Build with Veri
Add full identity verification (CNIC, selfie, proof of address and source of funds) to your Android, iOS, Flutter or web app. Eleven API calls, documented one by one in the order you make them, each with a cURL example and the exact response.
How it works
A verification (a case) belongs to one person. Your server starts it, your app walks the person through it, and your server reads the outcome.
User logs in
Your existing auth
Create case
With your API key
Session token
Returned to the app
The API key never leaves your server. The app receives only a short-lived token that can touch one case.
Capture
CNIC, selfie, bills
Upload
Straight to storage
Confirm & submit
Details + consent
The app walks the user through the documents that case needs, shows live feedback, then confirms details and submits.
Read
AI extracts fields
Check
Quality, name, address
Review
Compliance decision
Every document is quality-checked and read, cross-checked against the others, and the case goes to a compliance officer. Your server reads the decision, and everything read off the documents, with its API key.
Get a key
An admin issues one key per app, named after it. It lives on your server.
Start a case
Your server creates a case with your user id and gets a session token.
User verifies
The app uploads documents, fixes retakes, confirms details and consents.
Read the result
Your server looks the case up until it is approved or rejected.
Never put the API key inside the mobile app
session_token from API 1.Quick start
The whole integration in seven steps. Each links to the API calls it uses.
- 1
Get an API key
An admin creates one key per app. It stays on your server, never in the app.
- 2
Start a verification
Your server creates a case and hands the session token to the app.
- 3
Ask three questions first
Country, home address and which ID they will use. The answers decide which documents the case needs.
- 4
Upload each required document
Take the list from required_documents - five for a CNIC, four for a passport. For each: get an upload link, upload the file, confirm it.
- 5
Show progress
Check the case and show each document's feedback. Re-upload any retakes.
- 6
Confirm details
Show what was read from their ID and send back the user's corrections.
- 7
Submit
Once the user has given their mobile number and ticked the consent box.
- 8
Get the result
Your server checks the case until it is approved or rejected.
The screens of our web app, and the API calls behind each. The identity step is one screen for a passport and two for a CNIC, so a CNIC user sees nine and a passport user eight.
- 1CountryWhere the documents are fromAPI 1a
- 2Home addressWhere they live nowAPI 1c
- 3Which IDCNIC or passportAPI 1b
- 4Identity documentBoth CNIC sides, or the passport pageAPI 2 → 4
- 5SelfieFace checkAPI 2 → 4
- 6Proof of addressWhat it is, then the fileAPI 2 → 4
- 7Source of fundsWhat it is, then the fileAPI 2 → 4
- 8Your detailsConfirm & correctAPI 6 → 7
- 9Review & submitConsentAPI 8
Authentication
Base URL
https://kyc-api.codewithmuh.comEvery path ends with a slash (/api/kyc/cases/, not /api/kyc/cases). Request bodies are JSON with Content-Type: application/json.
Two credentials
X-Api-Key: veri_…Your server only. Used for API 1, 9, 10 and 11. Valid until revoked.
Authorization: Bearer <session_token>Your app, for one person. Returned by API 1 and used for API 1a–1c and 2–8. Reaches exactly one case, and lasts 2 hours (renew it with API 11).
Getting an API key
- Ask a Veri admin for a key and say which app it is for (“Android app”, “iOS app”, “Partner backend”). Each app gets its own key, so one can be revoked without breaking the others.
- The admin creates it in Admin → Access → API keys → Add, or with
python manage.py api_key create "Android app". - The key is shown once. Store it on your server as
VERI_API_KEY. If it is lost, ask for a new one and have the old one revoked.
Testing? Ask for a sandbox key. It works exactly like a live key, on the same server and the same AI, but its cases belong to a sandbox tenant: they are kept apart from live customers’ cases, never matched against them as reused documents, and can be wiped at any time, so use test documents only. A key’s tenant is fixed when it is created, so going live means a new, live key.
The app sends the session token on every call. Set up one small client and reuse it:
// React Native / web. Keep the token in memory or secure storage.
const BASE = "https://kyc-api.codewithmuh.com";
let session = { caseId: null, token: null };
export function setSession(caseId, token) {
session = { caseId, token };
}
export async function veri(path, options = {}) {
const res = await fetch(BASE + path, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.token}`,
...options.headers,
},
});
const body = await res.json().catch(() => ({}));
if (res.status === 401) throw new SessionExpired(); // get a new token: API 11
if (!res.ok) throw Object.assign(new Error(body.error), { status: res.status, body });
return body;
}Documents to collect
Your app uploads these with API 2–4, in any order — five for a CNIC, four for a passport. The wording and tips are exactly what our web app shows, so you can reuse them on your screens.
Front of your CNIC
The side with the photo. All four corners visible, text sharp. Lay the card on a flat, dark surface away from direct light; glare is the most common reason a photo fails. Scans, screenshots and photocopies are refused, and so is the wrong document: a passport, or the back of the card.
Back of your CNIC
The side with the barcode and address. Hold the phone close so the card fills the frame: the address is printed small, and usually only in Urdu. A photo of the card itself: a screenshot, a copy or a crop of just the address is refused.
Passport data page
The photo page, instead of the two CNIC sides. Press it flat and fill the frame: the two lines of code at the foot of the page are checked digit by digit, and at arm’s length they are unreadable. A PDF is accepted by the upload call, but a PDF of an identity document is a scan and is refused once it is read: photograph the booklet. A CNIC sent here is refused.
Selfie
One real person, alone, with the whole face visible. A photo with no face, more than one person, a covered face, an animal, a drawing, or a photo of a photo, a screen or an ID is sent back. A reviewer compares it with the photo on the ID.
Proof of address
It must be what evidence_type says it is: a bill sent as BANK_STATEMENT is refused. Scans and photocopies are accepted; screenshots and photos of a screen are refused, and a statement must have every page. A bill or statement must be from the last three months; a tenancy agreement or government letter may be up to a year old, because neither is reissued monthly. It doesn’t need to be in the user’s name; shared and family housing is common, and a reviewer confirms it.
Source of funds
A recent bank or wallet statement with every page, or a signed employment contract or employer’s letter, matching evidence_type. A JazzCash or Easypaisa statement works as well as a bank statement. Payslips are not accepted.
Which of these a case needs depends on the ID the user picked — a CNIC has two sides, a passport has one data page. Don’t hard-code the list: set the choice with API 1b and build your screens from the required_documents it returns. API 1a and API 5 return the same list. Everything below the ID is asked for either way.
Bangladesh (market: "BD"): NATIONAL_ID means NID_FRONT and NID_BACK instead of the two CNIC sides. PASSPORT is the same document type in both markets. Not available yet: a case can be created with market: "BD", but API 1a refuses it with market_unavailable, so the verification cannot be completed. Read available_markets from API 5 and offer only what it lists.
Ask the user what the document is on the last two. evidence_type is required there and the answer changes how the page is read and how old it may be — it is not a formality, and there is no sensible default to send on their behalf.
API calls
In the order you make them. Each call says who makes it (your server or your app), what to send, and exactly what comes back.
Start
Check verification is working
/api/kyc/status/- Auth
- None
- Call it
- Optional: before API 1, or to tell the user when verification is paused or starting up.
Public: no API key and no session, and the answer is the same for everyone. It can be called from your server or straight from your app. Always a 200; the state is in the body.
What to read
stateoptionalready: documents are read in seconds. starting: the reading system is starting up — cases can be started and documents uploaded now, and are read as soon as it is ready. paused: verification is switched off, and API 1 answers 503 until it is back.
minutes_remainingoptionalOnly while starting: about how long until documents are read.
messageoptionalA sentence to show the user, in the same words the 503 uses.
curl https://kyc-api.codewithmuh.com/api/kyc/status/HTTP/1.1 200 OK
{
"state": "starting",
"minutes_remaining": 12,
"message": "Verification is starting up and should be ready in about 12 minutes. Documents uploaded meanwhile are read as soon as it is."
}When it cannot tell — its own reading of the system is out of date — it says ready rather than warn about an outage it cannot see. /api/health/ only says the API itself is up; this is the call that looks at the reading system.
Start a verification
/api/kyc/cases/- Auth
X-Api-Key- Call it
- When the user taps “Verify my identity” in your app.
Body
reference_idrequiredYour own id for this person, so you can find their cases later. Digits, letters or both, e.g. 100245 or USR-100245. 1–64 characters: letters, digits and - _ . :. Not a CNIC, phone number or email.
emailoptionalThe person’s email, kept for your records. Nothing is sent to it.
marketoptionalPK (Pakistan, default) or BD (Bangladesh). BD is accepted here, but a Bangladeshi case cannot be completed yet — API 1a refuses it with market_unavailable. Create Pakistani cases until available_markets says otherwise.
curl -X POST https://kyc-api.codewithmuh.com/api/kyc/cases/ \
-H "X-Api-Key: $VERI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"reference_id": "USR-100245",
"email": "ali@example.com",
"market": "PK"
}'HTTP/1.1 201 Created
{
"case_id": "8d1f0c3e-4b7a-4a51-9f0e-2c6b1d9a7e44",
"reference_id": "USR-100245",
"session_token": "8d1f0c3e-…:1790000000:Qm9x…:5f2a…",
"expires_in": 7200,
"policy_version": "pk-2026.09",
"status": "DOCUMENT_UPLOAD",
"market": "PK",
"identity_type": "NATIONAL_ID",
"required_documents": ["CNIC_FRONT", "CNIC_BACK", "SELFIE",
"PROOF_OF_ADDRESS", "SOURCE_OF_WEALTH"],
// true when the key is a sandbox key: test data, wiped at any time.
"sandbox": false,
"created_at": "2026-09-22T09:14:03.417201Z"
}Save case_id against your user (you need it for API 5–11), and return case_id and session_token to the app. The rest describes the new case: required_documents is what it must collect for the default ID type (it changes if the user picks a passport at API 1b), and sandbox is true when the key is a sandbox key, so the case is test data that can be wiped at any time.
Call this once per person, not once per document. Every document — the ID, the selfie, proof of address and source of funds — goes into that one case through API 2–4 with its session_token. Calling API 1 before each upload starts a new case every time, each holding a single document, and none of them can be completed.
If it fails
| 400 with fields | A field is invalid, e.g. { "reference_id": "required" }. Fix that field. |
| 400 “Invalid tenant.” | The X-Api-Key header is missing altogether on this call. |
| 401 | The API key is wrong or revoked. |
| 503 | Verification is paused. Show the error message and try later. |
Set the case up: three calls before any document
None is enforced — a case has a default country and ID type, and no address — but skipping them costs you something concrete: the wrong upload screens, and a proof of address that is not compared with any address at all. The address on the back of the CNIC never stands in for the home address, so without it the ADDRESS_MATCH check is NOT_APPLICABLE. Make them once before the first upload, and ask for the country first: it decides what the other two mean. The other two are independent of each other — our own web app asks for the home address, then the ID type.
Set the country
/api/kyc/cases/{case_id}/country/- Auth
Authorization: Bearer- Call it
- First, when the user has said which country their documents are from.
You set a default for this when you created the case (API 1, market), because you usually know which market you operate in. This is the applicant confirming or changing it — their documents are what actually get verified, and you often cannot know which country issued them.
Body
marketrequiredPK or BD. Only the ones in available_markets (see API 5) are accepted — read that list, don’t hard-code one.
HTTP/1.1 200 OK
{
"market": "PK",
"required_documents": ["CNIC_FRONT", "CNIC_BACK", "SELFIE",
"PROOF_OF_ADDRESS", "SOURCE_OF_WEALTH"]
}A country Veri knows about is not the same as one an applicant can finish a verification in. available_markets is the second list, and the two are told apart in the error so your UI can say “not yet” rather than “that isn’t a country”. Today it is ["PK"]: Bangladesh has document types and market routing, but not the NID reading, number validation or Porichoy check behind them, and offering it would let someone finish a verification quietly weaker than a Pakistani one.
Changing it retires the identity documents already uploaded, for the same reason API 1b does: the national card is the market’s own, so a CNIC is not evidence on a Bangladeshi case.
If it fails
| 400 { "market": "market_unavailable" } | A real country, but not one the flow can finish yet. Tell the user it’s coming, don’t retry. |
| 400 { "market": "invalid_market" } | Not a country Veri knows about at all. |
| 409 “already been submitted” | The case is finished; the country is fixed. |
Set the ID type
/api/kyc/cases/{case_id}/identity/- Auth
Authorization: Bearer- Call it
- Once, when the user has chosen which ID they will use. Skip it only if you are happy with the default.
Body
identity_typerequiredNATIONAL_ID (the default — CNIC in Pakistan, NID in Bangladesh) or PASSPORT.
HTTP/1.1 200 OK
{
"identity_type": "PASSPORT",
// Everything this case must collect now - the same list API 1a and API 5
// return. Build your upload screens from it rather than hard-coding one:
// a CNIC has two sides, a passport one page.
"required_documents": ["PASSPORT", "SELFIE", "PROOF_OF_ADDRESS", "SOURCE_OF_WEALTH"]
}required_documents comes back with the answer: it is what this case must now collect, and it is the list to build your upload screens from. A CNIC needs two sides, a passport needs one data page — so ask the user before you ask for photos, not after.
Changing it later is allowed and retires whatever was uploaded for the previous choice. That is deliberate: a passport case must not keep a CNIC’s checks on its record. Anything already uploaded for the other ID goes to SUPERSEDED and stops counting.
If it fails
| 400 { "identity_type": "invalid_identity_type" } | Only NATIONAL_ID and PASSPORT exist. A driving licence is not an accepted ID. |
| 409 “already been submitted” | The case is finished; the ID type is fixed. |
Save the home address
/api/kyc/cases/{case_id}/address/- Auth
Authorization: Bearer- Call it
- Once, before any document — ask the user to type where they live now.
This is the address every proof of address is compared against. Ask for it before reading any document, so the documents are checked against what the user says rather than pre-filled from what the documents happen to say. It is the only address a proof of address is compared with, on either identity path: a proof of address showing a different address from the CNIC is not flagged for review.
The comparison reads the street-level parts — house, street, block, sector, phase, area and city. province and postcode are stored with the case and shown to reviewers, but they are not part of the match, so a right address in the wrong province still matches.
Body
arearequiredArea, sector or phase, e.g. DHA Phase 5, Gulberg III or G-11/3.
cityrequirede.g. Lahore. Letters only.
provincerequiredOne of Punjab, Sindh, Khyber Pakhtunkhwa, Balochistan, Islamabad Capital Territory, Gilgit-Baltistan, Azad Jammu and Kashmir.
houseoptionalHouse or flat, e.g. 12 or Flat 3B.
streetoptionalStreet, e.g. 4 or Main Boulevard.
postcodeoptionalFive digits, e.g. 54000.
HTTP/1.1 200 OK
{
"declared_address": {
"house": "12",
"street": "4",
"area": "DHA Phase 5",
"city": "Lahore",
"province": "Punjab"
}
}Sending it again replaces it, and re-runs the address comparison against every document already read — so a user who corrects a typo does not have to re-upload anything.
If it fails
| 400 with fields | Field name to code, e.g. { "province": "invalid_province" }: required, invalid_city, invalid_province, invalid_postcode, too_long, unknown_field (a key this call does not take - note API 7 accepts a wider set). Show it next to that input. |
| 409 “already been submitted” | The case is finished; the address is fixed. |
Upload each document: calls 2, 3 and 4, once per document
The file never goes through the Veri API. The app gets a one-time upload link, sends the file straight to secure storage, then tells Veri it is there. Repeat for each of the documents this case needs — five for a CNIC, four for a passport.
Get an upload link
/api/kyc/documents/initiate/- Auth
Authorization: Bearer- Call it
- Once per document, after the user has taken the photo or picked the file.
Body
document_typerequiredWhich document, e.g. CNIC_FRONT or PASSPORT. See Documents to collect.
content_typerequiredimage/jpeg, image/png or application/pdf. Selfies: JPG or PNG only. A PDF of a CNIC or passport is accepted by this call and then refused once it is read, as a scan of the original — send a photo of the card or booklet instead.
byte_sizerequiredThe file’s exact size in bytes, at most 15 MB. Measure it after compressing the photo.
evidence_typerequiredRequired for PROOF_OF_ADDRESS and SOURCE_OF_WEALTH: what the user says the document is, from the list in Documents to collect. Ask them — it sets how the document is read and how old it is allowed to be. Omitting it is a 400, and so is a value that doesn’t belong to that document.
Every other document is what it is, so don’t send it for a CNIC, a passport or a selfie; sending one there is a 400 too.
debug_retention_consentedoptionalfalse by default, and that is what you want. true keeps this document’s image after processing, for debugging, instead of deleting it — only ever with the user’s explicit consent.
# byte_size must be the file's exact size. Get it with: wc -c < cnic_front.jpg
curl -X POST https://kyc-api.codewithmuh.com/api/kyc/documents/initiate/ \
-H "Authorization: Bearer $SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"document_type": "CNIC_FRONT",
"content_type": "image/jpeg",
"byte_size": 482133
}'HTTP/1.1 201 Created
{
"document_id": "b0c1a3f2-6f1e-4e4b-8a39-0c9d2f6f1a77",
"attempt": 1,
"upload_url": "https://kays-ingest.s3.amazonaws.com/ingest/2026/09/22/...&X-Amz-Signature=...",
"expires_in": 900,
"required_headers": {
"Content-Type": "image/jpeg",
"x-amz-server-side-encryption": "aws:kms",
"x-amz-server-side-encryption-aws-kms-key-id": "arn:aws:kms:me-central-1:…:key/…"
}
}Keep document_id for API 4, and upload_url + required_headers for API 3. The link expires in 15 minutes.
If it fails
| 400 { "evidence_type": "required" } | A proof of address or source of funds needs to say what kind of document it is. Ask the user. |
| 400 { "evidence_type": "invalid_evidence_type" } | That kind isn’t accepted for this document — e.g. an EMPLOYMENT_CONTRACT as proof of address. |
| 400 { "evidence_type": "not_applicable" } | A CNIC, passport or selfie carries no evidence_type. Leave it out. |
| 415 “Use a JPG, PNG or PDF file.” | Wrong content_type. A selfie says “Use a JPG or PNG photo.” instead. |
| 413 “That file is over 15 MB.” | Compress the photo and try again. |
| 401 | The session expired. Get a new token with API 11. |
| 409 “already been submitted” | The case is finished. Nothing more can be uploaded. |
Upload the file
{upload_url}- Auth
- None. Send the required_headers from API 2 instead.
- Call it
- Straight after API 2, within 15 minutes.
Send the raw file bytes as the body. Include every header from required_headers, exactly as given, and nothing else. Don’t send the Bearer token here.
# upload_url and the three headers come from the initiate response.
# Send every required_headers entry exactly as given - and no Authorization header.
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: image/jpeg" \
-H "x-amz-server-side-encryption: aws:kms" \
-H "x-amz-server-side-encryption-aws-kms-key-id: $KMS_KEY_ID" \
--data-binary @cnic_front.jpgHTTP/1.1 200 OK
(empty body - the file is now in storage, but Veri doesn't know yet)If it fails
| 403 SignatureDoesNotMatch | A header is missing or different. Copy required_headers as-is, and make sure your HTTP library doesn’t add “; charset=…” to Content-Type. |
| 403 Request has expired | The link is older than 15 minutes. Start again from API 2. |
| Network error | Start again from API 2 with a new link. |
Confirm the upload
/api/kyc/documents/{document_id}/complete/- Auth
Authorization: Bearer- Call it
- Straight after API 3 succeeds.
No body. Veri checks the file arrived and starts reading it; that takes a few seconds.
curl -X POST https://kyc-api.codewithmuh.com/api/kyc/documents/$DOCUMENT_ID/complete/ \
-H "Authorization: Bearer $SESSION_TOKEN"HTTP/1.1 200 OK
{
"status": "UPLOADED",
"processing": true
}
# Calling it again for the same document is safe, and answers with the
# status alone - no "processing" key:
{ "status": "EXTRACTED" }If it fails
| 409 “We could not find your upload” | The PUT never landed, or byte_size didn’t match the file. Start again from API 2. |
| Not sure it went through | Call it again. It’s safe to repeat, and a repeat answers with the status alone — no processing key. |
Calls 2, 3 and 4 together, in your app
One function that uploads one document. Call it once per document with that document’s document_type:
# One document, all three calls. Needs jq.
FILE=cnic_front.jpg
AUTH="Authorization: Bearer $SESSION_TOKEN"
# API 2 · initiate
INIT=$(curl -s -X POST https://kyc-api.codewithmuh.com/api/kyc/documents/initiate/ \
-H "$AUTH" -H "Content-Type: application/json" \
-d "$(jq -n --argjson s "$(wc -c < $FILE | tr -d ' ')" \
'{document_type: "CNIC_FRONT", content_type: "image/jpeg", byte_size: $s}')")
DOCUMENT_ID=$(echo "$INIT" | jq -r .document_id)
# API 3 · PUT the file, with every required_headers entry
HEADERS=()
while IFS= read -r h; do HEADERS+=(-H "$h"); done \
< <(echo "$INIT" | jq -r '.required_headers | to_entries[] | "\(.key): \(.value)"')
curl -s -X PUT "$(echo "$INIT" | jq -r .upload_url)" "${HEADERS[@]}" --data-binary @$FILE
# API 4 · complete
curl -s -X POST https://kyc-api.codewithmuh.com/api/kyc/documents/$DOCUMENT_ID/complete/ -H "$AUTH"Fewer retakes
Progress and details
Check progress
/api/kyc/cases/{case_id}/- Auth
Authorization: Bearer- Call it
- Every 3–5 seconds while a document is being read, and when the user opens a screen.
// Poll every 4 seconds while the app is waiting on processing.
export async function waitForProgress(onUpdate) {
for (;;) {
const kase = await veri(`/api/kyc/cases/${session.caseId}/`);
onUpdate(kase); // re-render your checklist
if (!isProcessing(kase)) return kase;
await new Promise((r) => setTimeout(r, 4000));
}
}
const isProcessing = (kase) =>
kase.documents.some((d) => d.current && ["UPLOADED", "PROCESSING"].includes(d.status));Or have results pushed
HTTP/1.1 200 OK
{
"id": "8d1f0c3e-4b7a-4a51-9f0e-2c6b1d9a7e44",
"status": "DOCUMENT_UPLOAD",
"market": "PK",
"policy_version": "pk-2026.09",
"created_at": "2026-09-22T09:14:03.417201Z",
// Countries an applicant can finish a verification in - not every country
// the API knows about. Build your picker from this, never a local copy.
"available_markets": ["PK"],
// What the user chose to prove their identity with, and what that choice
// means this case must collect. Drive your screens from required_documents.
"identity_type": "NATIONAL_ID",
"required_documents": ["CNIC_FRONT", "CNIC_BACK", "SELFIE",
"PROOF_OF_ADDRESS", "SOURCE_OF_WEALTH"],
"next_action": "upload_documents",
// The home address the user typed (API 1c). Use it to repopulate that
// screen when a session resumes; {} until they have given it.
"declared_address": {
"house": "12", "street": "4", "area": "DHA Phase 5",
"city": "Lahore", "province": "Punjab"
},
// What the identity document says, as the user confirmed it - null until
// that document has been read. Show it on your review screen: it is what
// is being submitted.
"identity_details": {
"document_type": "CNIC_FRONT",
"name": "Muhammad Haider",
"number": "42101-1234567-9",
"date_of_birth": "1994-03-14"
},
// The mobile number the user gives with the consent (API 8); "" until then.
"mobile_number": "",
"ai": { "state": "ready" },
"documents": [
{
"id": "b0c1a3f2-...",
"document_type": "CNIC_FRONT",
// "" unless the user chose one (proof of address, source of funds).
"evidence_type": "",
"status": "EXTRACTED",
"attempt": 1,
"current": true,
"quality_score": 0.91,
"uploaded_at": "2026-09-22T09:16:40.120443Z",
"feedback": {
"verdict": "ok",
"items": [
{ "label": "Name read", "state": "ok" },
{ "label": "CNIC number read", "state": "ok" }
],
"message": "Looks good.",
// A named problem, when there is one, and whether retaking can fix
// it. blocking: true means it cannot - stop offering a retake.
"code": null,
"blocking": false
},
// This document's own checks, latest result of each.
"checks": [
{ "check_type": "CNIC_STRUCTURE", "result": "PASS", "score": 0.5,
"explanation": "...", "evidence": { "province": "Sindh", "warnings": [] } }
],
// What was read off it, as the user confirmed it: the reading with
// each of their corrections applied. null until it has been read.
"fields": {
"name": "Muhammad Haider",
"father_name": "Abdul Rehman",
"cnic_number": "42101-1234567-9",
"date_of_birth": "14.03.1994",
"date_of_issue": "17.08.2021",
"date_of_expiry": "17.08.2031",
"gender": "M",
"country_of_stay": ""
},
// What the user changed on the details step, from what to what.
"corrections": [
{ "field": "name", "read": "Muhamad Haider", "confirmed": "Muhammad Haider" }
],
"confidence": {},
"overall_confidence": 0.88
},
{
"id": "e71c...",
"document_type": "SELFIE",
"evidence_type": "",
"status": "QUALITY_FAILED",
"attempt": 1,
"current": true,
"quality_score": 0.41,
"uploaded_at": "2026-09-22T09:18:02.511870Z",
"feedback": {
"verdict": "retake",
"items": [{ "label": "Too blurry", "state": "fail" }],
"message": "Hold the phone steady and retake the photo in good light.",
"code": null,
"blocking": false
},
"checks": [],
// A selfie has nothing to read.
"fields": null,
"corrections": [],
"confidence": {},
"overall_confidence": null
}
],
// One row per check: the most serious result where several documents ran it.
"checks": [
{ "check_type": "CNIC_STRUCTURE", "result": "PASS", "score": 0.5, "explanation": "...",
// The scores, counts and categories behind the result - never a value
// read off the document.
"evidence": { "province": "Sindh", "warnings": [] },
// The document it ran on; null for a check on the whole case.
"document_id": "b0c1a3f2-...", "document_type": "CNIC_FRONT" }
]
}What to read
next_actionWhich screen to show: upload_documents, retake_document, confirm_details (go to API 6), review_and_consent (go to API 8) or wait.
identity_detailsdocument_type, name, number and date_of_birth from the CNIC or passport, as the user confirmed them at API 7 — the number dashed (a CNIC) or compact (a passport), the date as YYYY-MM-DD where it could be read as one. null until the document has been read. Show them on your review screen, beside the consent box. document_type tells you whether to label the number “CNIC number” or “Passport number”.
mobile_numberThe number the user gave with their consent (API 8), in E.164. "" until they have submitted — use it to prefill the field if they come back.
declared_addressThe home address they typed at API 1c, so a resumed session can repopulate that screen. {} until they have given it.
documents[]Every attempt at every document. Use the entries with current: true.
documents[].fieldsEverything read off the document, as the user confirmed it: the reading with each of their corrections from API 7 applied. The keys are those API 6 lists for that document type — for a proof of address address, city, postcode, issue_date and the rest; for a passport cnic_number among them. null until the document has been read, and always for a selfie, which has nothing to read.
documents[].correctionsWhat the user changed: field, the value as read and as confirmed. [] when they changed nothing.
documents[].checksThat document’s own checks, the latest result of each, with its evidence. Also confidence and overall_confidence (0–1) for the reading, and uploaded_at.
checks[]One row per check type, the most serious result where several documents ran it: the result, score, explanation, the evidence behind it — scores, counts and categories, such as match_percent for the address or age_days for a bill, never a value read off the document — and the document_id and document_type it ran on (null for a check on the whole case).
documents[].feedbackverdict (ok / warn / retake), a checklist of items, a message to show the user, a code naming the problem when there is a named one (e.g. cnic_expired, passport_expired), and blocking. null while still reading.
A document that was sent back (QUALITY_FAILED) names what was wrong with it: wrong_document (not the document the step asks for, or not the evidence_type chosen), screen_capture, not_a_photo, scanned_copy and photocopy (CNIC and passport only: a bill or statement may be a scan), partial_document, incomplete_statement, and for the selfie no_face, multiple_faces, face_too_small, face_not_visible, not_a_person and not_a_live_photo. The case waits until a new upload of that document is read.
A document that was read but cannot be used names that too: not_in_your_name — a proof of address or a bank or wallet statement in another person’s name. It is refused, never sent to a reviewer: its NAME_CONSISTENCY check is FAIL, the case waits in CUSTOMER_ACTION_REQUIRED until a document in the user’s own name is read, and uploading the same document again does not change that. Ask for a different document, not a sharper photo; API 7 and 8 answer 409 with the reason until one arrives.
blocking: true means another photo cannot help — an expired ID stays expired. Show the message and stop offering a retake, or the user will loop.
ai.stateready, starting (with minutes_remaining) or paused.
Show the same states as our web app
| The current document has | Show |
|---|---|
status UPLOADED or PROCESSING | A spinner: “Reading the front of your CNIC…” |
the same, and ai.state starting | “Saved — it will be read shortly”. Let the user carry on. |
feedback.verdict ok | “Looks good”, with feedback.items as a checklist |
feedback.verdict warn | “Read — with a note for the reviewer” and feedback.message |
feedback.verdict retake | “Please retake this photo” and feedback.message. Upload it again with the same document_type (API 2–4). |
Read the extracted details
/api/kyc/documents/{document_id}/result/- Auth
Authorization: Bearer- Call it
- When
next_actionisconfirm_details, for the identity document —CNIC_FRONTplusCNIC_BACKfor the address, orPASSPORTon its own — and forPROOF_OF_ADDRESS, to show the address it carries.
Returns what was read from the ID, to prefill the “Your details” screen: the document’s document_type, evidence_type and attempt, every field as read, and the user’s corrections beside them once they have made any. The same fields with those corrections applied are in API 5 and, for your server, API 9.
Call it for the proof of address as well, on either identity path, and show that address read-only on the same screen — our web app does. It is the reading the user’s home address is checked against, so letting them edit it here would either change nothing or let them type their way past the check. A wrong reading is fixed by uploading a different document, not by editing it.
curl https://kyc-api.codewithmuh.com/api/kyc/documents/$DOCUMENT_ID/result/ \
-H "Authorization: Bearer $SESSION_TOKEN"HTTP/1.1 200 OK
{
"document_id": "b0c1a3f2-...",
"document_type": "CNIC_FRONT",
"evidence_type": "",
"attempt": 1,
"status": "EXTRACTED",
// As read, to prefill the details screen.
"fields": {
"name": "Muhammad Haider",
"father_name": "Abdul Rehman",
"cnic_number": "42101-1234567-9",
"date_of_birth": "14.03.1994",
"date_of_issue": "17.08.2021",
"date_of_expiry": "17.08.2031",
"gender": "M"
},
// What the user changed on the details step; [] until they have.
"corrections": [],
"confidence": { "name": 0.98, "father_name": 0.74, "cnic_number": 0.99, ... },
"boxes": { "name": [0.08, 0.31, 0.52, 0.37], ... },
"overall_confidence": 0.93,
"route": "PRIMARY_OCR",
"source_available": false
}That is a CNIC_FRONT. Every key for every document type, with full responses for a passport, a proof of address and a statement, is in Fields by document type.
fieldsThe values to show. null if the document hasn’t been read yet — and in that case the body is only { status, fields }: confidence, boxes, overall_confidence, route and source_available are absent, not null.
confidence0–1 per field. Highlight the low ones so the user checks them.
boxesWhere each field is on the photo, as [left, top, right, bottom] fractions of the image (0–1). Use it to highlight the field on the user’s own photo when they tap it.
The back of the card
On most cards the address on the back is printed only in Urdu. It is read as printed and written in English, so for CNIC_BACK show both and let the user correct either one (API 7, cnic_address). The front is read from its English fields only. A passport has no back and no address, so none of this applies on that path — there is one result to read, and the address comes from API 1c alone.
HTTP/1.1 200 OK
{
"document_id": "6a2d90c4-...",
"document_type": "CNIC_BACK",
"evidence_type": "",
"attempt": 1,
"status": "EXTRACTED",
"fields": {
"address": "House No. 12, Gali No. 4, DHA Phase 5, Lahore",
"address_original": "مکان نمبر 12، گلی نمبر 4، ڈی ایچ اے فیز 5، لاہور",
"address_script": "urdu",
"document_date": "17.08.2021"
},
"confidence": { "address": 0.81, "address_original": 0.66, ... },
...
}addressThe permanent address in English, for the user to confirm and for the reviewer. It is not compared with the proof of address — that is compared with the home address from API 1c only.
address_originalThe address exactly as printed on the card, in its own script, not translated. Show it right to left (dir="rtl") when address_script is urdu or mixed.
address_scripturdu, english, mixed, or "" if it couldn’t be told. When it is urdu or mixed, the English address was written from the Urdu, so ask the user to check it.
The proof of address
Besides the one-line address, a PROOF_OF_ADDRESS result carries the city and the postcode of that address on their own, for a screen that shows them in separate fields, and the date the document was issued. All three are always present; a value that couldn’t be found is "".
HTTP/1.1 200 OK
{
"document_id": "3f9e2a71-...",
"document_type": "PROOF_OF_ADDRESS",
"evidence_type": "UTILITY_BILL",
"attempt": 1,
"status": "EXTRACTED",
"fields": {
"account_holder_name": "MUHAMMAD HAIDER",
"address": "House # 181, Block E, Nawab Town, Johar Town, Lahore",
"city": "Lahore",
"postcode": "54000",
"issuer": "Lahore Electric Supply Company",
// The date it was issued, as YYYY-MM-DD; "" when none is printed.
"issue_date": "2026-10-01",
// The same date as printed, and the period the bill covers.
"document_date": "01-Oct-2026",
"period_start": "01-Sep-2026",
"period_end": "30-Sep-2026"
},
"corrections": [],
"confidence": {},
"boxes": {},
"overall_confidence": 0.88,
"route": "PRIMARY_OCR",
"source_available": false
}cityThe city or town, without the province, e.g. Lahore — not in capitals even when the bill prints it that way. Taken from the address even when the document has no separate city field: an address ending “…, Johar Town, Lahore” gives Lahore. Empty only when the address names no city the AI could pick out.
postcodeFive digits. The one printed on the document if there is one, including at the end of the address (LAHORE-54700); otherwise, since most bills print none, the main post office code of the city — 54000 for Lahore, 74200 for Karachi. Empty when neither is known. A main post office code may not be the user’s own area’s, so let them change it.
issue_dateThe date the document was issued, as YYYY-MM-DD: a bill’s bill or issue date (never its due date), a statement’s statement or issue date, a letter’s date. Empty when none is printed, when only a month is (a bill month), or when the date has more than one reading (05/10/26). document_date is the same date as printed, and period_start and period_end the period a bill or statement covers, also as printed.
A passport
A PASSPORT result carries passport_number and nationality, the holder’s cnic_number on its own (below), plus mrz_line1 and mrz_line2: the two lines of code at the foot of the page. Those two are evidence, not a field to show — don’t put them in front of the user. They are verified against their own ICAO 9303 check digits and cross-checked with the printed fields, and the outcome appears in API 5 as the MRZ_CHECKSUM and MRZ_VIZ_CONSISTENCY checks.
HTTP/1.1 200 OK
{
"document_id": "55101bc6-...",
"document_type": "PASSPORT",
"evidence_type": "",
"attempt": 1,
"status": "EXTRACTED",
"fields": {
"name": "Muhammad Haider",
"surname": "HAIDER",
"given_names": "MUHAMMAD",
"father_name": "Abdul Rehman",
"passport_number": "AB1234567",
"nationality": "PAKISTANI",
"country_code": "PAK",
// The holder's CNIC, dashed. "" on a passport from any other country.
"cnic_number": "42101-1234567-9",
"citizenship_number": "42101-1234567-9",
"date_of_birth": "14 MAR 1994",
"place_of_birth": "LAHORE",
"date_of_issue": "17 AUG 2021",
"date_of_expiry": "16 AUG 2031",
"gender": "M",
"issuing_authority": "PAKISTAN",
// Evidence for the MRZ checks - don't show these to the user.
"mrz_line1": "P<PAKHAIDER<<MUHAMMAD<<<<<<<<<<<<<<<<<<<<<<<",
"mrz_line2": "AB12345671PAK9403143M31081704210112345679<90"
},
"corrections": [],
"confidence": {},
"boxes": {},
"overall_confidence": 0.94,
"route": "PRIMARY_OCR",
"source_available": false
}cnic_numberThe holder’s CNIC number, dashed as on the card: 42101-1234567-9. A Pakistani passport carries it twice, printed as the citizenship number and in the code at the foot of the page; the copy in the code is used when its check digit verifies, otherwise the printed one. Empty for a passport from any other country, or when neither copy is a 13-digit CNIC. citizenship_number is the printed number as read.
A failing check digit is never a rejection: on a phone photo the likeliest cause is a misread, so it goes to a reviewer. And a passing one is not proof of authenticity — the algorithm is public, so it says the zone was read correctly, not that the passport was issued.
Confirm details
/api/kyc/cases/{case_id}/details/- Auth
Authorization: Bearer- Call it
- When the user taps Confirm on the details screen. Required even if nothing changed.
Body
correctionsrequiredOnly the fields the user changed, e.g. { "father_name": "Abdul Rehman" }. Send {} if everything was right. Each value is at most 128 characters.
Four of them cannot end up blank: name, the ID number, date_of_birth and date_of_expiry — if the model could not read one, the user has to type it before they can continue. father_name, date_of_issue, gender and nationality may stay empty.
Allowed on a CNIC or NID: name, father_name, cnic_number, date_of_birth, date_of_issue, date_of_expiry, gender (M/F/X).
Allowed on a passport: the same, but passport_number and nationality in place of cnic_number. Sending a field that doesn’t belong to the ID the user chose is a 400 with unknown_field — a correction to something they were never shown is not a correction.date_of_expiry accepts Lifetime on a CNIC, because NADRA prints that on some cards. No passport office does, so on a passport it must be a real date.
declared_addressoptionalOnly if the user edited their home address on this screen. Keys: house, street, block, sector, phase, area, city, province, postcode, free_text — a wider set than API 1c takes.
It replaces the stored address rather than merging into it, so send the whole address, not just what changed. It is held to the same rules as API 1c ( area, city and province required, unless you send free_text), and refused with the same codes — which is what stops a partial object quietly wiping the city the address checks depend on.
cnic_addressoptionalThe permanent address on the back of the CNIC, if the user corrected it: address (English) and/or address_original (as printed, e.g. in Urdu), both from the CNIC_BACK result in API 6. Leave it out entirely on the passport path — a passport has no address to correct. Send only the keys the user changed, and leave cnic_address out if they changed neither. address can’t be empty, and each value is at most 512 characters. The correction is kept for the reviewer; it does not change the address check, which compares the proof of address with the home address only. This is the address printed on the card, not where the user lives now (declared_address). In Bangladesh it corrects the NID_BACK reading.
{
"corrections": { "father_name": "Abdul Rehman" },
// Optional, and only if the user edited their home address here. It
// REPLACES the stored one, so send the whole address, not the parts that
// changed: area, city and province are required, as at API 1c.
"declared_address": {
"house": "12", "street": "4", "area": "DHA Phase 5",
"city": "Lahore", "province": "Punjab"
},
"cnic_address": {
"address": "House No. 12, Street No. 4, DHA Phase 5, Lahore"
}
}HTTP/1.1 200 OK
{
"status": "FINAL_REVIEW_AND_CONSENT",
"corrections_recorded": 2
}// API 6 · Read what was extracted from the ID card (to prefill your form)
const cnicFront = kase.documents.find((d) => d.current && d.document_type === "CNIC_FRONT");
const result = await veri(`/api/kyc/documents/${cnicFront.id}/result/`);
// result.fields -> { name, father_name, cnic_number, date_of_birth, ... }
// result.confidence -> { name: 0.98, ... } (highlight low-confidence fields)
// The back carries the address, often printed only in Urdu: show both versions.
const cnicBack = kase.documents.find((d) => d.current && d.document_type === "CNIC_BACK");
const back = await veri(`/api/kyc/documents/${cnicBack.id}/result/`);
// back.fields -> { address, address_original, address_script: "urdu" | "english" | "mixed" | "" }
// The address read from the proof of address, shown read-only beside them:
// it is what the home address is checked against, so it is not editable.
const proof = kase.documents.find((d) => d.current && d.document_type === "PROOF_OF_ADDRESS");
const poa = await veri(`/api/kyc/documents/${proof.id}/result/`);
// poa.fields -> { address, city, postcode, account_holder_name, issuer, issue_date, document_date, ... }
// postcode: as printed, else the city's main post office code ("54000" for Lahore), else ""
// issue_date: "YYYY-MM-DD", or "" when no single issue date is printed
// On the passport path: the holder's CNIC number, dashed, beside the passport number.
// passport.fields -> { name, passport_number, nationality, cnic_number: "42101-1234567-9", ... }
// API 7 · Send back ONLY the fields the user changed (send {} if none)
try {
await veri(`/api/kyc/cases/${session.caseId}/details/`, {
method: "PATCH",
body: JSON.stringify({
corrections: { father_name: "Abdul Rehman" },
declared_address: { // optional; REPLACES the stored address, so send it whole
house: "12", street: "4", area: "DHA Phase 5", city: "Lahore", province: "Punjab",
},
cnic_address: { // optional, only the CNIC address keys the user changed
address: "House No. 12, Street No. 4, DHA Phase 5, Lahore",
},
}),
});
} catch (e) {
// fields names the problem per field - and it can name a field you did
// NOT send: every confirmable field is checked as it now stands, so an
// expiry the user never touched can come back { date_of_expiry: "expired" }.
if (e.status === 400) showFieldErrors(e.body.fields); // { cnic_number: "invalid_cnic" }
else throw e;
}When a value is invalid
HTTP/1.1 400 Bad Request
{
"error": "Some details need correcting.",
"fields": { "cnic_number": "invalid_cnic" }
}fields maps each field to a code: required, invalid_name, invalid_cnic, invalid_passport, invalid_nationality, invalid_date, implausible_date, invalid_gender, future_date (issued in the future), before_birth (issued before the date of birth), before_issue (expires before it was issued), expired, too_long or unknown_field. Show it next to that input. A problem in cnic_address is reported under its own key: address (required or too_long) or address_original (too_long).
It checks every field, not only the ones you send
{} body can still come back 400 naming a field the user never touched, for instance { "date_of_expiry": "expired" } or { "cnic_number": "invalid_cnic" }. Be ready to show an error on any field on that screen, not only the ones you submitted. An unreadable CNIC number the user left alone is as wrong as one they typed.If it fails
| 409 | A document still needs reading or retaking. Check API 5 and follow next_action. |
| 409 “already been submitted” | The case is finished; details can no longer be changed. |
A retake after confirming means confirming again
DOCUMENT_UPLOAD, and once the new file has been read it returns to confirm_details — this call has to be made again before API 8 will accept the case. Follow next_action rather than treating this as a one-off step; our own web app shows a “please confirm your details again” banner when it happens.Submit
Consent and submit
/api/kyc/cases/{case_id}/submit/- Auth
Authorization: Bearer- Call it
- When the user ticks your consent box and taps Submit.
Body
consentrequiredMust be the boolean true (not the string "true").
mobile_numberrequiredThe user’s mobile number with its country code, like +923001234567. Spaces, dashes, brackets, a leading 00 and the trunk 0 after the country code are all fine; it is stored as +923001234567 and returned with the case (API 9). A Pakistani or Bangladeshi number must be a mobile number, not a landline.
Accepted country codes today: +92, +880, +971, +966, +974, +968, +965, +973, +44, +1, +60, +61, +39, +34, +49 — the markets we serve and where their people most often live. Anything else is invalid_country_code; tell us if you need one added.
// Only after the user has ticked your consent checkbox.
const { status } = await veri(`/api/kyc/cases/${session.caseId}/submit/`, {
method: "POST",
body: JSON.stringify({
consent: true, // must be the boolean true
mobile_number: "+923001234567", // with its country code
}),
});
// status === "COMPLIANCE_REVIEW" -> show "Thanks, we're reviewing your documents"HTTP/1.1 200 OK
{
"status": "COMPLIANCE_REVIEW"
}The case is now with a compliance officer, and the session can no longer change it. Show a “We’re reviewing your documents” screen. Repeating the call is safe; it returns the current status.
If it fails
| 400 “Consent is required to submit.” | consent was missing or not the boolean true. This one carries no fields. |
| 400 { "mobile_number": "required" } | No number was sent. It is required: nothing is stamped until it passes. |
| 400 { "mobile_number": "invalid_country_code" } | The number starts with a country code we do not accept yet. |
| 400 { "mobile_number": "invalid_mobile" } | Not a mobile number for that country — including a number sent with no country code at all, e.g. 03001234567. Ask for it with the code. |
| 409 | Details aren’t confirmed yet (API 7), or a document needs retaking. |
On your server, afterwards
Get the result
/api/kyc/partner/cases/{case_id}/- Auth
X-Api-Key- Call it
- After submission, on a schedule or when the user opens the relevant screen, until the status is final.
Everything Veri holds on the case, for your server. Besides the status, it returns what the user’s own session sees at API 5: the identity_details, the declared_address they typed, and every document with what was read off it as they confirmed it (documents[].fields), what they corrected (documents[].corrections), and each check with its evidence. It works at any point, not only after submission — call it when a webhook says a document has a result, to read that result’s values.
curl https://kyc-api.codewithmuh.com/api/kyc/partner/cases/$CASE_ID/ \
-H "X-Api-Key: $VERI_API_KEY"HTTP/1.1 200 OK
{
"case_id": "8d1f0c3e-4b7a-4a51-9f0e-2c6b1d9a7e44",
"reference_id": "USR-100245",
"email": "ali@example.com",
"mobile_number": "+923001234567",
"app": "Android app",
// true for a sandbox key's case: test data, wiped at any time.
"sandbox": false,
"status": "COMPLIANCE_REVIEW",
"market": "PK",
// This user verified with a passport (the CNIC path is in API 5's example).
"identity_type": "PASSPORT",
"policy_version": "pk-2026.09",
"created_at": "2026-09-22T09:14:03.417201Z",
"updated_at": "2026-09-22T09:31:40.901277Z",
"consent_given_at": "2026-09-22T09:31:40.882913Z",
"submitted_at": "2026-09-22T09:31:40.882913Z",
"decided_at": null,
"next_action": "wait",
"required_documents": ["PASSPORT", "SELFIE", "PROOF_OF_ADDRESS", "SOURCE_OF_WEALTH"],
// The identity document's name, number and date of birth, as confirmed.
// On a passport, number is the PASSPORT number: the CNIC is in the
// passport document's fields.cnic_number, below.
"identity_details": {
"document_type": "PASSPORT",
"name": "Muhammad Haider",
"number": "AB1234567",
"date_of_birth": "1994-03-14"
},
// The home address the user typed (API 1c); {} if it was never given.
"declared_address": {
"house": "181", "block": "E", "area": "Johar Town",
"city": "Lahore", "province": "Punjab"
},
// Every attempt at every document, exactly as API 5 lists them: status,
// readout, its own checks, and what was read off it as the user confirmed
// it ("fields") with what they changed ("corrections").
"documents": [
{
"id": "3f9e2a71-...",
"document_type": "PROOF_OF_ADDRESS",
"evidence_type": "UTILITY_BILL",
"status": "EXTRACTED",
"attempt": 1,
"current": true,
"quality_score": 0.87,
"uploaded_at": "2026-09-22T09:24:11.302915Z",
"feedback": { "verdict": "ok", "items": [ ... ], "message": "...", "code": null, "blocking": false },
"checks": [
{ "check_type": "DOCUMENT_RECENCY", "result": "PASS", "score": 1.0,
"explanation": "Document dated 15 days ago; policy window is 92 days.",
"evidence": { "age_days": 15, "max_age_days": 92 } }
],
"fields": {
"account_holder_name": "Muhammad Haider",
"address": "House # 181, Block E, Nawab Town, Johar Town, Lahore",
"city": "Lahore",
"postcode": "54000",
"issuer": "Cyber Internet Services",
"issue_date": "2026-10-01",
"document_date": "01-Oct-2026",
"period_start": "01-Oct-2026",
"period_end": "31-Oct-2026"
},
"corrections": [],
"confidence": {},
"overall_confidence": 0.75
},
{
// The holder's CNIC is fields.cnic_number.
"id": "55101bc6-...",
"document_type": "PASSPORT",
"evidence_type": "",
"status": "EXTRACTED",
"attempt": 1,
"current": true,
"quality_score": 0.85,
"uploaded_at": "2026-09-22T09:16:40.120443Z",
"feedback": { "verdict": "ok", "items": [ ... ], "message": "...", "code": null, "blocking": false },
"checks": [
{ "check_type": "MRZ_CHECKSUM", "result": "PASS_WITH_CONDITIONS", "score": 0.65,
"explanation": "...",
"evidence": { "check_digits": { "document_number": true, "date_of_birth": true,
"date_of_expiry": true, "composite": true, "personal_number": true },
"failed": [], "issuing_state": "PAK", "warnings": [] } }
],
"fields": {
"name": "Muhammad Haider",
"surname": "HAIDER",
"given_names": "MUHAMMAD",
"father_name": "Abdul Rehman",
"passport_number": "AB1234567",
"nationality": "PAKISTANI",
"country_code": "PAK",
"cnic_number": "42101-1234567-9",
"citizenship_number": "42101-1234567-9",
"date_of_birth": "14 MAR 1994",
"place_of_birth": "LAHORE",
"date_of_issue": "17 AUG 2021",
"date_of_expiry": "16 AUG 2031",
"gender": "M",
"issuing_authority": "PAKISTAN",
"mrz_line1": "P<PAKHAIDER<<MUHAMMAD<<<<<<<<<<<<<<<<<<<<<<<",
"mrz_line2": "AB12345671PAK9403143M31081704210112345679<90"
},
"corrections": [],
"confidence": {},
"overall_confidence": 0.94
},
...
],
"checks": [ ... ],
// Whether documents are read now or wait for the AI to start.
"ai": { "state": "ready" }
}The values are read with your API key, here, and never pushed: a webhook says a result is ready but carries nothing read off the document. sandbox: true marks a sandbox key’s case — test data, never a real customer’s verification.
| status | What to do |
|---|---|
COMPLIANCE_REVIEW / MANUAL_REVIEW | Still being reviewed. Check again later. |
APPROVED | Verified. Unlock the features that need KYC. |
REJECTED | Not verified. Show your support contact. |
MORE_INFORMATION_REQUIRED | The compliance team follows up with the user directly. Re-uploading through the API isn’t supported for this status yet. |
If it fails
| 404 | No case with that id was created by your organisation. |
List a user's cases
/api/kyc/partner/cases/?reference_id={your_id}- Auth
X-Api-Key- Call it
- To find the verifications for one of your users, newest first.
Query
reference_idrequiredThe same id you sent at API 1, matched exactly. There is no “list everything” call: without it you get a 400.
curl "https://kyc-api.codewithmuh.com/api/kyc/partner/cases/?reference_id=USR-100245" \
-H "X-Api-Key: $VERI_API_KEY"HTTP/1.1 200 OK
{
"results": [
{
"case_id": "8d1f0c3e-4b7a-4a51-9f0e-2c6b1d9a7e44",
"reference_id": "USR-100245",
"email": "ali@example.com",
"mobile_number": "+923001234567",
"app": "Android app",
"sandbox": false,
"status": "COMPLIANCE_REVIEW",
"market": "PK",
"identity_type": "NATIONAL_ID",
"policy_version": "pk-2026.09",
"created_at": "2026-09-22T09:14:03.417201Z",
"updated_at": "2026-09-22T09:31:40.901277Z",
"consent_given_at": "2026-09-22T09:31:40.882913Z",
"submitted_at": "2026-09-22T09:31:40.882913Z",
"decided_at": null
}
]
}Newest first, and capped at the 50 most recent — there is no paging, so for a user with more than that, the older ones are not returned. If the newest case isn’t submitted yet, resume it with API 11 rather than starting a new one. Each row is the case’s summary, as at the top of API 9; read the documents and checks there.
If it fails
| 400 { "reference_id": "required" } | The query parameter is missing or blank. |
| 401 | The API key is wrong, revoked or missing. |
| Empty results | That reference_id has no cases under your organisation — check you sent your own id. |
New session token
/api/kyc/partner/cases/{case_id}/session/- Auth
X-Api-Key- Call it
- When the app gets a 401, or the user comes back to an unfinished verification.
Session tokens last 2 hours. This gives the app a new one for the same case, so documents already uploaded are kept. Never create a new case just because a token expired.
curl -X POST https://kyc-api.codewithmuh.com/api/kyc/partner/cases/$CASE_ID/session/ \
-H "X-Api-Key: $VERI_API_KEY"HTTP/1.1 201 Created
{
"case_id": "8d1f0c3e-4b7a-4a51-9f0e-2c6b1d9a7e44",
"session_token": "8d1f0c3e-…:1790007200:Zk2p…:9c1d…",
"expires_in": 7200,
"status": "DOCUMENT_UPLOAD"
}If it fails
| 409 | The case is past the applicant's hands — submitted, in review, approved, rejected or awaiting more information. Show the “under review” screen. |
| 404 | No case with that id was created by your organisation. |
| 401 | The API key is wrong, revoked or missing. |
Fields by document type
Every key of the fields object, for each document type, with its exact name and format. Map your backend to these.
fields is the same object everywhere it appears: API 6 returns it as read; API 5 and, for your server, API 9 return it in documents[].fields as the user confirmed it, with their corrections applied. Keys are snake_case, and every value is a string unless the table says otherwise.
A missing key means empty
"" — and a key can also be missing altogether when the document didn’t show it (a passport without a place of birth comes back without place_of_birth). Read every key as optional and treat a missing one like "". Only the keys marked Always present are guaranteed. fields itself is null until the document has been read, and always for a SELFIE, which has nothing to read.CNIC_FRONT
Read from the English (Latin-script) side of the card only.
| Key | What it is | Example |
|---|---|---|
name | Full name, as a person writes it | Muhammad Haider |
father_name | Father's or husband's name | Abdul Rehman |
cnic_number | CNIC number as printed, with its dashes | 42101-1234567-9 |
date_of_birth | As printed (the card uses DD.MM.YYYY) | 14.03.1994 |
date_of_issue | As printed | 17.08.2021 |
date_of_expiry | As printed; Lifetime on a lifetime card | 17.08.2031 |
gender | M, F or "" | M |
country_of_stay | As printed | Pakistan |
CNIC_BACK
The permanent address, usually printed only in Urdu.
| Key | What it is | Example |
|---|---|---|
address | The permanent address in English | House 12, Street 4, DHA Phase 5, Lahore |
address_original | The address exactly as printed, in its own script | مکان نمبر 12، گلی نمبر 4، ڈی ایچ اے فیز 5، لاہور |
address_script | urdu, english, mixed or "" | urdu |
document_date | A date printed on the back (the card's issue date), as printed | 17.08.2021 |
PASSPORT
The data page. Dates are as printed. The CNIC number is cnic_number - not identity_details.number, which on this path is the passport number.
| Key | What it is | Example |
|---|---|---|
name | Full name, given names first, as a person writes it | Muhammad Haider |
surname | As printed | HAIDER |
given_names | As printed | MUHAMMAD |
father_name | Father's or husband's name, if printed | Abdul Rehman |
passport_number | As printed | AB1234567 |
nationality | As printed | PAKISTANI |
country_code | Three-letter code | PAK |
cnic_number | The holder's CNIC, dashed. Always present; "" on a passport from any other country | 42101-1234567-9 |
citizenship_number | The citizenship or national identity number, as printed | 42101-1234567-9 |
date_of_birth | As printed | 14 MAR 1994 |
place_of_birth | As printed | LAHORE |
date_of_issue | As printed | 17 AUG 2021 |
date_of_expiry | As printed | 16 AUG 2031 |
gender | M, F, X or "" | M |
issuing_authority | As printed | PAKISTAN |
mrz_line1 | First line of the code at the foot of the page, 44 characters. Evidence: don't show it | P<PAKHAIDER<<MUHAMMAD<<<… |
mrz_line2 | Second line, 44 characters. Evidence: don't show it | AB12345671PAK9403143M3108… |
PROOF_OF_ADDRESS
Utility bill, bank or wallet statement, tenancy agreement or government letter - the same keys for all of them.
| Key | What it is | Example |
|---|---|---|
account_holder_name | The name it is addressed to, as printed | MUHAMMAD HAIDER |
address | The customer's own address, in one line | House # 181, Block E, Johar Town, Lahore |
city | City of that address, no province. Always present | Lahore |
postcode | Five digits: printed, else the city's main post office code. Always present | 54000 |
issuer | The organisation that issued it | Lahore Electric Supply Company |
issue_date | Date issued, YYYY-MM-DD; "" when none is printed. Always present | 2026-10-01 |
document_date | The same date, as printed | 01-Oct-2026 |
period_start | Start of the period it covers, as printed | 01-Sep-2026 |
period_end | End of the period it covers, as printed | 30-Sep-2026 |
SOURCE_OF_WEALTH
Bank statement, wallet statement, employment contract or employer letter. Amounts are strings, exactly as printed.
| Key | What it is | Example |
|---|---|---|
account_holder_name | As printed | MUHAMMAD HAIDER |
account_number | As printed | 0123456789012 |
address | As printed | House 88, Street 19, DHA Phase 5, Lahore |
bank_name | The bank, wallet or employer | Bank Alfalah |
opening_balance | As printed | 50,000.00 |
closing_balance | As printed | 300,000.00 |
period_start | As printed | 01 Jun 2026 |
period_end | As printed | 31 Aug 2026 |
page_numbers | Page numbers printed on it - an array of integers | [1, 2, 3] |
transactions | Every row - an array of objects with date, description, debit, credit and running_balance, all strings as printed | [{ "date": "01 Jul 2026", … }] |
Full responses
Each document type as API 6 returns it. The same fields object is in each document of API 5 and API 9.
HTTP/1.1 200 OK
{
"document_id": "55101bc6-...",
"document_type": "PASSPORT",
"evidence_type": "",
"attempt": 1,
"status": "EXTRACTED",
"fields": {
"name": "Muhammad Haider",
"surname": "HAIDER",
"given_names": "MUHAMMAD",
"father_name": "Abdul Rehman",
"passport_number": "AB1234567",
"nationality": "PAKISTANI",
"country_code": "PAK",
// The holder's CNIC, dashed. "" on a passport from any other country.
"cnic_number": "42101-1234567-9",
"citizenship_number": "42101-1234567-9",
"date_of_birth": "14 MAR 1994",
"place_of_birth": "LAHORE",
"date_of_issue": "17 AUG 2021",
"date_of_expiry": "16 AUG 2031",
"gender": "M",
"issuing_authority": "PAKISTAN",
// Evidence for the MRZ checks - don't show these to the user.
"mrz_line1": "P<PAKHAIDER<<MUHAMMAD<<<<<<<<<<<<<<<<<<<<<<<",
"mrz_line2": "AB12345671PAK9403143M31081704210112345679<90"
},
"corrections": [],
"confidence": {},
"boxes": {},
"overall_confidence": 0.94,
"route": "PRIMARY_OCR",
"source_available": false
}HTTP/1.1 200 OK
{
"document_id": "3f9e2a71-...",
"document_type": "PROOF_OF_ADDRESS",
"evidence_type": "UTILITY_BILL",
"attempt": 1,
"status": "EXTRACTED",
"fields": {
"account_holder_name": "MUHAMMAD HAIDER",
"address": "House # 181, Block E, Nawab Town, Johar Town, Lahore",
"city": "Lahore",
"postcode": "54000",
"issuer": "Lahore Electric Supply Company",
// The date it was issued, as YYYY-MM-DD; "" when none is printed.
"issue_date": "2026-10-01",
// The same date as printed, and the period the bill covers.
"document_date": "01-Oct-2026",
"period_start": "01-Sep-2026",
"period_end": "30-Sep-2026"
},
"corrections": [],
"confidence": {},
"boxes": {},
"overall_confidence": 0.88,
"route": "PRIMARY_OCR",
"source_available": false
}HTTP/1.1 200 OK
{
"document_id": "34ab7c47-...",
"document_type": "SOURCE_OF_WEALTH",
"evidence_type": "BANK_STATEMENT",
"attempt": 1,
"status": "EXTRACTED",
"fields": {
"account_holder_name": "MUHAMMAD HAIDER",
"account_number": "0123456789012",
"address": "House 88, Street 19, DHA Phase 5, Lahore",
"bank_name": "Bank Alfalah",
"opening_balance": "50,000.00",
"closing_balance": "300,000.00",
"period_start": "01 Jun 2026",
"period_end": "31 Aug 2026",
// The page numbers the statement prints, as integers.
"page_numbers": [1, 2, 3],
// Every row, amounts exactly as printed.
"transactions": [
{ "date": "01 Jul 2026", "description": "SALARY JUL", "debit": "",
"credit": "125,000.00", "running_balance": "175,000.00" },
{ "date": "01 Aug 2026", "description": "SALARY AUG", "debit": "",
"credit": "125,000.00", "running_balance": "300,000.00" }
]
},
"corrections": [],
"confidence": {},
"boxes": {},
"overall_confidence": 0.81,
"route": "PRIMARY_OCR",
"source_available": false
}HTTP/1.1 200 OK
{
"document_id": "b0c1a3f2-...",
"document_type": "CNIC_FRONT",
"evidence_type": "",
"attempt": 1,
"status": "EXTRACTED",
// As read, to prefill the details screen.
"fields": {
"name": "Muhammad Haider",
"father_name": "Abdul Rehman",
"cnic_number": "42101-1234567-9",
"date_of_birth": "14.03.1994",
"date_of_issue": "17.08.2021",
"date_of_expiry": "17.08.2031",
"gender": "M"
},
// What the user changed on the details step; [] until they have.
"corrections": [],
"confidence": { "name": 0.98, "father_name": 0.74, "cnic_number": 0.99, ... },
"boxes": { "name": [0.08, 0.31, 0.52, 0.37], ... },
"overall_confidence": 0.93,
"route": "PRIMARY_OCR",
"source_available": false
}HTTP/1.1 200 OK
{
"document_id": "6a2d90c4-...",
"document_type": "CNIC_BACK",
"evidence_type": "",
"attempt": 1,
"status": "EXTRACTED",
"fields": {
"address": "House No. 12, Gali No. 4, DHA Phase 5, Lahore",
"address_original": "مکان نمبر 12، گلی نمبر 4، ڈی ایچ اے فیز 5، لاہور",
"address_script": "urdu",
"document_date": "17.08.2021"
},
"confidence": { "address": 0.81, "address_original": 0.66, ... },
...
}Webhooks
Have each document's result sent to your server the moment it is ready, instead of checking for it. Optional: API 5 keeps working the same way, with or without a webhook.
Turning it on
- Give a Veri admin an HTTPS address on your server that accepts
POST, such ashttps://api.yourapp.com/veri/webhook. It must be reachable from the internet. - The admin adds it to your app’s API key (Admin → Access → API keys → Webhook URL). The signing secret is shown once (
whsec_…): store it on your server asVERI_WEBHOOK_SECRET, next to the API key. - The admin sends a test event (
ping) to check your endpoint answers.
Events are sent for the cases your API key creates (API 1). If the secret leaks, ask for a new one: the old one stops working at once.
Try it without a server
Events
| type | When |
|---|---|
document.processed | A document has a result: read (EXTRACTED), sent back for a retake (QUALITY_FAILED) or unreadable (FAILED). One per upload, usually 1–10 seconds after API 4. |
ping | Sent by the admin to test your endpoint. data holds your app’s name. Answer it like any other event. |
POST /veri/webhook HTTP/1.1
Content-Type: application/json
User-Agent: Veri-Webhooks/1
Veri-Event-Id: 59e39f3a-7d69-4478-b236-f270a9006ff5
Veri-Event-Type: document.processed
Veri-Signature: t=1791567455,v1=5f49406b8aa1ce3f9c145b7737dabebb7514b2600d4816f5c863bb9a22fb4530
// Formatted here for reading. It arrives as compact JSON: check the
// signature against the bytes you received, not this.
{
// Unique per event, and the same as Veri-Event-Id. An event can arrive
// more than once: skip an id you have already handled.
"id": "59e39f3a-7d69-4478-b236-f270a9006ff5",
"type": "document.processed",
"created_at": "2026-10-09T17:37:35.107920Z",
"data": {
"case_id": "c675aed5-71e4-4e3d-bfc1-5aeee0f27783",
// Your own id for the user, as you sent it at API 1.
"reference_id": "user-00042",
// The case after this result, and the screen to show - as API 5 gives them.
"case_status": "CUSTOMER_ACTION_REQUIRED",
"next_action": "retake_document",
// The document as API 5 lists it, without what was read off it: no
// fields, corrections or confidence, and checks without their evidence.
// Read those with your API key (API 9).
"document": {
"id": "14e0794b-cc92-4dc9-bc59-c8c16048fc32",
"document_type": "SELFIE",
"evidence_type": "",
"status": "QUALITY_FAILED",
"attempt": 1,
"quality_score": 0.65,
"current": true,
"uploaded_at": "2026-10-09T17:37:21.640128Z",
"feedback": {
"verdict": "retake",
"items": [{ "label": "Face too blurry", "state": "fail" }],
"message": "Your face isn't clear enough to compare with your ID. Hold the phone steady at arm's length and take it again.",
"code": null,
"blocking": false
},
"checks": []
}
}
}An event says what happened and carries the document’s status, readout and check results, but never anything read off it — no names, numbers, addresses or dates, and no check evidence. Your server reads those with its API key: on document.processed, call API 9 with the case_id and take the document’s fields from there. An event is pushed to an address and kept in delivery logs; values read with your key are only ever an answer to your own request.
Check the signature
Every request carries Veri-Signature: t=<unix seconds>,v1=<hex>. v1 is HMAC-SHA256, keyed with your secret, over t, a full stop, and the request body exactly as received. Refuse the request with a 400 when it doesn’t match or t is more than 5 minutes old. Compute it over the raw bytes: JSON you have parsed and serialised again won’t match.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.VERI_WEBHOOK_SECRET; // whsec_...
// express.raw, not express.json: the signature covers the bytes as sent.
app.post("/veri/webhook", express.raw({ type: "application/json" }), async (req, res) => {
const { t = "", v1 = "" } = Object.fromEntries(
(req.get("Veri-Signature") ?? "").split(",").map((part) => part.split("=")),
);
const expected = crypto.createHmac("sha256", SECRET).update(`${t}.`).update(req.body).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
const valid =
v1.length === expected.length && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
if (!valid || !fresh) return res.sendStatus(400);
const event = JSON.parse(req.body);
res.sendStatus(200); // answer within 10 seconds; do the work after
if (await alreadyHandled(event.id)) return; // the same event can arrive twice
if (event.type === "document.processed") {
const { case_id, next_action, document } = event.data;
await notifyApp(case_id, document, next_action); // e.g. a push to the user's phone
}
});Answering, retries and duplicates
- Answer with any
2xxwithin 10 seconds, and do slow work (a push to the phone, a database write) after answering. - Anything else (an error, a timeout or a redirect) is retried after 10 s, 30 s, 2 min, 10 min, 30 min, 1 h, 3 h and 6 h: nine attempts over about 11 hours. Redirects are not followed.
- The same event can arrive more than once.
id(also sent asVeri-Event-Id) is unique per event: skip one you have already handled. - Events can arrive out of order.
document.attemptanddocument.currentsay which upload is the one that counts; when in doubt, read API 5.
Keep a fallback
Run it all with cURL
Before writing app code, run a real verification from your terminal. This script runs the whole flow in order — calls 1, 1a–1c, 2–5, 7, 8 and 9 (it skips API 6, which only prefills a screen).
- Install
jq(brew install jqorapt install jq). - Save the script as
veri-test.shand put five test files next to it:cnic_front.jpg,cnic_back.jpg,selfie.jpg,utility_bill.pdf,statement.pdf. - Run
VERI_API_KEY=veri_… bash veri-test.sh.
#!/usr/bin/env bash
# A whole verification from your terminal. Needs curl and jq.
# VERI_API_KEY=veri_... ./veri-test.sh
# Put five files next to it: cnic_front.jpg, cnic_back.jpg, selfie.jpg,
# utility_bill.pdf and statement.pdf.
set -euo pipefail
API=https://kyc-api.codewithmuh.com
c() { curl -sS --fail-with-body "$@"; }
# ── API 1 · Start a verification (your server does this, with the API key)
CASE=$(c -X POST $API/api/kyc/cases/ \
-H "X-Api-Key: $VERI_API_KEY" -H "Content-Type: application/json" \
-d '{"reference_id": "test-001", "email": "test@example.com"}')
CASE_ID=$(echo "$CASE" | jq -r .case_id)
AUTH="Authorization: Bearer $(echo "$CASE" | jq -r .session_token)"
echo "case $CASE_ID"
# ── API 1a · Which country? PK is the default. Only markets listed in
# available_markets (API 5) are accepted; BD is refused for now.
c -X PUT $API/api/kyc/cases/$CASE_ID/country/ -H "$AUTH" \
-H "Content-Type: application/json" -d '{"market": "PK"}' > /dev/null
# ── API 1b · Which ID? NATIONAL_ID is the default, so this line is only
# needed for the passport path - where it also drops CNIC_BACK from
# required_documents, and you'd upload PASSPORT instead of the two sides.
c -X PUT $API/api/kyc/cases/$CASE_ID/identity/ -H "$AUTH" -H "Content-Type: application/json" -d '{"identity_type": "NATIONAL_ID"}' | jq -r '.required_documents | join(", ")'
# ── API 1c · Where the user lives. Every proof of address is compared with
# this, so it goes in before any document is read.
c -X PUT $API/api/kyc/cases/$CASE_ID/address/ -H "$AUTH" -H "Content-Type: application/json" -d '{"house": "12", "street": "4", "area": "DHA Phase 5",
"city": "Lahore", "province": "Punjab"}' > /dev/null
# ── API 2 → 4 · Upload one document: initiate -> PUT -> complete
upload() { # upload <document_type> <file> <content_type> [evidence_type]
local body init url id headers=()
# evidence_type is required for PROOF_OF_ADDRESS and SOURCE_OF_WEALTH and
# refused on everything else, so the key is dropped when it is empty.
body=$(jq -n --arg t "$1" --arg c "$3" --arg e "${4:-}" \
--argjson s "$(wc -c < "$2" | tr -d ' ')" \
'{document_type: $t, content_type: $c, byte_size: $s}
+ (if $e == "" then {} else {evidence_type: $e} end)')
init=$(c -X POST $API/api/kyc/documents/initiate/ -H "$AUTH" \
-H "Content-Type: application/json" -d "$body")
url=$(echo "$init" | jq -r .upload_url)
id=$(echo "$init" | jq -r .document_id)
while IFS= read -r h; do headers+=(-H "$h"); done \
< <(echo "$init" | jq -r '.required_headers | to_entries[] | "\(.key): \(.value)"')
c -X PUT "$url" "${headers[@]}" --data-binary @"$2" > /dev/null
c -X POST $API/api/kyc/documents/$id/complete/ -H "$AUTH" > /dev/null
echo "uploaded $1"
}
upload CNIC_FRONT cnic_front.jpg image/jpeg
upload CNIC_BACK cnic_back.jpg image/jpeg
upload SELFIE selfie.jpg image/jpeg
upload PROOF_OF_ADDRESS utility_bill.pdf application/pdf UTILITY_BILL
upload SOURCE_OF_WEALTH statement.pdf application/pdf BANK_STATEMENT
# ── API 5 · Wait until every document has been read
while :; do
STATE=$(c $API/api/kyc/cases/$CASE_ID/ -H "$AUTH")
NEXT=$(echo "$STATE" | jq -r .next_action)
echo "$STATE" | jq -r '.documents[] | select(.current) | " \(.document_type): \(.status)"'
[ "$NEXT" = "upload_documents" ] || break
sleep 5
done
if [ "$NEXT" != "confirm_details" ]; then
echo "Stopped at next_action=$NEXT. Feedback:"
echo "$STATE" | jq '.documents[] | select(.current) | {document_type, feedback}'
exit 1
fi
# ── API 7 · Confirm the details as read (put corrections here if any were wrong)
c -X PATCH $API/api/kyc/cases/$CASE_ID/details/ -H "$AUTH" \
-H "Content-Type: application/json" -d '{"corrections": {}}' | jq
# ── API 8 · Consent and submit
c -X POST $API/api/kyc/cases/$CASE_ID/submit/ -H "$AUTH" \
-H "Content-Type: application/json" \
-d '{"consent": true, "mobile_number": "+923001234567"}' | jq
# ── API 9 · Read the result (your server does this, with the API key)
c $API/api/kyc/partner/cases/$CASE_ID/ -H "X-Api-Key: $VERI_API_KEY" \
| jq '{case_id, reference_id, status, submitted_at}'Every run creates a real case
reference_id starting with test- so it’s easy to recognise.Scenarios
What to do in each situation your app will meet.
| Situation | What to do |
|---|---|
| User closes the app and comes back later | Get a new token for the same case (API 11), then API 5 and follow next_action. |
| Any call returns 401 | Get a new token (API 11), store it, retry the call once. |
| A document is rejected | Show feedback.message, then upload again with the same document_type (API 2–4). |
| Upload interrupted | Start again from API 2. The previous good upload of that document stays until a new one lands. |
| ai.state is starting | Uploads are queued safely. Tell the user they can close the app and come back. |
| 503, or ai.state is paused | Verification is switched off. Show the error message; try later. API 0 says when it is back. |
| The same user verifies again | Create a new case with the same reference_id (API 1). API 10 lists them all. |
| 409 after submitting | The case is with compliance. Show the “under review” screen. |
| 429 Too many requests | Back off and retry (2s, 4s, 8s). Limits: 1,200/hour per API key; 30 uploads/hour per case; 60/hour per case across API 1a, 1b, 1c and 7. |
Errors
Errors are JSON with an error message that is safe to show users. Validation errors add fields (field name → code, never the value you sent). A few carry a status too, when the case is not in the state the call needs. Every response has an X-Request-Id header; include it when reporting a problem.
HTTP/1.1 400 Bad Request
X-Request-Id: 3f6c2d1e-8a0b-4c55-9e7f-1b2a3c4d5e6f
{
"error": "Some fields are invalid.",
"fields": { "reference_id": "invalid" }
}| Code | Meaning | What to do |
|---|---|---|
| 400 | Invalid input | Check fields |
| 401 | Wrong or revoked API key, or expired session token | Server: check the key. App: API 11 |
| 404 | Not found, or not yours | Check the id |
| 409 | Already submitted, not ready yet, or upload not found | Read the message; check API 5 |
| 413 / 415 | File too large / wrong type | Compress, or use JPG/PNG/PDF |
| 429 | Too many requests | Back off and retry |
| 503 | Verification paused | Try later |
Statuses
Case status
DRAFT | Created, nothing uploaded yet |
DOCUMENT_UPLOAD | Waiting for documents, or reading them |
QUALITY_CHECK / EXTRACTION / FALLBACK_EXTRACTION / FIELD_VALIDATION | Working on a document. Transient - next_action is "wait" |
CUSTOMER_ACTION_REQUIRED | A document must be retaken or replaced |
CUSTOMER_DETAILS_CONFIRMATION | All documents read; waiting for API 7 |
FINAL_REVIEW_AND_CONSENT | Details confirmed; waiting for API 8 |
SUBMITTED | Consent given; on its way to the review queue |
COMPLIANCE_REVIEW / MANUAL_REVIEW | Submitted, being reviewed |
APPROVED / REJECTED | Final decision |
MORE_INFORMATION_REQUIRED | The reviewer asked for more |
Document status
PENDING_UPLOAD | API 2 done; waiting for API 3 and 4 |
UPLOADED / PROCESSING | Being read; keep checking API 5 |
EXTRACTED | Read; see feedback |
QUALITY_FAILED / FAILED | Sent back: upload it again, as feedback says |
SUPERSEDED | Replaced by a newer upload; ignore |
checks[].result is PASS, PASS_WITH_CONDITIONS, REVIEW, FAIL, UNAVAILABLE (couldn’t run, e.g. no NADRA connection; not a pass) or NOT_APPLICABLE. Checks inform the reviewer; the decision is the case status.
Go-live checklist
- The API key is only on your server, in a secrets store; not in the app, git or logs.
- Each app has its own key, named after it.
- Production uses a live key, not the sandbox key you tested with.
reference_idis your internal user id, never a CNIC, phone or email.case_idfrom API 1 is saved against the user.- A 401 gets a new token (API 11) instead of a new case.
- API 3 sends every
required_headersentry exactly; photos are compressed first. - Retakes show
feedback.messageand re-upload the samedocument_type— and stop offering a retake whenfeedback.blockingistrue. - API 7 is always called, even with no corrections — and again after any document is replaced, because the case goes back to
confirm_details. - The details screen shows the proof-of-address
address(API 6) read-only, beside the ID fields. - The upload screens are built from
required_documents, not a hard-coded list of five. - On the CNIC path, the details screen shows the back address as printed, right to left when it is Urdu, and in English, and sends
cnic_addresswhen the user corrects either. On the passport path there is no such section. - The review screen shows
identity_details— the name, number and date of birth being submitted — and asks for the user’s mobile number with its country code. - The consent box is shown before API 8, and
mobile_numberis sent with it. - Your server reads the decision with API 9, rather than trusting the app.
- With a webhook: the signature and its 5-minute window are checked on the raw body, a
2xxgoes back within 10 seconds, and an eventidalready handled is skipped. - Tested end to end with the cURL script, then with the app.
Need help?
X-Request-Id and case_id, never document images or personal data.