Turn a prospect into an AML file, without retyping anything
Send a Belgian enterprise number. We look the company up in the registry, create the file prefilled, and hand you back a link to send your customer. When a compliance officer approves it, your system hears about it.
Generating your own client? The document behind that reference is served raw at /api/v2/openapi.json, and needs no credentials to read.
On this page
Quickstart
Three calls. The first two are yours to make; the third is ours to you.
curl -X POST https://amlcompany.com/api/auth/oauth2/token \
-d grant_type=client_credentials \
-d client_id="$CLIENT_ID" \
-d client_secret="$CLIENT_SECRET" \
-d scope='onboardings:read onboardings:write' \
-d resource='https://amlcompany.com/api/v2'curl -X POST https://amlcompany.com/api/v2/onboardings \
-H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{
"enterpriseNumber": "BE0675566990",
"serviceType": "DOMICILIATION",
"contact": { "email": "[email protected]" },
"externalRef": "crm:company:abc123"
}'POST https://your-app.example.com/hooks/aml
x-amlcompany-signature: v1=9f86d0818...
x-amlcompany-timestamp: 1786525800
x-amlcompany-event-id: cmf9x2k10000
{ "id": "cmf9x2k10000",
"kind": "onboarding.approved",
"data": { "subject": { "type": "onboarding", "id": "cmf3k2l0000abc" },
"status": "VALIDATED", "riskLevel": "LOW" } }Sandbox
Build against a test environment first. It is a separate organization on the same host, so nothing about your code changes: same endpoints, same tokens, same webhooks.
An administrator creates it from Paramètres → Intégrations → Créer un environnement de test. Its files are invisible to every other organization, so your test data can never land in someone's compliance figures.
A sandbox never calls the Belgian registry: your tests would otherwise depend on a third party and on data that changes underneath them. These numbers, and only these, work.
| Enterprise number | What happens |
|---|---|
BE0999999001 | A complete company, SANDBOX TEST SRL |
BE0999999002 | A company shaped to assess as higher risk |
BE0999999404 | Always 404, so you can test the path you must not retry |
BE0999999500 | Always 500, so you can test the path you must retry |
The last two are the point. You cannot make the real registry fail on demand, so without them both branches of your retry logic ship untested.
Authentication
OAuth2 client credentials. Tokens last an hour; cache them and refresh on expiry.
Always send resource. It names the API you intend to call. Omit it and you get an opaque token instead of a signed one, and every API call answers 401 with the code opaque_token.
That code is distinct from unauthorized on purpose: one means re-authenticate, the other means your configuration is wrong and retrying will not help.
| Scope | Grants |
|---|---|
onboardings:read | Consulter vos dossiers AML, leur statut et leur score |
onboardings:write | Créer des dossiers AML et y déposer des informations |
clients:read | Consulter la liste de vos clients |
clients:write | Créer et modifier des clients |
companies:read | Consulter les sociétés rattachées à vos dossiers |
register:read | Consulter le registre public des entreprises (BCE, SIRENE) : fiche, score, recherche |
register:write | Gérer les entreprises que vous surveillez dans le registre |
reports:read | Télécharger vos rapports AML |
verifications:read | Consulter le résultat des vérifications de gérant et l'identité vérifiée |
verifications:write | Demander la vérification d'un gérant d'entreprise et obtenir le lien à lui transmettre |
webhooks:manage | Gérer les notifications envoyées à cette application |
Ask for the narrowest set that does your job. webhooks:manage lets your application register its own webhooks; reports:read is declared and grants nothing.
Create a file
You send ten digits. We do the registry lookup, so you need no registry integration of your own.
The response carries a hostedUrl: a link for your customer, valid 30 days, revocable, and audited on every open. Send it by email; it is not for you.
Send externalRef with your own id. Calling again with the same value returns the existing file rather than creating a twin, and lets you find it later with ?externalRef=. Without it you have no way back from your record to ours.
Idempotency-Key covers something different: a request you retried because the connection dropped. The original response is replayed for 24 hours.
A 404 means the enterprise number does not exist: never retry it. A 500 means the registry is down: retry in a few minutes. Treating them alike breaks in one direction or the other.
Fill the file in
A file is not assessable until it names its beneficial owners and carries its documents. Collect both where your user already is.
POST /api/v2/onboardings/{id}/beneficiaries
{ "firstName": "Marie", "lastName": "Dupont",
"role": "Gérant", "percentage": 75,
"externalRef": "crm:company_person:42" }curl -X POST .../onboardings/{id}/documents \
-H "authorization: Bearer $TOKEN" \
-F [email protected] -F kind=IDENTITY_CARD -F beneficiaryId={ownerId}POST /api/v2/onboardings/{id}/submitName, role and share are enough to record an owner: send what you know, and the officer completes the rest, helped by the reading of the identity document attached to them. The file cannot be submitted while an owner lacks a birth date or an identity document type and number, and each owner's missing says which.
You cannot approve a file through this API, and you never will be able to. Accepting a business relationship is the obliged entity's own legal responsibility. Submitting can also fail with 409 when the file is incomplete, when its risk means the relationship must be reported to the authorities rather than approved, or when someone moved the file between your call and the write. Only the last one is worth retrying, after reading the file again.
Read a file
Status, risk and the linked company. Cursor paginated, newest first.
{
"id": "cmf3k2l0000abc",
"status": "VALIDATED",
"risk": { "level": "LOW", "score": 120, "max": 530, "ratio": 23 },
"externalRef": "crm:company:abc123",
"company": { "name": "EXAMPLE SRL", "vatNumber": "BE0675566990" }
}Never show score on its own. Its ceiling depends on the questionnaire the file used, so a raw 273 means nothing. Use ratio, or show score next to max.
risk is always an object, never null. On a file nobody has assessed yet, its fields are null.
Company register
The KBO/BCE and SIRENE data our own screens read, on your side of the wire: search, one company, its score, and who else is registered at an address.
GET /api/v2/register/companies/BE0403091220
{
"enterpriseNumber": "0403091220",
"name": "SOLVAY",
"score": { "grade": "B", "score": 78, "zScoreZone": "safe" },
"financials": [{ "year": 2024, "revenue": 13500000000, ... }],
"organization": { "isWatched": true, "isClient": false, "alertsCount": 2 }
}These reads are not scoped to your organization, and that is deliberate: a register entry says the same thing to everyone, so you can ask about any company, onboarded or not. The organization block is the one part that differs between two callers, and it is what turns a public lookup into something a compliance officer can act on.
| Endpoint | Answers |
|---|---|
GET /register/search?q= | Free text over names, numbers and addresses |
GET /register/companies/{n} | Identity, address, activities, establishments, names, contacts, score, filed figures, Gazette notices |
GET /register/companies/{n}/score | The financial score alone, for re-checking a portfolio |
GET /register/addresses/companies | Every company registered at one address |
PUT / DELETE /register/watches/{n} | Follow a company, so a change to it raises an alert |
An empty result and an unreachable register are not the same answer. The register is a third party and it has outages. They come back as 503 with Retry-After, never as an empty 200: one means the company is not in the register, the other means we could not ask, and they call for opposite conduct.
A company withheld under a GDPR objection answers 410, not 404. It existed and was withdrawn: erase any copy you hold, and do not retry.
Watching is a PUT on the number rather than a toggle, so a retry after a dropped connection leaves the company watched instead of quietly unwatching it. changed tells a first call from a retry.
Verify a company manager
Prove that your user runs a Belgian company. They verify their identity with itsme, or an identity document and a selfie, and we confront it with the mandates the Moniteur belge publishes for that company.
curl -X POST https://amlcompany.com/api/v2/verifications \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "purpose": "mandate",
"enterpriseNumber": "BE0675566990",
"expectedPerson": { "firstName": "Jean", "lastName": "Dupont" },
"reference": "your-own-id-42" }'
{ "id": "cmg1v0k2x0000abc", "reference": "your-own-id-42",
"status": "PENDING", "hostedUrl": "https://amlcompany.com/verification/...",
"hostedUrlExpiresAt": "2026-10-02T12:00:00.000Z" }Send hostedUrl to the person, or open it for them. On a desktop it shows a QR code, so they finish on the phone that carries itsme or a camera. The token needs verifications:write.
{ "id": "cmf9x2k10000",
"kind": "verification.concluded",
"data": { "subject": { "type": "verification", "id": "cmg1v0k2x0000abc" },
"id": "cmg1v0k2x0000abc", "reference": "your-own-id-42",
"purpose": "mandate", "status": "VERIFIED",
"decision": "pass", "reasons": ["mandate.matched"] } }GET /api/v2/verifications/cmg1v0k2x0000abc
{ "decision": "pass", "reasons": ["mandate.matched"],
"identity": { "firstName": "Jean", "lastName": "Dupont",
"birthDate": "1980-04-12", ... },
"mandate": { "role": "Gérant", "since": "2017-05-09",
"publicationId": "24338112", "signingAuthority": "unknown",
"source": { "readAt": "2026-06-02T14:49:15Z",
"coverage": "complete" } } }| decision | Meaning | What to do |
|---|---|---|
pass | A published mandate belongs to the certified person | Proceed |
review | An officer decides: partial first names, homonyms, someone else than announced | Wait for the next verification.concluded |
fail | The register was read in full and holds no mandate for this person | Offer another route |
indeterminate | The register could not be read in full | Not a refusal: retry later, or ask for the appointment deed |
The webhook carries no identity, and the return to your site is not proof. The name and birth date are read with GET under verifications:read. Decide on the webhook or on that call, never on a URL the browser brings back.
fail is not a fraud signal: a manager appointed last week, or one holding the mandate through their own company, lands there legitimately. Keep publicationId and source: they are the evidence the check rests on.
expectedPerson is a signal, not a filter: someone else verifying sends the check to review, never to a refusal. signingAuthority is always unknown, since holding a mandate is not the power to bind the company alone. We never return a national register number, a document serial, a facial image or the company's other managers.
Webhooks
An administrator connects your endpoint from the settings screen and is shown the signing secret once.
The signed string is the timestamp, a dot, and the raw request body. Signing the body alone will never match, and neither will re-serialising the parsed JSON: sign the bytes you received.
const expected = "v1=" + crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
// Constant time, and refuse anything older than 5 minutes.
const a = Buffer.from(expected), b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);| Event | Fires when |
|---|---|
onboarding.created | A file is created, including through this API |
onboarding.submitted | A file goes for validation |
onboarding.approved | A file is approved |
onboarding.rejected | A file is refused |
onboarding.risk_changed | The risk level changes, not on every rescore |
document.uploaded | A document is added |
client.created | A client is created |
verification.created | A manager check is opened |
verification.concluded | A manager check has an outcome, and again when an officer closes its review |
alert.raised | An alert needs the officer on a file: sanctions match, PEP status, overdue review, expired identity document. Routing only, never the screening detail |
alert.resolved | The officer closed one of those alerts |
Answer 2xx quickly, then do the work. A handler that finishes a long job before replying will time out and be retried, which is how duplicates are born. Deliveries are at least once, so make your handler idempotent on x-amlcompany-event-id.
We retry a 5xx, a 408, a 429 or a timeout up to 12 times over roughly a day, honouring Retry-After. Any other 4xx stops immediately. Redirects are never followed: configure the final URL.
Your endpoint must be publicly reachable over HTTPS. We refuse to deliver to private address space, including loopback and cloud metadata addresses.
Errors
One shape everywhere. Branch on code, never on message.
{
"error": { "code": "forbidden", "message": "Scope onboardings:write is required for this call" },
"requestId": "9f1c8e2a-..."
}| Code | HTTP | Retry | Meaning |
|---|---|---|---|
unauthorized | 401 | After fixing | Missing, expired or forged token |
opaque_token | 401 | No | You omitted `resource` when requesting the token |
forbidden | 403 | No | Scope missing, or the organization cannot be acted on |
invalid_request | 400 | No | The body failed validation; see `details` |
not_found | 404 | No | No such file here, or no such enterprise in the registry |
gone | 410 | No | Withheld under a GDPR objection. Erase any copy you hold. |
conflict | 409 | No | The request contradicts current state |
limit_exceeded | 402 | No | A plan cap is reached; see `quota` in the body |
idempotency_key_reuse | 422 | No | Same idempotency key, different body |
rate_limited | 429 | Yes | Too many requests. Honour Retry-After. |
internal_error | 500 | Yes | Our side. On POST /onboardings it also covers the registry being down, which predates service_unavailable and stays a 500 so existing callers keep their branch. |
service_unavailable | 503 | Yes | The public register is down. Honour Retry-After. |
Quote requestId when you contact us: it identifies the exact call in our logs.
Rate limits
120 requests per minute per application.
Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset. A 429 adds Retry-After in seconds. The budget refills continuously rather than in one lump, so back off instead of spinning.
Register reads carry a second, separate budget: a monthly quota of lookups, set by your plan. Reaching it answers 402 with a quota object naming the cap and where you stand against it, until the month rolls over. A request you got wrong and an outage on our side are both uncounted.