Documentation

API documentation

REST + JSON over HTTPS. Every request is HMAC-signed. Examples use placeholder values.

Quickstart

  1. Request access. Once your dashboard account is active, sign in and open API keys.
  2. Create a key with only the scopes you need. The signing secret is shown once.
  3. Store KYC_API_KEY, KYC_API_SECRET and KYC_BASE_URL as server-side secrets — never in a mobile or browser app.
  4. Send a signed request and log the X-Request-Id response header.
# body.json: {"selfie":{"image":"<base64>"},"reference":{"image":"<base64 KTP>"}}
TS=$(date +%s)
NONCE=$(openssl rand -hex 16)
BODY_HASH=$(openssl dgst -sha256 -hex body.json | sed 's/^.* //')
SIG=$(printf '%s\n%s\n%s\n%s\n%s' "$TS" "$NONCE" POST /v1/kyc/verify "$BODY_HASH" \
  | openssl dgst -sha256 -hmac "$KYC_API_SECRET" -binary \
  | openssl base64 -A | tr '+/' '-_' | tr -d '=')

curl -sS "$KYC_BASE_URL/v1/kyc/verify" \
  -H "Content-Type: application/json" \
  -H "X-Api-Key: $KYC_API_KEY" \
  -H "X-Timestamp: $TS" \
  -H "X-Nonce: $NONCE" \
  -H "X-Signature: v1=$SIG" \
  -H "Idempotency-Key: $(uuidgen)" \
  --data-binary @body.json

Authentication

Every /v1 request carries X-Api-Key, X-Timestamp (Unix seconds, ±300 s), X-Nonce (16–128 chars of [A-Za-z0-9_-], unique) and X-Signature. Failures return a generic 401.

signature
canonical = timestamp + "\n" + nonce + "\n" + METHOD + "\n" + path_with_query + "\n" + sha256_hex(raw_body)
X-Signature = "v1=" + base64url_no_padding(HMAC_SHA256(secret, canonical))

Sign the exact bytes you send; re-serialising JSON after signing changes the hash.

Scopes

ScopeGrants
identity:checkIdentity, NIB and account lookups; bank list
data:nik_decode · data:name_matchNIK Decoder · Name Match
biometric:face_matchPOST /v1/face/match
biometric:livenessPOST /v1/liveness/passive
biometric:selfie_ktpPOST /v1/kyc/selfie-with-ktp
biometric:kyc_verifyPOST /v1/kyc/verify, hosted verification links, GET /v1/policies
document:checkPOST /v1/ktp/authenticity — no personal data returned
document:ocrPOST /v1/ktp/ocr and options.extractIdentity — responses contain personal data
risk:manageBlocklist and identifier erasure

Idempotency

Send Idempotency-Key on POST requests. A retry with the same key and body within 24 h returns the stored response (Idempotency-Replayed: true) and is not billed again. A different body returns 422; a retry while the first request runs returns 409.

Rate limits

Each key has a request budget and a limit on concurrent biometric requests. Exceeding them returns 429 with Retry-After. Your key's limits are shown in the dashboard; contact us for higher limits.

Errors

Errors are { "error": "code", "requestId": "…" }. None of them are billed.

StatuserrorMeaning
400invalid_requestMalformed input or missing signing headers.
400invalid_idempotency_keyIdempotency-Key must be 8–128 chars of [A-Za-z0-9_-:.].
401unauthorizedAuthentication failed. Deliberately generic — check key, timestamp, nonce and signature.
403insufficient_scopeValid key without the capability. requiredScope names it.
404not_foundValid request, no matching record (NIB, account, analysis id).
409idempotency_request_in_progressA request with this Idempotency-Key is still running.
409not_reviewableAnalysis is not in review, or was already resolved.
413payload_too_largeImage or body over the size/pixel limit.
415unsupported_mediaImage is not JPEG or PNG.
422idempotency_key_reusedSame Idempotency-Key with a different request body.
422face_not_usableThe photo is not usable. quality explains which image to retake.
422document_not_readableNo KTP text found (ktp/ocr).
429rate_limitedPer-key request budget exceeded. See Retry-After.
429too_many_concurrent_requestsToo many biometric/OCR requests in flight for this key.
502provider_unavailableUpstream source or verification service failed. Retry with backoff.
503service_busyVerification service at capacity. Retry shortly.
504inference_timeoutAnalysis took too long. Safe to retry with the same Idempotency-Key.

