Company Verification (KYB)
KYB (Know Your Business) verifies a company as an entity: its registration documents, an authorized signatory's identity, and an AML/sanctions screen of the registered name. It runs on a separate set of endpoints from individual KYC (/v1/verify, /v1/sessions/*) but shares the same authentication, webhook delivery, and review model.
How it works
A company verification aggregates three independent checks into one decision:
- Documents — every document your country's requirements matrix marks
requiredmust be uploaded and accepted (registration certificate, proof of address, etc.). - Signatory identity — the authorized signatory completes a normal individual KYC check (ID + selfie) through a hosted link you send them.
- AML screening — the registered company name is screened for sanctions/PEP/watchlist hits the moment the verification is created.
The terminal decision is computed, not asserted: REJECT dominates NEEDS_REVIEW dominates APPROVED. Any required document rejected, or the signatory rejected, rejects the whole verification. Anything still incomplete (a document not yet accepted, the signatory not yet done) parks the verification in NEEDS_REVIEW rather than guessing.
Create → AML screen (name) + resolve required documents
│
├── Upload documents (you, or the signatory via the hosted link)
├── Signatory completes ID + selfie via verifyUrl
│
└── Every required doc accepted + signatory approved + AML clear
→ status: COMPLETED, decision: APPROVED
Any required doc rejected, or signatory rejected
→ status: COMPLETED, decision: REJECTED
Still incomplete, or an AML hit
→ status: NEEDS_REVIEWNEEDS_REVIEW verifications resolve automatically as documents get accepted and the signatory finishes — or an operator can manually review them. Either path fires the verification.completed webhook once the outcome is terminal.
1. Create a Company Verification
POST /v1/company-verificationsHeaders:
| Header | Type | Required | Description |
|---|---|---|---|
X-API-Key | string | Yes | Your API key |
Content-Type | string | Yes | application/json |
Idempotency-Key | string | No | Repeat the same key to safely retry without creating a duplicate — see Idempotent replay |
Request Body:
{
"country": "SZ",
"registeredName": "Acme Trading (Pty) Ltd",
"registrationNumber": "REG-2019-004521",
"registeredAddress": "12 Somhlolo Road, Mbabane",
"taxId": "TIN-882910",
"incorporationDate": "2019-03-14T00:00:00.000Z",
"externalRef": "biz_acme_001"
}| Field | Type | Required | Description |
|---|---|---|---|
country | string | Yes | ISO 3166-1 alpha-2 country code. Must have an active requirements matrix (see Supported countries) — an unsupported country fails loudly rather than silently skipping requirements. |
registeredName | string | Yes | Legal/registered company name. This is what gets AML-screened. |
registrationNumber | string | No | Company registration number |
registeredAddress | string | No | Registered office address |
taxId | string | No | Tax identification number |
incorporationDate | string | No | ISO 8601 date. Affects which documents are required — see Required Documents |
externalRef | string | No | Your internal reference ID |
Response 202 Accepted:
{
"success": true,
"data": {
"verificationId": "cv_a1b2c3d4e5f6g7h8i9j0k1l2",
"status": "pending",
"requiredDocuments": [
{ "docType": "company_registration", "label": "Certificate of Incorporation / Registration", "required": true, "sortOrder": 10 },
{ "docType": "form_j", "label": "Form J — Return of Directors & Officers", "required": true, "sortOrder": 20 },
{ "docType": "form_c", "label": "Form C — Notice of Registered Office & Postal Address", "required": true, "sortOrder": 30 },
{ "docType": "trading_license", "label": "Municipal trading license", "required": true, "sortOrder": 40 },
{ "docType": "proof_of_address", "label": "Utility bill / lease (≤3 months)", "required": true, "sortOrder": 50 },
{ "docType": "director_id", "label": "Authorized signatory ID", "required": true, "sortOrder": 60 },
{ "docType": "tax_clearance", "label": "SRA tax clearance / TIN", "required": false, "sortOrder": 70 }
],
"signatorySessionToken": "eyJhbGciOiJIUzI1NiIs...",
"verifyUrl": "https://verify.yeboverify.com#session=eyJhbGciOiJIUzI1NiIs..."
}
}| Field | Type | Description |
|---|---|---|
verificationId | string | Unique company verification ID (prefixed cv_) |
status | string | pending immediately after creation |
requiredDocuments | array | The resolved document checklist for this company — see Required Documents |
signatorySessionToken | string | Session token for the signatory's individual identity check. Same shape as a normal /v1/sessions/create token. |
verifyUrl | string | Send the authorized signatory here to complete their ID + selfie check. |
verifyUrl carries the session token in the URL fragment
#session=..., not ?session=.... The token is deliberately kept out of the query string so it never reaches your server logs, analytics, or the Referer header of any page the signatory navigates to next. If you're constructing this URL yourself (rather than using the one returned in the response), preserve the #.
Idempotent replay
Pass the same Idempotency-Key header on a retried request (e.g. after a network timeout) and you get back the existing verification instead of a duplicate — no second AML screen, no second billed usage event. The response is 200 OK instead of 202 Accepted, and every field (including a freshly-derived signatorySessionToken and verifyUrl, since session tokens expire) reflects the original record's current state.
Errors
| Code | HTTP Status | Description |
|---|---|---|
KYB_COUNTRY_UNSUPPORTED | 400 | No active requirements matrix exists for country yet |
VALIDATION_ERROR | 400 | Request body failed validation (e.g. country isn't a 2-letter code) |
NOT_AUTHENTICATED | 401 | Missing/invalid X-API-Key |
A 402 (quota exceeded) is possible too — a company verification is metered as one billable company_verification usage event, separate from the signatory's individual KYC event.
Required Documents
Which documents are required varies by country and, for some document types, by how old the company is. createCompanyVerification's response already gives you the resolved checklist for this specific company — you don't need to look up the matrix separately.
Supported countries
Today Eswatini (SZ) is the only seeded country. Requesting any other country returns KYB_COUNTRY_UNSUPPORTED. Reach out if you need another country enabled — the matrix is per-country configurable, not hardcoded.
Eswatini (SZ) document set
| docType | Label | Required |
|---|---|---|
company_registration | Certificate of Incorporation / Registration | Always |
form_j | Form J — Return of Directors & Officers | Always |
form_c | Form C — Notice of Registered Office & Postal Address | Only if the company is ≥1 year old (unknown incorporation date defaults to required) |
trading_license | Municipal trading license | Always |
proof_of_address | Utility bill / lease (≤3 months) | Always |
director_id | Authorized signatory ID | Always |
tax_clearance | SRA tax clearance / TIN | Optional |
The form_c condition is the one exception to "always required": pass incorporationDate on create so a genuinely young company isn't blocked waiting on a document it may not have yet. If you omit incorporationDate, it's treated as required (unknown age never waives a requirement).
Uploading Documents
Documents can come from either side of the relationship — whichever fits your flow:
From your backend (API key auth)
POST /v1/company-verifications/:verificationId/documentsHeaders:
| Header | Type | Required | Description |
|---|---|---|---|
X-API-Key | string | Yes | Your API key |
Content-Type | string | Yes | multipart/form-data |
Request Body (multipart/form-data):
| Field | Type | Required | Description |
|---|---|---|---|
document | file | Yes | The document file (JPEG, PNG, or PDF, max 10MB) |
docType | string | Yes | One of the docType values from requiredDocuments (e.g. company_registration) |
Response 201 Created:
{
"success": true,
"data": {
"documentId": "clxxxxxxxxxxxx",
"docType": "company_registration",
"status": "uploaded"
}
}From the hosted signatory flow (session auth)
When the signatory is completing documents through the verifyUrl link (e.g. a hosted upload step in verify-app alongside their ID + selfie), use the session-authenticated counterpart instead of your API key:
POST /v1/sessions/company-documentsRequest Body (multipart/form-data):
| Field | Type | Required | Description |
|---|---|---|---|
session | string | Yes | The signatorySessionToken (or the token from the verifyUrl fragment) |
docType | string | Yes | One of the required docType values |
document | file | Yes | The document file |
Response:
{
"success": true,
"docType": "company_registration",
"status": "uploaded"
}Both paths land the document in the same place — a document uploaded by the signatory is immediately visible to you via GET /v1/company-verifications/:verificationId. Uploaded documents start in uploaded status; they only count toward the requiredDocuments checklist once an operator marks them accepted via review.
The Signatory Identity Check
Every company verification has exactly one linked individual verification: the authorized signatory's ID + selfie check, run through the same hosted flow individual KYC uses (see Mobile Integration and Getting Started).
- Send the signatory to the
verifyUrlfrom the create response (or fromGET /v1/company-verifications/:verificationIdif they need it again). - They complete the normal ID + selfie capture in their browser — nothing KYB-specific from their side.
- When their individual verification completes, it's automatically linked back to the company verification and the aggregate decision recomputes — you don't call anything to "attach" it.
- The linked result surfaces on the company verification as
signatoryVerificationIdand, once decided, on theverification.completedwebhook'scompany.signatoryDecision.
If the signatory abandons the flow or their individual check ends in NEEDS_REVIEW, the company verification stays NEEDS_REVIEW too — it can't reach a terminal decision without a resolved signatory.
Reading Verifications
Get a Company Verification
GET /v1/company-verifications/:verificationIdResponse:
{
"success": true,
"data": {
"id": "clxxxxxxxxxxxx",
"verificationId": "cv_a1b2c3d4e5f6g7h8i9j0k1l2",
"externalRef": "biz_acme_001",
"country": "SZ",
"registeredName": "Acme Trading (Pty) Ltd",
"registrationNumber": "REG-2019-004521",
"status": "NEEDS_REVIEW",
"decision": null,
"decisionReason": null,
"amlDecision": "clear",
"amlScore": 0,
"signatoryVerificationId": "vrf_id_xyz789abc",
"documents": [
{
"id": "clyyyyyyyyyyyy",
"docType": "company_registration",
"status": "uploaded",
"signedUrl": "https://cdn.yeboverify.com/company-verifications/...",
"createdAt": "2026-05-08T12:00:00.000Z"
}
],
"reviewHistory": [],
"webhookSent": false,
"createdAt": "2026-05-08T12:00:00.000Z",
"completedAt": null
}
}documents[].signedUrl is a time-limited (1 hour) signed URL to view the uploaded file — re-fetch this endpoint if it expires. reviewHistory is the append-only audit trail of every review decision applied to this verification (mirrors the individual KYC audit trail).
List Company Verifications
GET /v1/company-verificationsQuery Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page | number | 1 | Page number |
limit | number | 20 | Results per page (max 100) |
status | string | – | Filter by PENDING, PROCESSING, COMPLETED, FAILED, NEEDS_REVIEW |
Response:
{
"success": true,
"data": {
"items": [
{
"verificationId": "cv_a1b2c3d4e5f6g7h8i9j0k1l2",
"registeredName": "Acme Trading (Pty) Ltd",
"status": "NEEDS_REVIEW",
"decision": null,
"createdAt": "2026-05-08T12:00:00.000Z"
}
],
"total": 1,
"page": 1,
"limit": 20
}
}Manually Review a Verification
Operators (or your own back office, via the API key) resolve a NEEDS_REVIEW company verification by accepting/rejecting each uploaded document. The terminal decision is then recomputed from the aggregate — you never set decision directly, the same way individual KYC review works.
POST /v1/company-verifications/:verificationId/reviewRequest Body:
{
"documentReviews": [
{ "documentId": "clyyyyyyyyyyyy", "status": "accepted" },
{ "documentId": "clzzzzzzzzzzzz", "status": "rejected", "note": "Certificate is expired" }
]
}| Field | Type | Required | Description |
|---|---|---|---|
documentReviews | array | Yes | At least one verdict |
documentReviews[].documentId | string | Yes | A document ID belonging to this verification |
documentReviews[].status | string | Yes | accepted or rejected |
documentReviews[].note | string | No | Reviewer note, max 1000 chars |
Reviewer identity is derived entirely from the authenticated API key (name/prefix) — there's no reviewedBy free-text field to spoof.
Response:
{
"success": true,
"data": {
"verificationId": "cv_a1b2c3d4e5f6g7h8i9j0k1l2",
"status": "COMPLETED",
"decision": "REJECTED"
}
}Errors:
| Code | HTTP Status | Description |
|---|---|---|
NOT_FOUND | 404 | Verification doesn't exist for your business |
INVALID_DOCUMENT | 400 | One or more documentIds don't belong to this verification |
NOT_REVIEWABLE | 409 | Verification isn't PENDING/NEEDS_REVIEW (already terminal, or a concurrent review won the race) |
A review doesn't have to immediately produce a terminal decision — if you accept some documents but the signatory hasn't finished yet, the verification stays NEEDS_REVIEW and will recompute again automatically once the signatory result lands.
Resend Webhook
If your endpoint missed the verification.completed delivery for a terminal company verification, re-trigger it manually:
POST /v1/company-verifications/:verificationId/resend-webhookReturns 404 if not found for your business, 409 if the verification hasn't reached a terminal decision yet, 400 if you don't have a webhookUrl configured (see Webhooks).
Webhook Payload
KYB delivers through the exact same verification.completed webhook individual KYC uses — same signing, same retry policy, same X-YeboVerify-Signature-V2 scheme — distinguished by a type: "company" field and a company block instead of the person-specific fields (faceScore, ocrConfidence, extractedData).
{
"event": "verification.completed",
"eventId": "evt_1a2b3c4d5e6f7a8b9c0d1e2f",
"type": "company",
"verificationId": "cv_a1b2c3d4e5f6g7h8i9j0k1l2",
"externalRef": "biz_acme_001",
"status": "completed",
"decision": "approved",
"confidence": "high",
"aml": {
"decision": "clear",
"score": 0,
"hits": []
},
"company": {
"registeredName": "Acme Trading (Pty) Ltd",
"registrationNumber": "REG-2019-004521",
"country": "SZ",
"signatoryVerificationId": "vrf_id_xyz789abc",
"signatoryDecision": "approved",
"documents": [
{ "docType": "company_registration", "status": "accepted" },
{ "docType": "form_j", "status": "accepted" }
]
},
"timestamp": "2026-05-08T12:05:00.000Z"
}| Field | Type | Description |
|---|---|---|
type | string | "company" — the KYB discriminator. Individual KYC payloads send type: "individual"; switch on it before assuming person-shaped fields exist. |
company.registeredName / registrationNumber / country | string | Echoes what you submitted at creation |
company.signatoryVerificationId | string | null | The linked individual verification ID |
company.signatoryDecision | string | null | approved, rejected, needs_review, or null if the signatory hasn't finished |
company.documents | array | Every uploaded document's final docType/status |
aml | object | Same AML block individual payloads carry — see Webhooks → AML |
See Webhooks for signature verification (X-YeboVerify-Signature-V2), retry policy, and eventId-based dedupe — all of it applies identically to type: "company" deliveries.
End-to-End Example
# 1. Create the company verification
curl -X POST https://api.yeboverify.com/v1/company-verifications \
-H "X-API-Key: your_api_key" \
-H "Content-Type: application/json" \
-d '{
"country": "SZ",
"registeredName": "Acme Trading (Pty) Ltd",
"registrationNumber": "REG-2019-004521",
"incorporationDate": "2019-03-14T00:00:00.000Z",
"externalRef": "biz_acme_001"
}'
# → { verificationId: "cv_...", requiredDocuments: [...], verifyUrl: "https://verify.yeboverify.com#session=..." }
# 2. Upload the documents you already have on file
curl -X POST https://api.yeboverify.com/v1/company-verifications/cv_.../documents \
-H "X-API-Key: your_api_key" \
-F "docType=company_registration" \
-F "document=@registration_certificate.pdf"
# 3. Send the signatory the verifyUrl to complete their ID + selfie check
# (they do this in a browser — nothing further to call here)
# 4. Once documents are uploaded, accept/reject them
curl -X POST https://api.yeboverify.com/v1/company-verifications/cv_.../review \
-H "X-API-Key: your_api_key" \
-H "Content-Type: application/json" \
-d '{ "documentReviews": [{ "documentId": "cl...", "status": "accepted" }] }'
# 5. Listen for verification.completed on your webhook, or poll:
curl https://api.yeboverify.com/v1/company-verifications/cv_... \
-H "X-API-Key: your_api_key"