Developer Documentation
A unified REST API for identity, biometric, business, and telecom verification — designed for fintechs, marketplaces, telecom, and regulated SaaS.
KYC.bd is a modular verification stack for Bangladesh and emerging markets. Each module exposes a focused REST endpoint over HTTPS with JSON payloads.
National ID workflows with consent capture.
Selfie-to-document biometric comparison & liveness.
Company registration, TIN, and merchant checks.
SIM ownership and operator validation.
Real-time event delivery for verification lifecycle.
Rate limits, audit logs, and unified auth.
The verification API is served from https://kyc.bd/api/public/v1. Every request is authenticated with a project API key in the Authorization header. Create keys in the verification console.
Authorization: Bearer kyc_test_xxxxxxxxxxxxxxxxkyc_test_…
Runs against the deterministic test provider. Set template.mock_outcome to exercise every path.
kyc_live_…
Every submitted live session goes to the human review queue (requires_review). A trained reviewer inspects the documents and selfie, and their approve/reject is the final decision, sent as a verification.completed / verification.failed webhook.
Keys are scoped to one project. Only a hash is stored; a key is shown once at creation. Revoked or expired keys return 401.
Every response carries an x-request-id header. Errors share one envelope:
HTTP/1.1 409 Conflict
x-request-id: req_3f9c1a7e2b8d4c6f0a1e5b7d
{
"error": {
"code": "invalid_state",
"message": "Cannot move verification from verified to cancelled.",
"request_id": "req_3f9c1a7e2b8d4c6f0a1e5b7d"
}
}| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_json / idempotency_key_required | Malformed body or missing Idempotency-Key |
| 401 | unauthorized | Missing, revoked or expired API key |
| 404 | not_found | Resource does not exist in this project |
| 409 | invalid_state | Action not allowed in the current status |
| 422 | validation_error / missing_document / missing_selfie | Field validation or missing uploads |
| 422 | consent_required | Applicant has no active identity_verification consent |
| 422 | document_too_large / document_type_mismatch / document_missing | Upload over 10 MB, bytes don't match declared type, or object not found — re-upload |
| 422 | invalid_url | Webhook URL rejected (private/reserved address, non-public host, credentials, or non-443 port in live) |
| 500 | internal_error | Unexpected error — retry with the same Idempotency-Key |
POST /verifications requires an Idempotency-Key header (max 128 characters). Replaying the same key within the same project returns the original verification with 200 instead of creating a new one — safe for network retries. POST /applicants is idempotent on external_id. Webhook deliveries carry kyc-event-id and idempotency-key headers so your receiver can de-duplicate.
curl -X POST https://kyc.bd/api/public/v1/verifications \
-H "Authorization: Bearer $KYC_API_KEY" \
-H "Idempotency-Key: onboarding-USER-1001-attempt-1" \
-H "Content-Type: application/json" \
-d '{"applicant_id":"5b0c…","template":{"documents":["nid"],"require_selfie":true}}'Status changes are enforced by the platform; illegal transitions return 409 invalid_state.
created ──► started ──► documents_submitted ──► processing ──┬─► verified
│ │ ▲ ├─► failed
│ │ │ ├─► requires_review ──► verified | failed | requires_retry
│ │ └──── requires_retry ◄─────┘
└───────────┴──► cancelled | expired (7 days by default)Decisions come from a per-project risk policy: approve, review, reject or retry. risk_score is policy output from configured signal weights, not an objective measure of identity. In test mode, template.mock_outcome accepts pass, review, retry or fail. To pin a published flow version, send flow_id at the top level of the request; template.flow_id is rejected with 422.
| Method | Path | Purpose |
|---|---|---|
| GET | /me | Current project, organization and key |
| GET | /projects | Projects in the organization |
| GET | /usage | Billable usage totals, last 30 days |
| POST | /applicants | Create applicant with consent (idempotent on external_id) |
| GET | /applicants/{id} | Fetch applicant (no personal data returned) |
| POST | /applicants/{id}/consents | Record consent { purpose, consent_version } |
| GET | /applicants/{id}/consents | Consent history with active flag |
| POST | /applicants/{id}/consents/{consentId}/withdraw | Withdraw consent; cancels open verifications and erases personal data |
| POST | /applicants/{id}/deletion | Request erasure of all identity data (async, emits applicant.deleted) |
| POST | /verifications | Create verification (Idempotency-Key required) |
| GET | /verifications | List verifications (?status=) |
| GET | /verifications/{id} | Fetch verification |
| POST | /verifications/{id}/cancel | Cancel an open verification |
| POST | /verifications/{id}/documents | Get a signed upload URL for one file |
| POST | /verifications/{id}/submit | Submit uploads and run checks |
| GET | /verifications/{id}/checks | Individual check results |
| GET | /verifications/{id}/decision | Risk assessment, signals and review status |
| POST | /webhook-endpoints | Register a webhook endpoint (secret shown once) |
| GET | /webhook-endpoints | List endpoints |
| GET | /webhook-deliveries | Recent delivery attempts |
{
"external_id": "USER-1001",
"type": "individual",
"consent": { "purpose": "identity_verification", "version": "2026-10" },
"pii": { "full_name": "Applicant Name", "date_of_birth": "1990-01-01", "national_id": "1234567890" }
}{ "object": "applicant", "id": "5b0c2f1e-…", "external_id": "USER-1001", "type": "individual", "status": "active", "created_at": "2026-10-07T20:00:00Z" }Personal data is stored in a restricted zone and is never returned by the API. POST /verifications requires an active identity_verification consent (inline here or via POST /applicants/{id}/consents), otherwise it returns 422 consent_required.
Retention: documents are purged after the project's document window (default 90 days); personal data after the PII window (default 365 days) once no verification is open; raw provider responses with documents. Deletion requests are processed within the hour.
{
"applicant_id": "5b0c2f1e-…",
"type": "individual_kyc",
"country": "BD",
"template": { "documents": ["nid"], "require_selfie": true, "mock_outcome": "pass" }
}{
"id": "a581d10c-…", "object": "verification", "applicant_id": "5b0c2f1e-…",
"status": "created", "decision": null, "risk_score": null, "risk_level": null,
"expires_at": "2026-10-14T20:00:00Z", "created_at": "2026-10-07T20:00:00Z"
}Request a short-lived signed upload URL per file. document_type: nid, passport, driving_license or selfie. Accepted: JPEG, PNG, WebP, PDF up to 10 MB.
POST /verifications/a581d10c-…/documents
{ "document_type": "nid", "side": "front", "mime_type": "image/jpeg", "file_size": 284113 }
201 Created
{ "object": "document_upload", "document_id": "9e1d…", "upload_url": "https://…signed…",
"method": "PUT", "headers": { "content-type": "image/jpeg" }, "expires_in": 7200 }curl -X PUT "$UPLOAD_URL" -H "Content-Type: image/jpeg" --data-binary @nid-front.jpgFingerprints the uploaded files, then runs document, liveness and face-match checks and the risk policy.
{ "id": "a581d10c-…", "status": "verified", "decision": "approve", "risk_score": 0, "risk_level": "low", "documents_received": 2 }{
"object": "decision", "verification_id": "a581d10c-…", "status": "requires_review", "decision": "review",
"risk": { "risk_score": 40, "risk_level": "medium", "decision": "review",
"risk_signals": [{ "code": "face_match_inconclusive", "weight": 40, "detail": "score 0.72 < 0.8" }] },
"review": { "id": "…", "status": "open", "resolved_at": null }
}The module examples below describe planned product surfaces. The live, generally available API is the verification API documented above.
{
"full_name": "John Doe",
"date_of_birth": "1998-05-15",
"national_id": "1234567890",
"mobile": "+8801XXXXXXXXX",
"consent": true,
"reference_id": "USER-1001"
}{
"status": "verified",
"verification_id": "KYC-8472931",
"match_score": 98.7,
"risk_level": "low",
"timestamp": "2026-05-07T12:00:00Z"
}curl -X POST https://api.kyc.bd/v1/identity/verify \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"full_name":"John Doe",
"date_of_birth":"1998-05-15",
"national_id":"1234567890",
"mobile":"+8801XXXXXXXXX",
"consent":true
}'{
"document_image": "base64_encoded_image",
"selfie_image": "base64_encoded_image",
"reference_id": "USER-1001"
}{
"status": "matched",
"confidence_score": 96.4,
"liveness": true,
"fraud_risk": "low"
}{
"company_name": "Example Technologies Ltd",
"registration_number": "C-123456",
"tin": "987654321",
"contact_email": "admin@example.com"
}{
"status": "verified",
"company_status": "active",
"risk_level": "medium",
"verification_id": "BIZ-82711"
}{
"mobile_verified": true,
"operator": "Detected Operator",
"sim_status": "active",
"risk_score": 12
}Register an endpoint with POST /webhook-endpoints ({"url": "https://…", "events": ["*"]}). The signing secret (whsec_…) is returned once. Live endpoints must use HTTPS on port 443 with a public hostname; every A/AAAA record is resolved at registration and again before each delivery, and private, loopback, link-local, CGNAT, metadata and other reserved ranges are refused. Redirects are never followed (a 3xx counts as a failed attempt), each attempt aborts after 10 seconds, and at most 64 KB of your response body is read. Residual risk: a DNS answer can change between our check and the connection (resolve-then-fetch TOCTOU) because the runtime cannot pin an IP; Cloudflare's platform egress independently blocks RFC1918 and cloud-metadata targets, and a dedicated egress proxy is planned.
| Event | Description |
|---|---|
| verification.created | Verification session created |
| verification.started | First document upload URL issued |
| verification.document_submitted | Documents submitted for checks |
| verification.processing | Checks are running |
| verification.requires_retry | Applicant must re-upload (e.g. unreadable document) |
| verification.requires_review | Sent to the manual review queue |
| verification.completed | Verified (approved by policy or reviewer) |
| verification.failed | Rejected by policy or reviewer |
| verification.expired | Not completed before expires_at |
| verification.cancelled | Cancelled via the API, consent withdrawal or deletion request |
| applicant.deleted | Deletion request completed: documents, personal data and raw provider data erased |
POST https://your-app.example/webhooks/kyc
content-type: application/json
kyc-signature: t=1791403200,v1=5d41402abc4b2a76b9719d911017c592…
kyc-event-id: 7c9e6679-7425-40de-944b-e07fc1f90ae7
idempotency-key: 7c9e6679-7425-40de-944b-e07fc1f90ae7
{
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"type": "verification.completed",
"created_at": "2026-10-07T20:00:05Z",
"data": { "id": "a581d10c-…", "object": "verification", "status": "verified", "decision": "approve", "risk_score": 0 }
}Verify the signature: compute HMAC-SHA256 of `${t}.${rawBody}` with your secret, compare to v1 in constant time, and reject timestamps older than 5 minutes.
import crypto from "node:crypto";
export function verifyKycSignature(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? ""));
}import hmac, hashlib, time
def verify_kyc_signature(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
if abs(time.time() - int(parts["t"])) > 300:
return False
expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))Retries: any non-2xx response or a 10-second timeout is retried with exponential backoff (30s, 60s, 2m, 4m, 8m) for up to 6 attempts, then marked failed. Inspect deliveries with GET /webhook-deliveries.
Official client libraries with typed methods and built-in retries.