Table of contents
Editor's note:
The problem
KYC is not something you solve once. Every market has its own rules, and those rules change.
In Nigeria, an LLC needs CAC registration documents, at least two directors, and disclosure of any shareholder holding 25% or more. In Kenya, the same LLC structure looks different: different documents, different thresholds. In the UK, you're dealing with FCA-aligned requirements and a different set of acceptable identity documents altogether.
A fintech gets scrutinised more than a general retailer. A healthcare business has its own requirements on top of that. Layer in risk tiers, where high-risk merchants face stricter checks regardless of country, and you have a matrix that grows every time you enter a new market.
And the rules themselves don't sit still. A new CBN directive, an FCA update, a change in UBO disclosure thresholds somewhere. These land regularly, often on short notice.
The question we kept running into wasn't just how do we model these requirements, but how do we build something that can absorb a regulatory change without requiring an engineering deployment to go out?
Our answer: stop treating compliance rules as code.
The real scope of the problem
When most engineers first think about this, they think about documents. What does Nigeria require? What does Kenya require? That's reasonable, but it's only part of it.
The full picture of what varies across markets and merchant types includes:
- What documents are required, optional, or conditional
- What form fields to collect, and in what order
- What address format applies (a UK postcode lookup is structurally different from a Nigerian state/LGA selection)
- Whether to collect date of birth at all, since some markets require it and others don't
- What identity document options are acceptable, and what combinations satisfy the requirement
- What entity count rules apply: minimum directors, UBO thresholds, shareholder structures
- What validation rules apply to each field
All of this varies, all of it can change, and all of it has to stay consistent across your backend, your frontend, and what the merchant actually sees.
The shift in thinking that unlocked things for us: the backend should own the entire merchant-facing KYC experience. Not just validate what the frontend collects, but own what gets shown in the first place. The frontend becomes a renderer. It asks the API what to show, and shows it.
The config isn't a document checklist. It's a complete description of what a merchant should see, fill in, and submit for their specific context.
Why Conditional Logic Doesn't Scale
The first instinct is usually something like this:
if (country === 'NG' && businessType === 'LLC') {
requireDocument('cac_certificate');
requireDocument('memat');
requireMinDirectors(2);
showField('rc_number');
}
if (country === 'NG' && industry === 'fintech') {
requireDocument('cbn_licence');
}
if (country === 'GB') {
useAddressFormat('uk_postcode');
requireField('date_of_birth');
}
This is fine for one market. Maybe two. By the third, the function is hundreds of lines, untested as a whole, and nobody wants to touch it. More practically: every time a regulator updates something, your product team wants to tweak a form field, or you need to go live in a new region, you need a code change, a PR, a deployment, and a live window where something can go wrong.
Compliance timelines don't accommodate sprint planning. Neither do market expansion timelines.
The distinction we drew at Kora: the logic for evaluating and resolving requirements is code. The rules themselves are configuration. Configuration should live somewhere you can update without redeploying your application.
When you get this right, adding a new market is a configuration task, not an engineering project.
How to Think About the Dimensions
KYC requirements vary across four dimensions, and it's worth being explicit about them before thinking about how to model them.
Country is your base. Every country has baseline compliance requirements, acceptable identity documents, required form fields, and its own address format. Each country is its own starting point, not a variation on a common template.
Business type sits on top of country. An LLC in Nigeria and a sole proprietorship in Nigeria have different requirements. The same corporate structure in two countries often maps to completely different regulatory expectations, so business-type configs are always scoped per country.
Industry is additive. Fintech gets more scrutiny than retail. NGOs have their own requirements. This layer adds to what's already required and never removes anything the base or business-type layers set.
Risk tier is a global overlay. High-risk merchants face additional checks regardless of which market they're in: source of funds documentation, stricter identity minimums. This applies consistently across all countries.
The practical value of thinking in layers is that each one is independent and can change independently. A new CBN fintech directive only touches the Nigeria/fintech layer. A new global risk policy only touches the risk tier layer. Nothing else moves.
One way to model it
What follows is how we approached the implementation. The data structures and patterns here are illustrations, so adapt them to your stack, your data layer, and your scale.
A layered config store
The idea is a store of config entries, where each entry is scoped to a context (country, business type, industry, risk tier) and describes what applies in that context. One entry might cover all Nigerian merchants at the base level. Another covers Nigerian LLCs. Another covers Nigerian fintechs. They're independent and composable.
One way to model this in a relational database:
-- Illustrative schema, adapt to your storage and querying needs
CREATE TABLE kyc_requirement_sets (
id UUID PRIMARY KEY,
country_code VARCHAR(2),
business_type VARCHAR(50), -- NULL = applies to all business types in this country
industry VARCHAR(100), -- NULL = applies to all industries
risk_tier VARCHAR(20), -- NULL = applies to all risk tiers
config JSONB NOT NULL,
effective_from TIMESTAMPTZ NOT NULL,
effective_to TIMESTAMPTZ, -- supports scheduled regulatory updates
is_active BOOLEAN NOT NULL DEFAULT true
);
The config column carries everything the merchant should encounter for this context. A simplified Nigerian LLC example:
{
"form_fields": [
{ "key": "business_name", "type": "text", "label": "Registered Business Name", "required": true },
{ "key": "rc_number", "type": "text", "label": "RC Number", "required": true },
{ "key": "date_of_incorporation", "type": "date", "label": "Date of Incorporation", "required": true },
{ "key": "date_of_birth", "type": "date", "label": "Date of Birth", "required": true },
{
"key": "address",
"type": "address",
"label": "Business Address",
"required": true,
"format": "ng_state_lga",
"sub_fields": [
{ "key": "street", "type": "text", "label": "Street Address", "required": true },
{ "key": "city", "type": "text", "label": "City", "required": true },
{ "key": "lga", "type": "select", "label": "Local Government Area", "required": true },
{ "key": "state", "type": "select", "label": "State", "required": true }
]
}
],
"documents": {
"required": [
{ "type": "cac_certificate", "label": "CAC Certificate of Incorporation" },
{ "type": "memat", "label": "Memorandum and Articles of Association" }
],
"optional": [
{ "type": "utility_bill", "label": "Utility Bill (Business Address Verification)" }
]
},
"id_documents": {
"options": [
{ "type": "passport", "label": "International Passport" },
{ "type": "national_id", "label": "National ID Card" },
{ "type": "drivers_licence", "label": "Driver's Licence" }
],
"min_required": 1
},
"directors": { "min_count": 2 },
"shareholders": { "min_count": 1, "ubo_threshold": 25 }
}
A UK merchant gets a different base config with different fields, a different address structure, and different identity options:
{
"form_fields": [
{ "key": "company_name", "type": "text", "label": "Registered Company Name", "required": true },
{ "key": "company_number", "type": "text", "label": "Companies House Number", "required": true },
{ "key": "date_of_birth", "type": "date", "label": "Date of Birth", "required": true },
{
"key": "address",
"type": "address",
"label": "Business Address",
"required": true,
"format": "uk_postcode",
"sub_fields": [
{ "key": "line_1", "type": "text", "label": "Address Line 1", "required": true },
{ "key": "line_2", "type": "text", "label": "Address Line 2", "required": false },
{ "key": "city", "type": "text", "label": "City", "required": true },
{ "key": "postcode", "type": "text", "label": "Postcode", "required": true }
]
}
],
"documents": {
"required": [
{ "type": "certificate_of_incorporation", "label": "Certificate of Incorporation" }
]
},
"id_documents": {
"options": [
{ "type": "passport", "label": "Passport" },
{ "type": "driving_licence", "label": "UK Driving Licence" }
],
"min_required": 1
}
}
Each entry stands on its own. The engine pulls all matching entries for a merchant's context and merges them at runtime.
The resolution engine
When a merchant starts a KYC application, the engine takes their context (country, business type, industry, risk tier), fetches every matching config entry, and merges them into one resolved config. Additive fields like documents and form fields get unioned across layers.
Threshold fields like minimum director counts take the strictest value. More specific layers win over more general ones on conflicts.
Conceptually:
// Conceptual, implementation depends on your stack
async resolveConfig(context: MerchantContext): Promise<ResolvedConfig> {
const layers = await this.store.findActiveForContext(context);
return this.mergeLayers(layers);
}
Two layers or ten, the output shape is the same. Adding a new market means new config entries. A regulatory update is a new entry with a future effective_from, with the previous entry marked as superseded. No deployment. No incident window.
The Backend Owns the Frontend
The resolved config is what the backend returns. The frontend doesn't decide what fields to show, what documents to request, or how to render an address. It asks the API, and the API answers in full.
A KYC session endpoint might return something like this:
{
"form_fields": [
{ "key": "business_name", "type": "text", "label": "Registered Business Name", "required": true },
{ "key": "rc_number", "type": "text", "label": "RC Number", "required": true },
{ "key": "date_of_birth", "type": "date", "label": "Date of Birth", "required": true },
{
"key": "address",
"type": "address",
"label": "Business Address",
"required": true,
"format": "ng_state_lga",
"sub_fields": [
{ "key": "street", "type": "text", "label": "Street Address", "required": true },
{ "key": "city", "type": "text", "label": "City", "required": true },
{ "key": "lga", "type": "select", "label": "Local Government Area", "required": true },
{ "key": "state", "type": "select", "label": "State", "required": true }
]
}
],
"documents": [
{ "key": "cac_certificate", "label": "CAC Certificate of Incorporation", "status": "submitted", "required": true },
{ "key": "memat", "label": "Memorandum and Articles of Association", "status": "missing", "required": true },
{ "key": "cbn_licence", "label": "CBN Licence", "status": "missing", "required": true }
],
"entity_checks": {
"directors": { "required": 2, "provided": 1, "satisfied": false }
},
"can_submit": false,
"blocking_reasons": [
"Memorandum and Articles of Association is required",
"CBN Licence is required",
"At least 2 directors required. 1 provided."
]
}
can_submit controls the submit button. blocking_reasons drives the error state. form_fields tells the frontend exactly what to render, in what order, and how.
When requirements change for a market, the frontend picks it up automatically with no frontend deployment and no risk of the UI drifting from the actual rules.
One source of truth. Backend-owned. Fully expressed in the API response.
What we learned
Don't put logic in the config
The thing that can quietly break this pattern is letting configs grow complicated. If your JSON starts growing nested conditionals, you haven't solved the complexity problem. You've moved it somewhere harder to test and review.
Keep config declarative. It says what applies. The engine owns how things get evaluated. If you're writing conditional expressions inside a config file, that logic belongs in the engine, expressed as a named condition type the config can reference.
Validate at write time, not at runtime
Whatever format your configs take, validate them before they're stored. A broken config that surfaces while a merchant is halfway through their application is a worse problem than one caught when someone tries to save it. Schema validation and dry-run checks aren't glamorous, but they pay off.
Two things we'd do differently
Version configs as immutable records from the start. The ability to schedule a future config ("this takes effect when the new regulation comes into force on the 1st") is worth designing in early. Retrofitting it is messier than it sounds.
Give the compliance team access sooner. For too long, updating a config meant going through an engineer with database access. A simple internal tool with validation, diff preview, and scheduled activation gives compliance and operations teams direct ownership of the rules they understand better than anyone on the engineering team does. It also frees engineers from being the bottleneck on every regulatory update.
Neither of these is a regret exactly. They're just things we'd front-load if we were starting from scratch.
The broader point
The principle here is separating what the rules are from how rules are enforced, and extending that one step further to separate what the merchant experiences from how that experience is rendered.
When compliance rules live in code, the engineering team becomes a dependency for every market change. When they live in configuration, compliance and operations teams can move at their own speed. Engineering's job becomes building a system that absorbs change well, not one that never changes.
This thinking isn't specific to KYC. Anywhere you have rules that vary by context, update on a schedule, or need to be owned by people outside engineering, the same approach applies. The implementation will look different depending on your constraints. The thinking transfers.
If you're working through similar problems, we'd genuinely like to hear how you've approached it.





.png)



%201.png)
%201.png)

%201.png)
%201%20(1).png)