API reference

Images: JPEG or PNG only, base64-encoded, up to 1.5 MB each. NIK: 16 digits. NIB: 13 digits. Full field rules are in the signed-in documentation.

Identity Check stable

GET /v1/identity/check/:nik scope: identity:check

Registered name, sex and date of birth from a 16-digit NIK.

request
GET /v1/identity/check/3201••••••••0001
X-Api-Key: key_live_••••
X-Timestamp: 1757022089
X-Nonce: 9f1c••••
X-Signature: v1=••••
response
{
  "data": {
    "nama": "B••• S•••••",
    "sex": "L",
    "tglLahir": "••-••-1990"
  },
  "requestId": "5d3c…"
}

Billed per successful lookup, including cached answers. Invalid input, rate-limited and provider-error requests are free.

Business Check stable

GET /v1/nib/check/:nib scope: identity:check

Legal name, tax ID and OSS issuance date from a 13-digit NIB.

request
GET /v1/nib/check/0907••••••537
X-Api-Key: key_live_••••
X-Timestamp: 1757022311
X-Nonce: a81d••••
X-Signature: v1=••••
response
{
  "data": {
    "namaPerusahaan": "PT C•••• M••••",
    "npwp": "60•••••••••010",
    "tglTerbitOss": "2023-06-09"
  },
  "requestId": "7a10…"
}

Billed per successful lookup. Not found (404), invalid input and provider errors are free.

Account Check stable

GET /v1/account/check/:bank/:number scope: identity:check

Holder name for 120+ banks and DANA, OVO, GoPay, ShopeePay.

request
GET /v1/account/check/MDRIC1ID/1110•••••3597
X-Api-Key: key_live_••••
X-Timestamp: 1757022311
X-Nonce: c2e4••••
X-Signature: v1=••••
response
{
  "data": {
    "bank": "Mandiri",
    "accountHolder": "R•••• A••••",
    "accountNumber": "1110•••••3597"
  },
  "requestId": "b19e…"
}

Billed per successful name resolution. Not found, invalid input and provider errors are free; the bank list is always free.

NIK Decoder stable

POST /v1/identity/nik/decode scope: data:nik_decode

Province, regency, district, birth date, gender and an optional age gate decoded from a NIK, with plausibility checks.

request
POST /v1/identity/nik/decode

{ "nik": "3201••••••••0001", "minAge": 21 }
response
{
  "data": {
    "valid": true,
    "region": { "province": "Jawa Barat", "regencyCode": "3201" },
    "birthDate": "19••-••-••",
    "gender": "L",
    "meetsMinimumAge": true,
    "reasons": []
  }
}

Billed per decoded NIK, valid or not. Malformed input (not 16 digits) is free.

Name Match stable

POST /v1/name/match scope: data:name_match

Indonesian-aware name comparison: titles, old spelling, Muhammad variants, initials and word order.

request
POST /v1/name/match

{
  "nameA": "H. Moch. Budi Santoso, S.H.",
  "nameB": "MUHAMMAD BUDI SANTOSO"
}
response
{
  "data": {
    "decision": "match",
    "score": 1,
    "reasons": ["titles_ignored", "spelling_normalized"]
  }
}

Billed per comparison. Invalid input is free.

Face Match stable

POST /v1/face/match scope: biometric:face_match

Compares a selfie with the KTP portrait, after checking both images are usable.

request
POST /v1/face/match
Idempotency-Key: 3f2b…

{
  "selfie":    { "image": "<base64>" },
  "reference": { "image": "<base64 KTP>" }
}
response
// example — illustrative values
{
  "data": {
    "decision": "match",
    "match": { "score": 0.XX },
    "reasons": []
  }
}

Billed per completed comparison. Photos rejected by the quality gate (422) and errors are free.

Passive Liveness beta

POST /v1/liveness/passive scope: biometric:liveness

Presentation-attack detection from a single selfie.

request
POST /v1/liveness/passive
Idempotency-Key: 8c61…

{ "selfie": { "image": "<base64>" } }
response
// example
{
  "data": {
    "decision": "live",
    "reasons": []
  }
}

Billed per completed check. Selfies rejected by the quality gate (422) and errors are free.

KTP OCR stable

POST /v1/ktp/ocr scope: document:ocr

Structured identity fields from a KTP photo, with NIK consistency checks.

request
POST /v1/ktp/ocr
Idempotency-Key: 1d9e…

