WritingCompliance
KYB onboarding: parallel checks, one risk decision, a full audit trail
How to design business onboarding that verifies a company and its owners quickly, sends only unclear cases to a reviewer, and can explain every decision later.
At Cellbunq I built a centralised KYB, KYC and AML compliance dashboard that brought verification checks and client risk data into one place, consolidating more than 10,000 data sources, and designed an express onboarding journey to reduce the friction of getting verified. Onboarding is where compliance and conversion meet head on: every question you add loses applicants, and every check you skip is a risk accepted without anyone deciding to accept it.
This post is about the design that reconciles the two. A business applies once, the system does the looking up, checks run in parallel where they can, and one explainable risk decision determines whether a person needs to look.
What KYB has to establish
Know Your Business is Know Your Customer applied to a company and to the people behind it. By the end of onboarding, you need to answer five questions with evidence:
| Question | Where the answer usually comes from |
|---|---|
| Does the company exist, and is it active? | The company registry in its country of incorporation |
| Who owns or controls it? | Registry filings and ownership registers, or the applicant where those are incomplete |
| Who acts for it? | Directors and officers, from the registry |
| Is anyone involved sanctioned or politically exposed? | Sanctions lists (UK, EU, UN and US) and PEP databases |
| Is the person applying who they say they are? | An identity document and a face match |
Ownership is usually the hardest. The people who count are the ultimate beneficial owners: in the UK and EU, broadly anyone holding more than 25 per cent of the shares or voting rights, or controlling the company in some other way. When one company owns another, the chain has to be followed until it reaches people.
Ask for less, fetch more
The fastest onboarding form is a short one. In an express journey the applicant gives the company's details and a director's identity document; almost everything else should be fetched rather than asked for.
A registry lookup returns the legal name, status, registered address, officers and, in many countries, the people with significant control. Pre-filling from it saves typing and improves quality, because registry data is more reliable than what someone types into a form in a hurry. Documents are requested only when a source is missing or incomplete, such as a shareholder register for a jurisdiction without a public ownership register.
Run checks as a workflow, not a queue
Most checks do not depend on one another, so there is no reason to run them in sequence. They do depend on the registry, though: the list of people to screen comes from it. That gives a simple dependency graph: look the company up, then fan out one set of checks per person.
flowchart TD
A[Application] --> R[Registry lookup]
A --> I[Identity check]
R --> C[Company status]
R --> P[Officers and owners]
P --> S[Sanctions screening]
P --> E[PEP screening]
C --> D{Risk decision}:::accent
S --> D
E --> D
I --> D
D --> OK[Approve]
D --> RV[Review]
D --> X[Decline]type Outcome = {
check: string;
subject: string;
status: "clear" | "flag" | "error";
detail?: string;
};
type Check = { name: string; subject: string; run: () => Promise<Outcome> };
export async function screen(application: Application): Promise<Outcome[]> {
const company = await registry.lookup(application.registrationNumber);
const people = [...company.officers, ...company.beneficialOwners];
const { applicant } = application;
const checks: Check[] = [
{ name: "company status", subject: company.name, run: () => companyStatus(company) },
...people.flatMap((person) => [
{ name: "sanctions", subject: person.name, run: () => sanctions.screen(person) },
{ name: "PEP", subject: person.name, run: () => pep.screen(person) },
]),
{ name: "identity", subject: applicant.name, run: () => identity.verify(applicant) },
];
const results = await Promise.allSettled(checks.map((c) => withTimeout(c.run(), 30_000)));
// A check that failed to run is an error for a person to look at, never a pass
return results.map((r, i) => {
if (r.status === "fulfilled") return r.value;
const { name, subject } = checks[i];
return { check: name, subject, status: "error", detail: String(r.reason) };
});
}Two details matter more than the parallelism. Every check has a timeout, so one slow provider cannot hold an application up indefinitely. And a check that fails to run is recorded as an error, never as a pass: an application whose sanctions check did not complete goes to a person, exactly like one with a match.
Screening produces candidates, not answers
Sanctions and PEP screening compare names against lists, and names are ambiguous. Transliteration, missing middle names and common surnames all produce matches that are not the same person. Tuned too strictly, screening misses real hits; tuned too loosely, it floods reviewers with false positives.
Secondary identifiers do most of the work. Date of birth and nationality discount many name matches automatically, and those that remain reach the reviewer with the reason attached: which list, which entry, which fields matched and which did not. A reviewer who can see why something matched decides quickly; one who has to repeat the search decides slowly and less consistently.
One risk decision, with reasons
The individual results then feed a single risk assessment. The inputs are familiar: the countries involved, the industry, how complex the ownership is, any screening matches, and how confident the identity check was. What matters most is the shape of the answer:
type Decision = {
outcome: "approve" | "review" | "decline";
score: number;
reasons: string[]; // in the words a compliance officer would use
rulesVersion: string;
};Low risk with every check clear is approved automatically. Anything else goes to a reviewer, who starts from the flag and its reasons rather than from scratch. Declines should be rare, and made by a person.
Keep a trail you can defend
Regulators ask two questions about onboarding: what did you know, and when did you know it. The system should answer both for any customer. That means keeping every check with its source, timestamp and a reference to the raw response; every reviewer action with who took it and why; and the rules version behind every automated decision.
erDiagram
CUSTOMER ||--o{ CHECK : "is screened by"
CHECK ||--|| SOURCE_RESPONSE : "keeps"
CUSTOMER ||--o{ DECISION : "receives"
DECISION }o--|| RULES_VERSION : "made under"
DECISION ||--o{ REVIEWER_ACTION : "may have"In the UK and EU, customer due diligence records generally have to be kept for five years after the relationship ends, so the trail has to outlive the customer's account.
Onboarding is not the end
A customer who was clear on the day they joined may not stay clear. Sanctions lists change, directors resign, and companies change hands. Ongoing monitoring re-screens customers when lists are updated, watches registries for changes to officers and ownership, and brings each customer back for review on a schedule set by their risk.
The same checks and the same audit trail serve onboarding and monitoring alike, which is the case for a centralised dashboard: one place to see every customer's current risk, and exactly how it got there.
Before onboarding goes live
- The form asks only for what the registry cannot supply
- The registry lookup runs first, and every other check fans out from it
- Every check has a timeout, and an error goes to a person, never through
- Screening matches reach the reviewer with the list, the entry and the fields that matched
- Every decision stores its reasons and the version of the rules it was made under
- Customers are re-screened when sanctions lists and registries change