API documentation
REST + JSON over HTTPS. Every request is HMAC-signed. Examples use placeholder values.
Quickstart
- Request access. Once your dashboard account is active, sign in and open API keys.
- Create a key with only the scopes you need. The signing secret is shown once.
- Store
KYC_API_KEY,KYC_API_SECRETandKYC_BASE_URLas server-side secrets — never in a mobile or browser app. - Send a signed request and log the
X-Request-Idresponse 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.jsonconst crypto = require('crypto');
const fs = require('fs');
const { KYC_BASE_URL, KYC_API_KEY, KYC_API_SECRET } = process.env;
async function kycVerify(selfiePath, ktpPath) {
const path = '/v1/kyc/verify';
const body = JSON.stringify({
selfie: { image: fs.readFileSync(selfiePath).toString('base64') },
reference: { image: fs.readFileSync(ktpPath).toString('base64') },
});
const ts = Math.floor(Date.now() / 1000).toString();
const nonce = crypto.randomBytes(16).toString('hex');
const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
const signature = crypto
.createHmac('sha256', KYC_API_SECRET)
.update([ts, nonce, 'POST', path, bodyHash].join('\n'))
.digest('base64url');
const res = await fetch(KYC_BASE_URL + path, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': KYC_API_KEY,
'X-Timestamp': ts,
'X-Nonce': nonce,
'X-Signature': `v1=${signature}`,
'Idempotency-Key': crypto.randomUUID(),
},
body,
});
return { status: res.status, body: await res.json() };
}<?php
$base = getenv('KYC_BASE_URL');
$key = getenv('KYC_API_KEY');
$secret = getenv('KYC_API_SECRET');
$path = '/v1/kyc/verify';
$body = json_encode([
'selfie' => ['image' => base64_encode(file_get_contents('selfie.jpg'))],
'reference' => ['image' => base64_encode(file_get_contents('ktp.jpg'))],
]);
$ts = (string) time();
$nonce = bin2hex(random_bytes(16));
$canonical = implode("\n", [$ts, $nonce, 'POST', $path, hash('sha256', $body)]);
$sig = rtrim(strtr(base64_encode(hash_hmac('sha256', $canonical, $secret, true)), '+/', '-_'), '=');
$ch = curl_init($base . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
"X-Api-Key: $key",
"X-Timestamp: $ts",
"X-Nonce: $nonce",
"X-Signature: v1=$sig",
'Idempotency-Key: ' . bin2hex(random_bytes(16)),
],
]);
$response = json_decode(curl_exec($ch), true);import base64, hashlib, hmac, json, os, secrets, time, uuid
import requests
BASE, KEY, SECRET = os.environ["KYC_BASE_URL"], os.environ["KYC_API_KEY"], os.environ["KYC_API_SECRET"]
def b64file(path):
with open(path, "rb") as f:
return base64.b64encode(f.read()).decode()
path = "/v1/kyc/verify"
body = json.dumps({
"selfie": {"image": b64file("selfie.jpg")},
"reference": {"image": b64file("ktp.jpg")},
}).encode()
ts, nonce = str(int(time.time())), secrets.token_hex(16)
canonical = "\n".join([ts, nonce, "POST", path, hashlib.sha256(body).hexdigest()])
mac = hmac.new(SECRET.encode(), canonical.encode(), hashlib.sha256).digest()
signature = base64.urlsafe_b64encode(mac).decode().rstrip("=")
res = requests.post(BASE + path, data=body, headers={
"Content-Type": "application/json",
"X-Api-Key": KEY,
"X-Timestamp": ts,
"X-Nonce": nonce,
"X-Signature": "v1=" + signature,
"Idempotency-Key": str(uuid.uuid4()),
})
print(res.status_code, res.json()["data"]["decision"])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.
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
| Scope | Grants |
|---|---|
identity:check | Identity, NIB and account lookups; bank list |
data:nik_decode · data:name_match | NIK Decoder · Name Match |
biometric:face_match | POST /v1/face/match |
biometric:liveness | POST /v1/liveness/passive |
biometric:selfie_ktp | POST /v1/kyc/selfie-with-ktp |
biometric:kyc_verify | POST /v1/kyc/verify, hosted verification links, GET /v1/policies |
document:check | POST /v1/ktp/authenticity — no personal data returned |
document:ocr | POST /v1/ktp/ocr and options.extractIdentity — responses contain personal data |
risk:manage | Blocklist 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.
| Status | error | Meaning |
|---|---|---|
400 | invalid_request | Malformed input or missing signing headers. |
400 | invalid_idempotency_key | Idempotency-Key must be 8–128 chars of [A-Za-z0-9_-:.]. |
401 | unauthorized | Authentication failed. Deliberately generic — check key, timestamp, nonce and signature. |
403 | insufficient_scope | Valid key without the capability. requiredScope names it. |
404 | not_found | Valid request, no matching record (NIB, account, analysis id). |
409 | idempotency_request_in_progress | A request with this Idempotency-Key is still running. |
409 | not_reviewable | Analysis is not in review, or was already resolved. |
413 | payload_too_large | Image or body over the size/pixel limit. |
415 | unsupported_media | Image is not JPEG or PNG. |
422 | idempotency_key_reused | Same Idempotency-Key with a different request body. |
422 | face_not_usable | The photo is not usable. quality explains which image to retake. |
422 | document_not_readable | No KTP text found (ktp/ocr). |
429 | rate_limited | Per-key request budget exceeded. See Retry-After. |
429 | too_many_concurrent_requests | Too many biometric/OCR requests in flight for this key. |
502 | provider_unavailable | Upstream source or verification service failed. Retry with backoff. |
503 | service_busy | Verification service at capacity. Retry shortly. |
504 | inference_timeout | Analysis 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
Registered name, sex and date of birth from a 16-digit NIK.
GET /v1/identity/check/3201••••••••0001
X-Api-Key: key_live_••••
X-Timestamp: 1757022089
X-Nonce: 9f1c••••
X-Signature: v1=••••{
"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
Legal name, tax ID and OSS issuance date from a 13-digit NIB.
GET /v1/nib/check/0907••••••537
X-Api-Key: key_live_••••
X-Timestamp: 1757022311
X-Nonce: a81d••••
X-Signature: v1=••••{
"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
Holder name for 120+ banks and DANA, OVO, GoPay, ShopeePay.
GET /v1/account/check/MDRIC1ID/1110•••••3597
X-Api-Key: key_live_••••
X-Timestamp: 1757022311
X-Nonce: c2e4••••
X-Signature: v1=••••{
"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
Province, regency, district, birth date, gender and an optional age gate decoded from a NIK, with plausibility checks.
POST /v1/identity/nik/decode
{ "nik": "3201••••••••0001", "minAge": 21 }{
"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
Indonesian-aware name comparison: titles, old spelling, Muhammad variants, initials and word order.
POST /v1/name/match
{
"nameA": "H. Moch. Budi Santoso, S.H.",
"nameB": "MUHAMMAD BUDI SANTOSO"
}{
"data": {
"decision": "match",
"score": 1,
"reasons": ["titles_ignored", "spelling_normalized"]
}
}Billed per comparison. Invalid input is free.
Face Match stable
Compares a selfie with the KTP portrait, after checking both images are usable.
POST /v1/face/match
Idempotency-Key: 3f2b…
{
"selfie": { "image": "<base64>" },
"reference": { "image": "<base64 KTP>" }
}// 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
Presentation-attack detection from a single selfie.
POST /v1/liveness/passive
Idempotency-Key: 8c61…
{ "selfie": { "image": "<base64>" } }// example
{
"data": {
"decision": "live",
"reasons": []
}
}Billed per completed check. Selfies rejected by the quality gate (422) and errors are free.
KTP OCR stable
Structured identity fields from a KTP photo, with NIK consistency checks.
POST /v1/ktp/ocr
Idempotency-Key: 1d9e…
{ "document": { "image": "<base64 KTP>" } }// 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
Document integrity and data-consistency checks on a KTP photo, without returning personal data.
POST /v1/ktp/authenticity
{ "document": { "image": "<base64 KTP>" } }{
"data": {
"status": "PASS",
"checks": { "…": "PASS" },
"reasons": []
}
}Billed per checked card. Unreadable or invalid images and errors are free.
Selfie with KTP stable
Checks a photo of the user holding their KTP against the card they uploaded.
POST /v1/kyc/selfie-with-ktp
{
"holding": { "image": "<face + KTP in hand>" },
"reference": { "image": "<base64 KTP>" }
}{
"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
Selfie and KTP in — approved, review or rejected out.
POST /v1/kyc/verify
Idempotency-Key: 2e7d…
{
"selfie": { "image": "<base64>" },
"reference": { "image": "<base64 KTP>" },
"clientRef": "user_123"
}// 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.
Hosted verification link
POST /v1/verification-sessions returns a single-use URL. Your user completes KTP and selfie on a Kenalyx page in Bahasa Indonesia or English, with your name and accent colour; you get a KYC Verify analysis and a verification_session.completed webhook. Review cases are handled in the dashboard's Reviews queue.
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
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).
No section matches your search.