{ "document": { "image": "<base64 KTP>" } }
response
// example — never use real identity data
{
  "data": {
    "status": "EXTRACTED",
    "fields": {
      "nik":  { "value": "3201••••0001", "confidence": 0.XX },
      "nama": { "value": "B••• S•••••",  "confidence": 0.XX }
    },
    "checks": { "nik_matches_birth_date": "PASS" }
  }
}

Billed per processed card. Images with no readable KTP text (422) and errors are free.

Document Check stable

POST /v1/ktp/authenticity scope: document:check

Document integrity and data-consistency checks on a KTP photo, without returning personal data.

request
POST /v1/ktp/authenticity

{ "document": { "image": "<base64 KTP>" } }
response
{
  "data": {
    "status": "PASS",
    "checks": { "…": "PASS" },
    "reasons": []
  }
}

Billed per checked card. Unreadable or invalid images and errors are free.

Selfie with KTP stable

POST /v1/kyc/selfie-with-ktp scope: biometric:selfie_ktp

Checks a photo of the user holding their KTP against the card they uploaded.

request
POST /v1/kyc/selfie-with-ktp

{
  "holding":   { "image": "<face + KTP in hand>" },
  "reference": { "image": "<base64 KTP>" }
}
response
{
  "data": {
    "decision": "approved",
    "checks": {
      "person_matches_card": "match",
      "held_card_matches_uploaded_card": "match"
    }
  }
}

Billed per completed check. Photos without a visible card or face (422) and errors are free.

KYC Verify stable

POST /v1/kyc/verify scope: biometric:kyc_verify

Selfie and KTP in — approved, review or rejected out.

request
POST /v1/kyc/verify
Idempotency-Key: 2e7d…

{
  "selfie":    { "image": "<base64>" },
  "reference": { "image": "<base64 KTP>" },
  "clientRef": "user_123"
}
response
// example
{
  "data": {
    "decision": "approved",
    "faceMatch": { "decision": "PASS" },
    "liveness":  { "decision": "PASS" },
    "document":  { "authenticity": { "status": "PASS" } },
    "reasonCodes": []
  }
}

One bundle price per completed verification — not billed per component. Photos rejected by the quality gate (422) and errors are free.

Fraud signals & blocklist

Checks that carry a NIK or bank account return riskSignals: blocklisted, earlier checks in your workspace, how many other clientRefs used the same identity, and flags such as identity_reused_across_client_refs. Send your own user id as clientRef. Identifiers are stored only as keyed fingerprints unique to your workspace and expire after 90 days. Manage the blocklist in the dashboard or with POST /v1/blocklist (scope risk:manage).

Verification policies

Define rules in the dashboard — minimum age, blocklist, identity reuse, document strictness — and pass options.policyId to KYC Verify or policyId to a hosted session. Responses carry the final decision, the engineDecision and the policy outcomes. A policy can only make a decision stricter, never approve what the verification engine did not.

Governance controls

Per API key: an IP allowlist (signed requests from other addresses are refused) and a monthly spend limit (402 spend_limit_reached, not billed; usage.threshold webhook at 80% and 100%). Per workspace: a data retention period from 30 days to 5 years, after which analysis details are redacted. DELETE /v1/analyses/:id redacts one immediately for a data subject request.

Batches

POST /v1/batches with service (identity, nib, account, nik_decode, name_match) and items or csv — up to 1,000 per batch. Poll GET /v1/batches/:id, fetch /results or /results.csv, or listen for batch.completed. Successful items are billed at the service price; results are deleted after 7 days.

Webhooks

One HTTPS endpoint per API key, configured in the dashboard. Events: analysis.completed, analysis.reviewed, batch.completed, verification_session.completed, usage.threshold. Every delivery carries Kenalyx-Signature: t=…,v1=… (HMAC-SHA256 of t.body); payloads contain ids and decisions only. Failed deliveries are retried for about an hour.

Certificates

GET /v1/analyses/:id/certificate returns an Ed25519-signed record of the checks and any human review, without personal data. Public keys: /.well-known/kenalyx-certificate-keys.json.

Analyses & review

GET /v1/analyses/:id
POST /v1/analyses/:id/review

Fetch an analysis created by your key, or resolve a review with { "decision": "approved" | "rejected", "reason": "…" }. The automated decision is kept; resolving is not billed. Bank codes for Account Check: GET /v1/account/banks (free).