API Overview
This guide explains how to integrate the Know Your Customer Limited Public API v2: the concepts behind it, then the tasks you will perform, with runnable examples throughout. Read it top to bottom the first time, then use it as a reference.
If you want to make your first call in ten minutes, start with the Quickstart and come back here for depth.
Introduction
The Know Your Customer Public API lets you build KYB (know your business) directly into your product. It verifies companies against live connections to the official company registries in 147 jurisdictions, the broadest coverage in the industry, covering 100% of legally registered entities in each connected jurisdiction. KYC (know your customer) on a company’s owners and directors is included, so you can verify the individuals behind a business in the same flow. It is a REST API used by banks, virtual banks, payment companies, and the many other organisations that need live, authoritative data on companies and who owns them.
A single API covers the full KYB and ongoing-monitoring lifecycle:
- Verify a company against its official registry, live at verification time, and read structured entity data: name, registration number, status, addresses, and officers.
- Retrieve the fresh underlying registry documents (registry extracts, certificates of incorporation, annual returns, and more).
- Resolve who ultimately owns and controls the company (beneficial ownership and the recursive org-chart).
- Screen the entity and its associated individuals against sanctions, PEP, and adverse-media lists.
- Run KYC on the company’s UBOs and directors: collect and check their identity documents (a complementary add-in, using third-party IDV).
- Produce an audit-ready report.
- Monitor the customer continuously after onboarding and act on alerts.
You do all of this through one resource, the case, and a small set of predictable endpoints. The API is asynchronous where the work takes time: you create a case, then poll until it is ready.
Sandbox and production
Know Your Customer runs a free sandbox at https://api.knowyourcustomer.dev. It is a high-fidelity, contract-faithful replica of the standard KYC platform: same contract, same response shapes, same status progression, pre-loaded with real public-registry companies and synthetic individuals. You build against the sandbox for free, with no live registry charges, then switch base URL and credentials to go to production. See Going to production and the Sandbox page.
When you are ready to write code, jump to the Quickstart or to Verify a company, end to end.
Core concepts
This section is the mental model. The rest of the guide is tasks built on these ideas, so it is worth reading once even if you skim later.
The case
A case is the unit of work. Everything you do happens inside a case.
There are two kinds:
- An entity (company) case: a corporate customer you are verifying (KYB).
- An individual case: a person you are verifying (KYC).
Every case has a unique identifier, the caseCommonId, returned when you create it. You use caseCommonId for every later call about that case: reading its status, listing its members, uploading documents, fetching its report.
A company case is rich: when you create it, Know Your Customer pulls the company's registry record, discovers its owners, builds the ownership tree, and runs screening. An individual case is simpler and centres on identity documents and AML screening.
Case lifecycle and statuses
A case is built asynchronously. When you create a company case, the API starts a background process that fetches the registry record, runs checks, and assembles the ownership structure. This takes time, from seconds to minutes depending on the jurisdiction. You do not get the finished case in the create response. You poll the case until it is ready.
A case moves through numeric statuses. The typical progression is:
0 Initializing
50 Queued for build
51 Fetching registry record
then a varying subset of:
53 (build sub-step)
54 (build sub-step)
9 Google search
100 AML checks
107 Building
3 ReadyNot every case passes through every intermediate status. The subset between 51 and 3 varies by jurisdiction and by what the case requires. Do not hard-code an expectation that, for example, status 100 always appears. Treat the intermediate statuses as informational and key your logic off the terminal state.
Readiness means status 3 and the case structure populated (members and steps present). Poll until you see status 3, then read the result. The full table is in the Reference appendix.
The complete set of statuses you will observe in practice is {0, 3, 9, 50, 51, 53, 54, 100, 107}.
Jurisdictions and registries
A company case is built against the company's home registry. The registry, and therefore the data and the processing time, depends on the jurisdiction. Some jurisdictions return in seconds; others take minutes; a few take longer. Build your polling around this (see Poll until ready).
You identify a jurisdiction with an ISO 3166-2 country code in the codeiso31662 field, for example GB, SG, or HK. Supplying the country when you create a case helps Know Your Customer route to the correct registry.
Members and ownership (UBO)
A company is rarely owned by one person. The API resolves a company's controlling entities and individuals and exposes them as controllingEntitiesAndIndividuals on the case.
Ownership is also exposed as a recursive org-chart: a root company node with nested shareholders. A shareholder can itself be a company, which in turn is owned by other companies or individuals. This is how the API represents multi-level beneficial ownership: a corporate parent sitting above other corporates, with individuals (the ultimate beneficial owners, or UBOs) at the leaves.
Each member carries a memberType field, a string that is either "Individual" or "Company". Each individual member has its own caseCommonId. You can address that individual as a case in its own right at /v2/Individuals/{caseCommonId}, for example to collect their identity documents.
The worked multi-level example is CROPWELL BISHOP CREAMERY LIMITED in the company walkthrough.
Steps
A case is made of steps. A step is one unit of verification work: retrieving the registry record, collecting a document, running AML, running a Google search, building the structure. Each step has a caseStepId.
Some steps are deactivated (isDeactivated = true). A step is deactivated when full verification is not required for it, for example a minority shareholder below your configured shareholding threshold, or an entity already screened clear against AML and sanctions lists. Deactivated steps have still been screened; they simply do not require full KYC. Where you do need full verification, you can activate a deactivated step. See Beneficial ownership and individuals.
Documents and prevalidation
Each case has mandatory documents:
- A company case needs registry documents.
- An individual case needs
photoid,selfie, andpoa(proof of address).
You upload documents with a multipart request. You can prevalidate: the API inspects the uploaded file and returns messages telling you whether it is acceptable. For example, an unrecognised or forged identity document returns a prevalidation message such as IdentityDocumentNotRecognized. Route identity documents to the individual's case and registry or board documents to the company case. Full contract in Documents.
AML screening
AML screening (sanctions, PEP, and adverse-media) runs as part of a case and surfaces as results you can read. You can review a hit, exclude a false positive, and recompute screening. After onboarding, live monitoring keeps screening the case and raises alerts that you list, inspect, and action. See AML and ongoing monitoring.
Reports
When a case is complete, you can obtain an audit-ready PDF report of it. The report captures the verified structure, the checks performed, and their outcomes, suitable for your audit trail. In the sandbox, reports are for evaluation only. See Reports.
Live monitoring
Live monitoring is ongoing screening after a customer is onboarded. New sanctions, PEP, or adverse-media matches raise alerts. You list alerts, fetch the detail of one, and action it. You also set a review date on a case to schedule periodic re-review. See AML and ongoing monitoring.
Getting access and authentication
Request access
To use the sandbox, request access. You sign the Sandbox Testing Agreement and receive sandbox client credentials: a client_id and a client_secret, plus the token URL and base URL. Production credentials are issued separately as part of commercial onboarding.
OAuth2 client-credentials (primary)
Authentication uses the OAuth2 client-credentials grant. You exchange your client_id and client_secret for a short-lived bearer token, then send that token on every API call.
Request a token by POSTing to the token endpoint:
grant_type=client_credentialsclient_id=<your client id>client_secret=<your client secret>scope=PublicApi
The response contains an access_token with a time-to-live of about ten minutes. Send it on every request in the Authorization header:
Authorization: Bearer <access_token>When the token expires you will get a 401. Request a fresh token and retry. A robust client caches the token and refreshes it shortly before the ten-minute expiry rather than on every call.
# Exchange client credentials for a bearer token (~10 min TTL)
TOKEN=$(curl -fsS -X POST "$BASE_URL/connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "scope=PublicApi" | jq -r '.access_token')
# Send it on every request:
# -H "Authorization: Bearer $TOKEN"import requests
BASE_URL = "https://api.knowyourcustomer.dev" # free Sandbox
resp = requests.post(
f"{BASE_URL}/connect/token",
data={
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"scope": "PublicApi",
},
timeout=30,
)
resp.raise_for_status()
token = resp.json()["access_token"] # ~10 min TTL
headers = {"Authorization": f"Bearer {token}"}const BASE_URL = "https://api.knowyourcustomer.dev"; // free Sandbox
const body = new URLSearchParams({
grant_type: "client_credentials",
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
scope: "PublicApi",
});
const res = await fetch(`${BASE_URL}/connect/token`, { method: "POST", body });
if (!res.ok) throw new Error(`token failed: ${res.status}`);
const { access_token } = await res.json(); // ~10 min TTL
const headers = { Authorization: `Bearer ${access_token}` };Customer API Key (legacy, secondary)
A legacy static Customer API Key header also exists and is unique to each customer. It is supported for older integrations. New integrations should use OAuth2 client-credentials, which is the primary and recommended scheme. If you do use the API key, send it as the customer API key header instead of the bearer token.
Base URLs
| Environment | Base URL |
|---|---|
| Sandbox | https://api.knowyourcustomer.dev |
| Production Europe | https://api.knowyourcustomer.com |
| Production Asia | https://api-asia.knowyourcustomer.com |
The contract is identical across all three. To move from sandbox to production you change the base URL and the credentials; the request and response shapes are the same.
Verify a company (KYB), end to end
This is the flagship walkthrough: search for a company, create a case, poll until ready, and read the verified result including the ownership structure. The full multi-language code for this journey is below.
#!/usr/bin/env bash
#
# KYC Public API v2: full onboarding journey (curl + bash)
#
# Journey: token -> search -> create -> poll to Ready -> members + org-chart
# -> get an individual member -> upload document (good vs forged)
# -> AML view -> close (Approved) -> download report PDF
#
# Requires: bash, curl, jq
# Usage: CLIENT_ID=... CLIENT_SECRET=... ./journey.sh
#
set -euo pipefail
BASE_URL="${BASE_URL:-https://api.knowyourcustomer.dev}" # free Sandbox
CLIENT_ID="${CLIENT_ID:-YOUR_CLIENT_ID}"
CLIENT_SECRET="${CLIENT_SECRET:-YOUR_CLIENT_SECRET}"
# What we want to onboard
ISO="GB"
QUERY="CROPWELL BISHOP"
# --- 1. Get an access token -------------------------------------------------
echo "==> Requesting access token"
TOKEN=$(curl -fsS -X POST "$BASE_URL/connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "scope=PublicApi" | jq -r '.access_token')
auth=(-H "Authorization: Bearer $TOKEN")
# --- 2. Search the registry -------------------------------------------------
echo "==> Searching registry for '$QUERY' in $ISO"
RAWNAME=$(curl -fsS -X POST "$BASE_URL/v2/Companies/search" "${auth[@]}" \
-H "Content-Type: application/json" \
-d "{\"codeiso31662\":\"$ISO\",\"query\":\"$QUERY\"}" \
| jq -r '.companySearch.searchResults[0].rawname')
echo " matched: $RAWNAME"
# --- 3. Create the company case ---------------------------------------------
echo "==> Creating company case"
CASE_ID=$(curl -fsS -X POST "$BASE_URL/v2/Companies" "${auth[@]}" \
-H "Content-Type: application/json" \
-d "{\"rawname\":\"$RAWNAME\",\"codeiso31662\":\"$ISO\"}" \
| jq -r '.caseDetail.details.common.caseCommonId')
echo " caseCommonId: $CASE_ID"
# --- 4. Poll until Ready (statusId == 3) ------------------------------------
echo "==> Polling for Ready"
for i in $(seq 1 60); do
STATUS=$(curl -fsS "$BASE_URL/v2/Companies/$CASE_ID" "${auth[@]}" \
| jq -r '.caseDetail.details.common.statusId')
echo " statusId=$STATUS"
if [ "$STATUS" = "3" ]; then break; fi
if [ "$STATUS" = "8" ]; then echo " case expired/failed"; exit 1; fi
sleep 5
done
# --- 5. Members + org-chart -------------------------------------------------
echo "==> Fetching members"
MEMBERS=$(curl -fsS "$BASE_URL/v2/Companies/$CASE_ID/members" "${auth[@]}")
echo "$MEMBERS" | jq '{controlling: (.controllingEntitiesAndIndividuals | length),
shareholders: (.shareholdersAndBeneficialOwners | length)}'
echo "==> Fetching org-chart (recursive ownership tree)"
curl -fsS "$BASE_URL/v2/Companies/$CASE_ID/org-chart" "${auth[@]}" \
| jq '{root: .name, shareholders: [.shareholders[]?.name]}'
# Pick the first individual controlling party (memberType == "Individual")
INDIV_ID=$(echo "$MEMBERS" \
| jq -r 'first(.controllingEntitiesAndIndividuals[] | select(.memberType=="Individual") | .member.caseCommonId)')
echo " individual member caseCommonId: $INDIV_ID"
# --- 6. Get the individual + its mandatory docs -----------------------------
echo "==> Fetching individual member $INDIV_ID"
curl -fsS "$BASE_URL/v2/Individuals/$INDIV_ID" "${auth[@]}" \
| jq '.caseDetail.details.common | {caseCommonId, statusId, statusName}'
echo "==> Mandatory documents for the individual"
curl -fsS "$BASE_URL/v2/Individuals/$INDIV_ID/documents/mandatory" "${auth[@]}" | jq .
# typically: ["photoid","selfie","poa"]
# --- 7. Upload a document (multipart): good then forged ---------------------
# Replace ./passport.jpg / ./forged.jpg with real files when running.
echo "==> Uploading a (good) identity document"
curl -fsS -X POST "$BASE_URL/v2/Individuals/$INDIV_ID/documents/upload" "${auth[@]}" \
-F "file=@./passport.jpg" \
-F "name=Passport" \
-F "fileCat=photoid" \
-F "caseCommonId=$INDIV_ID" \
-F "isCompany=false" \
| jq '{caseDocumentId, category, prevalidationMessages}'
# Good document -> prevalidationMessages: []
echo "==> Uploading a (forged) identity document, expect a prevalidation message"
curl -fsS -X POST "$BASE_URL/v2/Individuals/$INDIV_ID/documents/upload" "${auth[@]}" \
-F "file=@./forged.jpg" \
-F "name=Passport (forged)" \
-F "fileCat=photoid" \
-F "caseCommonId=$INDIV_ID" \
-F "isCompany=false" \
| jq '.prevalidationMessages'
# Forged document -> [{ "key": "IdentityDocumentNotRecognized", ... }]
# --- 8. AML view ------------------------------------------------------------
echo "==> AML checks for the company"
curl -fsS "$BASE_URL/v2/Companies/$CASE_ID/amlchecks" "${auth[@]}" \
| jq '{worldChecks: (.worldChecks | length), lexisNexisChecks: (.lexisNexisChecks | length)}'
# --- 9. Close the case with a decision --------------------------------------
echo "==> Recording decision: Approved"
curl -fsS -X PATCH "$BASE_URL/v2/Companies/$CASE_ID/status" "${auth[@]}" \
-H "Content-Type: application/json" \
-d '{"status":"Approved"}' | jq .
# --- 10. Download the KYC report PDF ----------------------------------------
echo "==> Downloading KYC report PDF"
curl -fsS "$BASE_URL/v2/Companies/$CASE_ID/report?language=en" "${auth[@]}" \
-o "kyc-report-$CASE_ID.pdf"
echo " saved kyc-report-$CASE_ID.pdf"
echo "==> Done."
#!/usr/bin/env python3
"""
KYC Public API v2 - full onboarding journey (Python + requests)
Journey: token -> search -> create -> poll to Ready -> members + org-chart
-> get an individual member -> upload document (good vs forged)
-> AML view -> close (Approved) -> download report PDF
Requires: pip install requests
Usage: CLIENT_ID=... CLIENT_SECRET=... python journey.py
"""
import os
import sys
import time
import requests
BASE_URL = os.environ.get("BASE_URL", "https://api.knowyourcustomer.dev") # free Sandbox
CLIENT_ID = os.environ.get("CLIENT_ID", "YOUR_CLIENT_ID")
CLIENT_SECRET = os.environ.get("CLIENT_SECRET", "YOUR_CLIENT_SECRET")
ISO = "GB"
QUERY = "CROPWELL BISHOP"
class KycClient:
"""Minimal client with an in-memory token cache and refresh-on-401."""
def __init__(self, base_url, client_id, client_secret):
self.base_url = base_url.rstrip("/")
self.client_id = client_id
self.client_secret = client_secret
self._token = None
self._expires_at = 0.0
def _token_value(self):
if self._token and time.time() < self._expires_at - 30:
return self._token
resp = requests.post(
f"{self.base_url}/connect/token",
data={
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_secret": self.client_secret,
"scope": "PublicApi",
},
timeout=30,
)
resp.raise_for_status()
body = resp.json()
self._token = body["access_token"]
self._expires_at = time.time() + int(body.get("expires_in", 600))
return self._token
def request(self, method, path, **kwargs):
url = f"{self.base_url}{path}"
headers = kwargs.pop("headers", {})
headers["Authorization"] = f"Bearer {self._token_value()}"
resp = requests.request(method, url, headers=headers, timeout=120, **kwargs)
if resp.status_code == 401: # token may have expired mid-journey - refresh once
self._token = None
headers["Authorization"] = f"Bearer {self._token_value()}"
resp = requests.request(method, url, headers=headers, timeout=120, **kwargs)
return resp
def main():
client = KycClient(BASE_URL, CLIENT_ID, CLIENT_SECRET)
# 2. Search the registry
print(f"==> Searching registry for {QUERY!r} in {ISO}")
r = client.request("POST", "/v2/Companies/search",
json={"codeiso31662": ISO, "query": QUERY})
r.raise_for_status()
results = r.json()["companySearch"]["searchResults"]
if not results:
sys.exit("No registry results")
rawname = results[0]["rawname"]
print(f" matched: {rawname}")
# 3. Create the company case
print("==> Creating company case")
r = client.request("POST", "/v2/Companies",
json={"rawname": rawname, "codeiso31662": ISO})
r.raise_for_status()
case_id = r.json()["caseDetail"]["details"]["common"]["caseCommonId"]
print(f" caseCommonId: {case_id}")
# 4. Poll until Ready (statusId == 3)
print("==> Polling for Ready")
for _ in range(60):
r = client.request("GET", f"/v2/Companies/{case_id}")
r.raise_for_status()
status = r.json()["caseDetail"]["details"]["common"]["statusId"]
print(f" statusId={status}")
if status == 3:
break
if status == 8:
sys.exit("Case expired/failed")
time.sleep(5)
# 5. Members + org-chart
print("==> Fetching members")
members = client.request("GET", f"/v2/Companies/{case_id}/members").json()
controlling = members.get("controllingEntitiesAndIndividuals", [])
print(f" controlling parties: {len(controlling)}, "
f"shareholders: {len(members.get('shareholdersAndBeneficialOwners', []))}")
print("==> Fetching org-chart (recursive ownership tree)")
org = client.request("GET", f"/v2/Companies/{case_id}/org-chart").json()
def walk(node, depth=0):
print(" " + " " * depth + f"- {node.get('name')} "
f"({node.get('effectivePercentage')}%)")
for child in (node.get("shareholders") or []):
walk(child, depth + 1)
for child in (node.get("officers") or []):
walk(child, depth + 1)
walk(org)
# memberType is a STRING ("Individual" / "Company")
individual = next((m for m in controlling if m.get("memberType") == "Individual"), None)
if not individual:
sys.exit("No individual controlling party found")
indiv_id = individual["member"]["caseCommonId"]
print(f" individual member caseCommonId: {indiv_id}")
# 6. Get the individual + mandatory docs
print(f"==> Fetching individual member {indiv_id}")
client.request("GET", f"/v2/Individuals/{indiv_id}").raise_for_status()
mandatory = client.request(
"GET", f"/v2/Individuals/{indiv_id}/documents/mandatory").json()
print(f" mandatory docs: {mandatory}") # typically ["photoid","selfie","poa"]
# 7. Upload a document (multipart) - good then forged
def upload(file_path, name, label):
with open(file_path, "rb") as fh:
r = client.request(
"POST", f"/v2/Individuals/{indiv_id}/documents/upload",
data={
"name": name,
"fileCat": "photoid",
"caseCommonId": str(indiv_id),
"isCompany": "false",
},
files={"file": (os.path.basename(file_path), fh,
"application/octet-stream")},
)
r.raise_for_status()
msgs = r.json().get("prevalidationMessages", [])
print(f" {label}: prevalidationMessages={msgs}")
print("==> Uploading a (good) identity document")
upload("./passport.jpg", "Passport", "good") # -> []
print("==> Uploading a (forged) identity document")
upload("./forged.jpg", "Passport (forged)", "forged") # -> [{key: IdentityDocumentNotRecognized}]
# 8. AML view
print("==> AML checks for the company")
aml = client.request("GET", f"/v2/Companies/{case_id}/amlchecks").json()
print(f" worldChecks={len(aml.get('worldChecks', []))}, "
f"lexisNexisChecks={len(aml.get('lexisNexisChecks', []))}")
# 9. Close the case with a decision
print("==> Recording decision: Approved")
r = client.request("PATCH", f"/v2/Companies/{case_id}/status",
json={"status": "Approved"})
r.raise_for_status()
print(f" {r.json()}")
# 10. Download the KYC report PDF
print("==> Downloading KYC report PDF")
r = client.request("GET", f"/v2/Companies/{case_id}/report",
params={"language": "en"})
if r.status_code == 409:
sys.exit("Report not ready yet")
r.raise_for_status()
out = f"kyc-report-{case_id}.pdf"
with open(out, "wb") as fh:
fh.write(r.content)
print(f" saved {out}")
print("==> Done.")
if __name__ == "__main__":
main()
/**
* KYC Public API v2 - full onboarding journey (Node / TypeScript, fetch)
*
* Journey: token -> search -> create -> poll to Ready -> members + org-chart
* -> get an individual member -> upload document (good vs forged)
* -> AML view -> close (Approved) -> download report PDF
*
* Requires: Node 18+ (built-in fetch, FormData, Blob).
* Run: CLIENT_ID=... CLIENT_SECRET=... npx tsx journey.ts
* (or compile with `tsc` and run with node)
*/
import { readFile, writeFile } from "node:fs/promises";
const BASE_URL = process.env.BASE_URL ?? "https://api.knowyourcustomer.dev"; // free Sandbox
const CLIENT_ID = process.env.CLIENT_ID ?? "YOUR_CLIENT_ID";
const CLIENT_SECRET = process.env.CLIENT_SECRET ?? "YOUR_CLIENT_SECRET";
const ISO = "GB";
const QUERY = "CROPWELL BISHOP";
/** Minimal client with an in-memory token cache and refresh-on-401. */
class KycClient {
private token: string | null = null;
private expiresAt = 0;
constructor(
private baseUrl: string,
private clientId: string,
private clientSecret: string,
) {}
private async tokenValue(): Promise<string> {
if (this.token && Date.now() < this.expiresAt - 30_000) return this.token;
const body = new URLSearchParams({
grant_type: "client_credentials",
client_id: this.clientId,
client_secret: this.clientSecret,
scope: "PublicApi",
});
const res = await fetch(`${this.baseUrl}/connect/token`, { method: "POST", body });
if (!res.ok) throw new Error(`token failed: ${res.status} ${await res.text()}`);
const json = (await res.json()) as { access_token: string; expires_in?: number };
this.token = json.access_token;
this.expiresAt = Date.now() + (json.expires_in ?? 600) * 1000;
return this.token;
}
async request(method: string, path: string, init: RequestInit = {}): Promise<Response> {
const headers = new Headers(init.headers);
headers.set("Authorization", `Bearer ${await this.tokenValue()}`);
let res = await fetch(`${this.baseUrl}${path}`, { ...init, method, headers });
if (res.status === 401) {
// token may have expired mid-journey - refresh once
this.token = null;
headers.set("Authorization", `Bearer ${await this.tokenValue()}`);
res = await fetch(`${this.baseUrl}${path}`, { ...init, method, headers });
}
return res;
}
async json<T = any>(method: string, path: string, init: RequestInit = {}): Promise<T> {
const res = await this.request(method, path, init);
if (!res.ok) throw new Error(`${method} ${path} -> ${res.status} ${await res.text()}`);
return (await res.json()) as T;
}
}
interface OrgNode {
name?: string;
effectivePercentage?: number | null;
shareholders?: OrgNode[] | null;
officers?: OrgNode[] | null;
others?: OrgNode[] | null;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function main(): Promise<void> {
const client = new KycClient(BASE_URL, CLIENT_ID, CLIENT_SECRET);
// 2. Search the registry
console.log(`==> Searching registry for '${QUERY}' in ${ISO}`);
const search = await client.json("POST", "/v2/Companies/search", {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ codeiso31662: ISO, query: QUERY }),
});
const rawname: string = search.companySearch.searchResults[0].rawname;
console.log(` matched: ${rawname}`);
// 3. Create the company case
console.log("==> Creating company case");
const created = await client.json("POST", "/v2/Companies", {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rawname, codeiso31662: ISO }),
});
const caseId: number = created.caseDetail.details.common.caseCommonId;
console.log(` caseCommonId: ${caseId}`);
// 4. Poll until Ready (statusId === 3)
console.log("==> Polling for Ready");
for (let i = 0; i < 60; i++) {
const c = await client.json("GET", `/v2/Companies/${caseId}`);
const status: number = c.caseDetail.details.common.statusId;
console.log(` statusId=${status}`);
if (status === 3) break;
if (status === 8) throw new Error("Case expired/failed");
await sleep(5000);
}
// 5. Members + org-chart
console.log("==> Fetching members");
const members = await client.json("GET", `/v2/Companies/${caseId}/members`);
const controlling: any[] = members.controllingEntitiesAndIndividuals ?? [];
console.log(
` controlling parties: ${controlling.length}, ` +
`shareholders: ${(members.shareholdersAndBeneficialOwners ?? []).length}`,
);
console.log("==> Fetching org-chart (recursive ownership tree)");
const org = await client.json<OrgNode>("GET", `/v2/Companies/${caseId}/org-chart`);
const walk = (node: OrgNode, depth = 0): void => {
console.log(` ${" ".repeat(depth)}- ${node.name} (${node.effectivePercentage}%)`);
for (const child of node.shareholders ?? []) walk(child, depth + 1);
for (const child of node.officers ?? []) walk(child, depth + 1);
};
walk(org);
// memberType is a STRING ("Individual" / "Company")
const individual = controlling.find((m) => m.memberType === "Individual");
if (!individual) throw new Error("No individual controlling party found");
const indivId: number = individual.member.caseCommonId;
console.log(` individual member caseCommonId: ${indivId}`);
// 6. Get the individual + mandatory docs
console.log(`==> Fetching individual member ${indivId}`);
await client.json("GET", `/v2/Individuals/${indivId}`);
const mandatory = await client.json("GET", `/v2/Individuals/${indivId}/documents/mandatory`);
console.log(` mandatory docs: ${JSON.stringify(mandatory)}`); // ["photoid","selfie","poa"]
// 7. Upload a document (multipart) - good then forged
const upload = async (filePath: string, name: string, label: string): Promise<void> => {
const bytes = await readFile(filePath);
const form = new FormData();
form.set("file", new Blob([bytes]), filePath.split("/").pop());
form.set("name", name);
form.set("fileCat", "photoid");
form.set("caseCommonId", String(indivId));
form.set("isCompany", "false");
const res = await client.json("POST", `/v2/Individuals/${indivId}/documents/upload`, {
body: form,
});
console.log(` ${label}: prevalidationMessages=${JSON.stringify(res.prevalidationMessages)}`);
};
console.log("==> Uploading a (good) identity document");
await upload("./passport.jpg", "Passport", "good"); // -> []
console.log("==> Uploading a (forged) identity document");
await upload("./forged.jpg", "Passport (forged)", "forged"); // -> [{key:"IdentityDocumentNotRecognized"}]
// 8. AML view
console.log("==> AML checks for the company");
const aml = await client.json("GET", `/v2/Companies/${caseId}/amlchecks`);
console.log(
` worldChecks=${(aml.worldChecks ?? []).length}, ` +
`lexisNexisChecks=${(aml.lexisNexisChecks ?? []).length}`,
);
// 9. Close the case with a decision
console.log("==> Recording decision: Approved");
const closed = await client.json("PATCH", `/v2/Companies/${caseId}/status`, {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "Approved" }),
});
console.log(` ${JSON.stringify(closed)}`);
// 10. Download the KYC report PDF
console.log("==> Downloading KYC report PDF");
const res = await client.request("GET", `/v2/Companies/${caseId}/report?language=en`);
if (res.status === 409) throw new Error("Report not ready yet");
if (!res.ok) throw new Error(`report -> ${res.status}`);
const pdf = Buffer.from(await res.arrayBuffer());
const out = `kyc-report-${caseId}.pdf`;
await writeFile(out, pdf);
console.log(` saved ${out}`);
console.log("==> Done.");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
The steps are described next so you understand what each call does.
Step 1: Search for the company
Search returns the registry matches for a name or registration number. You search so you can pick the exact record, because the create call must use a name that matches a search result.
POST /v2/Companies/search
Search by substring of the name or by registration number, optionally narrowing by country (codeiso31662). For example, searching cropwell bishop in GB returns CROPWELL BISHOP CREAMERY LIMITED with its registration number 00364890.
Pick the result you want and keep its exact rawname and registration number for the next step. If several companies share a similar name, the registration number is the reliable discriminator.
Step 2: Create the case
POST /v2/Companies
| Field | Required | Notes |
|---|---|---|
rawname | Yes | Must match the chosen search result. |
codeiso31662 | Optional | ISO country code, for example GB. Helps route to the correct registry. |
externalCode | Optional, recommended | The registration number (for example 00364890). Supply it wherever available for an exact registry match. |
The response includes the new caseCommonId. The case is now building in the background. Do not expect the structure in this response; move to polling.
{
"caseCommonId": "a1b2c3d4-...."
}Step 3: Poll until ready
GET /v2/Companies/{caseCommonId}
Poll the case and read its status. Keep polling until status is 3 (Ready) and the structure is populated.
Recommended polling: poll every 3 to 5 seconds with a sensible overall timeout. Because some jurisdictions take minutes, use a backoff (for example, start at 3 seconds and grow towards 15 seconds) and cap the total wait based on the jurisdiction. Do not poll in a tight loop. Treat statuses other than 3 as in-progress and keep waiting.
If a case is still not ready after your timeout, keep the caseCommonId and poll again later rather than recreating the case.
If you would rather not poll at all, register a webhook for the CaseReady event: the API pushes a message to your endpoint the moment a case finishes building, even if you never poll it. Polling suits short-lived scripts; webhooks suit running at scale or monitoring many cases over time.
Step 4: Read the result
Once the case is ready, read its properties, members, and org-chart.
GET /v2/Companies/{caseCommonId} returns the case properties (name, registration number, jurisdiction, status) and the ownership data.
Members are exposed as controllingEntitiesAndIndividuals. The org-chart is the recursive structure: a root company node with nested shareholders, each of which carries a memberType of "Company" or "Individual".
CROPWELL BISHOP CREAMERY LIMITED is a good multi-level example. Its structure shows a corporate parent above individual shareholders, so the org-chart has more than one level: the root company, a holding company beneath it, and individuals (the UBOs) beneath that. Walk the shareholders arrays recursively to build the full tree, and read each node's memberType to decide whether it is a company to expand further or an individual to verify.
To verify an individual member, take their caseCommonId and address them at /v2/Individuals/{caseCommonId}. See Beneficial ownership and individuals next.
Beneficial ownership and individuals
Members versus the org-chart
There are two views of ownership, and they answer different questions:
controllingEntitiesAndIndividualsis the flat list of the parties who control the company. Use it when you want the set of controllers without the hierarchy.- The org-chart is the recursive tree (root company, nested
shareholders). Use it when you need the structure: who owns whom, across levels.
Recursion
Ownership can nest several levels deep. A company can be owned by another company, which is owned by individuals. To find the ultimate beneficial owners, walk the tree: for each node, check memberType; if it is "Company", descend into its shareholders; if it is "Individual", you have reached a leaf owner.
Thresholds and deactivated minority steps
Not every shareholder requires full verification. Minority shareholders below your configured shareholding threshold are represented by deactivated steps (isDeactivated = true). They have been screened against AML and sanctions lists and are clear; they qualify for simplified due diligence. Entities already screened clear are likewise deactivated. This keeps the case focused on the parties that matter.
Addressing an individual member
Each individual member has its own caseCommonId. Address that person as an individual case:
GET /v2/Individuals/{caseCommonId}
From there you can collect and check their identity documents (see Documents) and read their AML results.
Activating a step for full verification
Where you do need full verification of a party whose step is deactivated, activate that step. Activation moves the step into the active set so it is fully verified rather than treated under simplified due diligence. Identify the step by its caseStepId (list a case's steps to find it) and activate it. After activation, collect documents and complete verification for that party as normal.
Documents
Mandatory documents per case type
| Case type | Mandatory documents |
|---|---|
| Company | Registry documents |
| Individual | photoid, selfie, poa |
Route documents to the correct case: identity documents (photoid, selfie, poa) go to the individual's case; registry and board documents go to the company case.
Prevalidation
You can prevalidate a document so the API checks it before you rely on it. Prevalidation returns messages in the prevalidationMessages field of the upload response. A clean document returns no blocking messages. An unrecognised or forged identity document returns a message such as IdentityDocumentNotRecognized. Treat prevalidation messages as the signal of whether the document is acceptable.
Upload (multipart contract)
Document upload is a multipart/form-data request with these fields:
| Field | Description |
|---|---|
file | The document file (binary). |
fileCat | The document category (for example photoid, selfie, poa, or a registry document category). |
name | A name for the document. |
caseCommonId | The case the document belongs to. |
isCompany | Whether this is a company case (true) or an individual case (false). |
createNewStep | Whether to create a new step for this document. |
The response is:
{
"caseDocumentId": "....",
"caseStepId": "....",
"category": "photoid",
"link": "https://....",
"prevalidationMessages": []
}caseDocumentId identifies the stored document; caseStepId is the step it was attached to; category echoes the document category; link is where the document can be retrieved; prevalidationMessages carries any validation findings such as IdentityDocumentNotRecognized.
Set isCompany to match the case the document belongs to. An identity document uploaded with isCompany=false and the individual's caseCommonId is routed to that person; a registry document uploaded with isCompany=true and the company's caseCommonId is routed to the company.
Download a document
Use the link returned in the upload response to retrieve a stored document. The link addresses the specific stored document for that case.
AML and ongoing monitoring
View AML results on a case
AML screening (sanctions, PEP, and adverse-media) is part of the case. Read the AML results on the case to see any hits, each with the matched list and the matched details. A case with no hits is clear.
Review, exclude, recompute
For each hit you can:
- Review it: record your assessment of whether the match is a true or false positive.
- Exclude it: dismiss a false positive so it no longer counts against the case, with your reason recorded for the audit trail.
- Recompute screening: re-run AML on the case, for example after new information or to refresh against updated lists.
Live-monitoring alerts
After onboarding, live monitoring re-screens the case and raises alerts when new matches appear.
- List alerts: retrieve the alerts, for triage.
- Alert detail: fetch one alert to see the matched party and the reason it was raised.
- Action an alert: resolve it, recording the outcome (for example confirm or dismiss the match). Actioning closes the alert with an audited decision.
Review dates
Set a review date on a case to schedule periodic re-review. The review date drives your ongoing-due-diligence cadence, for example reviewing a high-risk customer more frequently. Set, update, and read the review date on the case.
Reports
When a case is complete you can obtain its report, an audit-ready PDF of the verified case.
Obtain the report for a case by its caseCommonId. The report contains the case outcome: the verified company or individual details, the resolved ownership structure for a company, the documents collected, the AML screening result, and the checks performed, assembled for your audit file.
In the sandbox, reports are generated for evaluation only. They demonstrate the format and content but are not for production compliance use. Production reports, generated against live registry and screening data, are the audit-ready record.
Case management
Beyond building and reading a case, you manage it over its life.
- Steps: list a case's steps to see what verification it is made of and to find a
caseStepId. Read a step's detail for its status and findings, and update a step (for example mark it pass or fail) as you work through verification. - Step notes: attach notes to a step to record context or a decision for the audit trail.
- Assign a case to a user: assign a case to a named user so ownership is clear and visible in your workflow.
- Audit trail: every material action on a case is recorded. Read the case audit trail to see who did what and when.
- Update case info and status: update case information, and update the case status as you progress or conclude it (for example accept or reject).
- Close and delete: close a case when verification is complete; delete a case where appropriate. In the sandbox, deleting helps you reset your test data.
Going to production
What changes
The contract does not change. Sandbox and production share the same endpoints, request bodies, and response shapes. Moving to production means:
- Base URL: switch from
https://api.knowyourcustomer.devtohttps://api.knowyourcustomer.com(Europe) orhttps://api-asia.knowyourcustomer.com(Asia). - Credentials: use your production
client_idandclient_secret, issued on commercial onboarding, against the production token endpoint. - Data: production calls real registries and real screening lists. Registry calls incur charges, latency reflects real registries, and reports are the audit-ready record. The sandbox makes no live registry calls and uses synthetic individuals.
Obtaining production credentials
Production credentials are issued as part of commercial onboarding, separately from sandbox access. Contact Know Your Customer to begin (see Support below).
Rate limits and fair use
The API is rate-limited and subject to fair use. Build clients that cache the bearer token, poll with backoff rather than tight loops, and retry on transient errors with backoff. A 429 response means you are being rate-limited: back off and retry.
Support, status, and changelog
For help, contact help@knowyourcustomer.com. Breaking changes are versioned; the v2 contract is stable. Watch the API reference and changelog for additions.
Reference appendix
Status code table
| Status | Meaning |
|---|---|
| 0 | Initializing |
| 50 | Queued for build |
| 51 | Fetching registry record |
| 53 | Build sub-step |
| 54 | Build sub-step |
| 9 | Google search |
| 100 | AML checks |
| 107 | Building |
| 3 | Ready |
A case progresses 0 -> 50 -> 51 -> a varying subset of {53, 54, 9, 100, 107} -> 3. Readiness is status 3 with the structure populated. The intermediate set differs by case and jurisdiction; key your logic off 3, not off any single intermediate status. The full set observed in practice is {0, 3, 9, 50, 51, 53, 54, 100, 107}.
Error model
The API uses standard HTTP status codes.
| Code | Meaning | What to do |
|---|---|---|
| 200 / 201 | Success | Proceed. |
| 400 | Bad request | Fix the request body or parameters (for example a rawname that does not match a search result). |
| 401 | Unauthorized | Token missing or expired. Request a fresh token and retry. |
| 403 | Forbidden | Your credentials lack access to the resource. |
| 404 | Not found | Check the caseCommonId or caseStepId. |
| 409 | Conflict | The resource is in a state that does not allow the action. |
| 429 | Too many requests | You are rate-limited. Back off and retry. |
| 500 | Server error | Transient. Retry with backoff; if it persists, contact support. |
Error responses carry a message describing what went wrong. Log the full response body when diagnosing.
Glossary
| Term | Meaning |
|---|---|
| Full KYC case | An entity case with the complete ownership structure built, mandatory documents obtained, and AML performed; or an individual case with mandatory documents obtained and verified and AML performed. |
| Manual case | A case created for an unregistered entity, or without a live registry connection. |
| AML-only case | An individual or entity case for which only the AML check is completed. |
| caseCommonId | The unique identifier of a case. |
| caseStepId | The unique identifier of a step within a case. |
| Deactivated step | A step (isDeactivated = true) that does not require full verification: for example a minority shareholder below the shareholding threshold, or a party already screened clear. It has been screened against AML and sanctions lists. It can be activated for full verification where required. |
| Member | A party (company or individual) in a company's ownership structure. memberType is "Company" or "Individual". |
| UBO | Ultimate beneficial owner: an individual at the leaf of the ownership tree who ultimately owns or controls the company. |
