Aller au contenu principal
Developers

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.

Quickstart

Three calls. The first two are yours to make; the third is ours to you.

1. Get a token
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'
2. Create a file
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"
  }'
3. We call you, signed
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 numberWhat happens
BE0999999001A complete company, SANDBOX TEST SRL
BE0999999002A company shaped to assess as higher risk
BE0999999404Always 404, so you can test the path you must not retry
BE0999999500Always 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.

ScopeGrants
onboardings:readConsulter vos dossiers AML, leur statut et leur score
onboardings:writeCréer des dossiers AML et y déposer des informations
clients:readConsulter la liste de vos clients
clients:writeCréer et modifier des clients
companies:readConsulter les sociétés rattachées à vos dossiers
register:readConsulter le registre public des entreprises (BCE, SIRENE) : fiche, score, recherche
register:writeGérer les entreprises que vous surveillez dans le registre
reports:readTélécharger vos rapports AML
verifications:readConsulter le résultat des vérifications de gérant et l'identité vérifiée
verifications:writeDemander la vérification d'un gérant d'entreprise et obtenir le lien à lui transmettre
webhooks:manageGé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.

A beneficial owner
POST /api/v2/onboardings/{id}/beneficiaries

{ "firstName": "Marie", "lastName": "Dupont",
  "role": "Gérant", "percentage": 75,
  "externalRef": "crm:company_person:42" }
Their identity card, as multipart/form-data
curl -X POST .../onboardings/{id}/documents \
  -H "authorization: Bearer $TOKEN" \
  -F [email protected] -F kind=IDENTITY_CARD -F beneficiaryId={ownerId}
Hand it to a compliance officer
POST /api/v2/onboardings/{id}/submit

Name, 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.

One company
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.

EndpointAnswers
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}/scoreThe financial score alone, for re-checking a portfolio
GET /register/addresses/companiesEvery 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.

1. Open a check
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.

2. We call you with the verdict
{ "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"] } }
3. Read the identity and the evidence
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" } } }
decisionMeaningWhat to do
passA published mandate belongs to the certified personProceed
reviewAn officer decides: partial first names, homonyms, someone else than announcedWait for the next verification.concluded
failThe register was read in full and holds no mandate for this personOffer another route
indeterminateThe register could not be read in fullNot 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.

Verify a delivery
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);
EventFires when
onboarding.createdA file is created, including through this API
onboarding.submittedA file goes for validation
onboarding.approvedA file is approved
onboarding.rejectedA file is refused
onboarding.risk_changedThe risk level changes, not on every rescore
document.uploadedA document is added
client.createdA client is created
verification.createdA manager check is opened
verification.concludedA manager check has an outcome, and again when an officer closes its review
alert.raisedAn alert needs the officer on a file: sanctions match, PEP status, overdue review, expired identity document. Routing only, never the screening detail
alert.resolvedThe 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-..."
}
CodeHTTPRetryMeaning
unauthorized401After fixingMissing, expired or forged token
opaque_token401NoYou omitted `resource` when requesting the token
forbidden403NoScope missing, or the organization cannot be acted on
invalid_request400NoThe body failed validation; see `details`
not_found404NoNo such file here, or no such enterprise in the registry
gone410NoWithheld under a GDPR objection. Erase any copy you hold.
conflict409NoThe request contradicts current state
limit_exceeded402NoA plan cap is reached; see `quota` in the body
idempotency_key_reuse422NoSame idempotency key, different body
rate_limited429YesToo many requests. Honour Retry-After.
internal_error500YesOur 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_unavailable503YesThe 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.

    Nous respectons votre vie privée

    Nous utilisons des cookies essentiels au fonctionnement du site et, avec votre accord, des cookies de mesure d'audience (hébergée dans l'UE) pour améliorer nos services. En savoir plus.