Developer Docs

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.

Your backend

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.

Your app

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.

Veri platform

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.

1

Get a key

An admin issues one key per app, named after it. It lives on your server.

2

Start a case

Your server creates a case with your user id and gets a session token.

3

User verifies

The app uploads documents, fixes retakes, confirms details and consents.

4

Read the result

Your server looks the case up until it is approved or rejected.

Never put the API key inside the mobile app

Anything shipped in an APK or IPA can be extracted in minutes. Someone with your key can start verifications in your name and read your users’ results. Keep it on your server, and give the app only the short-lived session_token from API 1.

Quick start

The whole integration in seven steps. Each links to the API calls it uses.

  1. 1

    Get an API key

    An admin creates one key per app. It stays on your server, never in the app.

  2. 2

    Start a verification

    Your server creates a case and hands the session token to the app.

  3. 3

    Ask three questions first

    Country, home address and which ID they will use. The answers decide which documents the case needs.

  4. 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. 5

    Show progress

    Check the case and show each document's feedback. Re-upload any retakes.

  6. 6

    Confirm details

    Show what was read from their ID and send back the user's corrections.

  7. 7

    Submit

    Once the user has given their mobile number and ticked the consent box.

  8. 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.

  1. 1CountryWhere the documents are fromAPI 1a
  2. 2Home addressWhere they live nowAPI 1c
  3. 3Which IDCNIC or passportAPI 1b
  4. 4Identity documentBoth CNIC sides, or the passport pageAPI 2 → 4
  5. 5SelfieFace checkAPI 2 → 4
  6. 6Proof of addressWhat it is, then the fileAPI 2 → 4
  7. 7Source of fundsWhat it is, then the fileAPI 2 → 4
  8. 8Your detailsConfirm & correctAPI 6 → 7
  9. 9Review & submitConsentAPI 8

Authentication

Base URL

RESThttps://kyc-api.codewithmuh.com

Every 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

  1. 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.
  2. The admin creates it in Admin → Access → API keys → Add, or with python manage.py api_key create "Android app".
  3. 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.

1

Front of your CNIC

document_type
CNIC_FRONTNATIONAL_ID only
Files
JPG or PNG photo of the original card

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.

2

Back of your CNIC

document_type
CNIC_BACKNATIONAL_ID only
Files
JPG or PNG photo of the original card

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.

3

Passport data page

document_type
PASSPORTPASSPORT only
Files
JPG or PNG photo of the original booklet

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.

4

Selfie

document_type
SELFIE
Files
JPG or PNG only

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.

5

Proof of address

document_type
PROOF_OF_ADDRESS
evidence_type
UTILITY_BILL, BANK_STATEMENT, WALLET_STATEMENT, TENANCY_AGREEMENT or GOVERNMENT_CORRESPONDENCE
Files
The PDF from the provider, a scan, or a photo of the original

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.

6

Source of funds

document_type
SOURCE_OF_WEALTH
evidence_type
BANK_STATEMENT, WALLET_STATEMENT, EMPLOYMENT_CONTRACT or EMPLOYER_LETTER
Files
The PDF from the bank or wallet app, a scan, or a photo of the original

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

0

Check verification is working

GET/api/kyc/status/
Your server
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

stateoptional

ready: 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_remainingoptional

Only while starting: about how long until documents are read.

messageoptional

A sentence to show the user, in the same words the 503 uses.

curl https://kyc-api.codewithmuh.com/api/kyc/status/
Response
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.

1

Start a verification

POST/api/kyc/cases/
Your server
Auth
X-Api-Key
Call it
When the user taps “Verify my identity” in your app.

Body

reference_idrequired

Your 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.

emailoptional

The person’s email, kept for your records. Nothing is sent to it.

marketoptional

PK (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"
  }'
Response
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 fieldsA 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.
401The API key is wrong or revoked.
503Verification 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.

1a

Set the country

PUT/api/kyc/cases/{case_id}/country/
Your app
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

marketrequired

PK or BD. Only the ones in available_markets (see API 5) are accepted — read that list, don’t hard-code one.

Response
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.
1b

Set the ID type

PUT/api/kyc/cases/{case_id}/identity/
Your app
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_typerequired

NATIONAL_ID (the default — CNIC in Pakistan, NID in Bangladesh) or PASSPORT.

Response
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.
1c

Save the home address

PUT/api/kyc/cases/{case_id}/address/
Your app
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

arearequired

Area, sector or phase, e.g. DHA Phase 5, Gulberg III or G-11/3.

cityrequired

e.g. Lahore. Letters only.

provincerequired

One of Punjab, Sindh, Khyber Pakhtunkhwa, Balochistan, Islamabad Capital Territory, Gilgit-Baltistan, Azad Jammu and Kashmir.

houseoptional

House or flat, e.g. 12 or Flat 3B.

streetoptional

Street, e.g. 4 or Main Boulevard.

postcodeoptional

Five digits, e.g. 54000.

Response
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 fieldsField 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.

Your appVeri APISecure storage
API 2 · POST /documents/initiate/document_type, content_type, byte_size
document_id, upload_url, required_headers
API 3 · PUT upload_urlthe file bytes + required_headers
200 OK
API 4 · POST /documents/{id}/complete/
status: UPLOADED, processing: true
Veri then reads the document in the background, in a few seconds. You see the result with API 5.
2

Get an upload link

POST/api/kyc/documents/initiate/
Your app
Auth
Authorization: Bearer
Call it
Once per document, after the user has taken the photo or picked the file.

Body

document_typerequired

Which document, e.g. CNIC_FRONT or PASSPORT. See Documents to collect.

content_typerequired

image/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_sizerequired

The file’s exact size in bytes, at most 15 MB. Measure it after compressing the photo.

evidence_typerequired

Required 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_consentedoptional

false 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.

cURL
# 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
  }'
Response
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.
401The session expired. Get a new token with API 11.
409 “already been submitted”The case is finished. Nothing more can be uploaded.
3

Upload the file

PUT{upload_url}
Your app
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.

cURL
# 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.jpg
Response
HTTP/1.1 200 OK

(empty body - the file is now in storage, but Veri doesn't know yet)

If it fails

403 SignatureDoesNotMatchA 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 expiredThe link is older than 15 minutes. Start again from API 2.
Network errorStart again from API 2 with a new link.
4

Confirm the upload

POST/api/kyc/documents/{document_id}/complete/
Your app
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
curl -X POST https://kyc-api.codewithmuh.com/api/kyc/documents/$DOCUMENT_ID/complete/ \
  -H "Authorization: Bearer $SESSION_TOKEN"
Response
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 throughCall 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

Resize camera photos to about 1600–2400px on the long side at JPEG quality 85 before uploading, and show the tip for each document (see Documents to collect) before the user takes the photo.

Progress and details

5

Check progress

GET/api/kyc/cases/{case_id}/
Your app
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

With a webhook, your server hears about each document’s result the moment it is ready, so it doesn’t need to check every few seconds. This call keeps working either way.
Response
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_action

Which screen to show: upload_documents, retake_document, confirm_details (go to API 6), review_and_consent (go to API 8) or wait.

identity_details

document_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_number

The 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_address

The 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[].fields

Everything 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[].corrections

What the user changed: field, the value as read and as confirmed. [] when they changed nothing.

documents[].checks

That 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[].feedback

verdict (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.state

ready, starting (with minutes_remaining) or paused.

Show the same states as our web app

The current document hasShow
status UPLOADED or PROCESSINGA 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).
6

Read the extracted details

GET/api/kyc/documents/{document_id}/result/
Your app
Auth
Authorization: Bearer
Call it
When next_action is confirm_details, for the identity document — CNIC_FRONT plus CNIC_BACK for the address, or PASSPORT on its own — and for PROOF_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
curl https://kyc-api.codewithmuh.com/api/kyc/documents/$DOCUMENT_ID/result/ \
  -H "Authorization: Bearer $SESSION_TOKEN"
Response
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.

fields

The 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.

confidence

0–1 per field. Highlight the low ones so the user checks them.

boxes

Where 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.

Response for CNIC_BACK
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, ... },
  ...
}
address

The 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_original

The 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_script

urdu, 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 "".

Response for PROOF_OF_ADDRESS
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
}
city

The 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.

postcode

Five 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_date

The 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.

Response for PASSPORT
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_number

The 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.

7

Confirm details

PATCH/api/kyc/cases/{case_id}/details/
Your app
Auth
Authorization: Bearer
Call it
When the user taps Confirm on the details screen. Required even if nothing changed.

Body

correctionsrequired

Only 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_addressoptional

Only 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_addressoptional

The 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.

Request body
{
  "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"
  }
}
Response
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

400 response
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

Confirming means “these are my details and they are correct”, so the whole set is validated as it now stands — the model’s reading with the user’s corrections applied. A {} 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

409A 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

Replacing any document sends the case back to 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

8

Consent and submit

POST/api/kyc/cases/{case_id}/submit/
Your app
Auth
Authorization: Bearer
Call it
When the user ticks your consent box and taps Submit.

Body

consentrequired

Must be the boolean true (not the string "true").

mobile_numberrequired

The 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"
Response
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.
409Details aren’t confirmed yet (API 7), or a document needs retaking.

On your server, afterwards

9

Get the result

GET/api/kyc/partner/cases/{case_id}/
Your server
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"
Response
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.

statusWhat to do
COMPLIANCE_REVIEW / MANUAL_REVIEWStill being reviewed. Check again later.
APPROVEDVerified. Unlock the features that need KYC.
REJECTEDNot verified. Show your support contact.
MORE_INFORMATION_REQUIREDThe compliance team follows up with the user directly. Re-uploading through the API isn’t supported for this status yet.

If it fails

404No case with that id was created by your organisation.
10

List a user's cases

GET/api/kyc/partner/cases/?reference_id={your_id}
Your server
Auth
X-Api-Key
Call it
To find the verifications for one of your users, newest first.

Query

reference_idrequired

The 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"
Response
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.
401The API key is wrong, revoked or missing.
Empty resultsThat reference_id has no cases under your organisation — check you sent your own id.
11

New session token

POST/api/kyc/partner/cases/{case_id}/session/
Your server
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"
Response
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

409The case is past the applicant's hands — submitted, in review, approved, rejected or awaiting more information. Show the “under review” screen.
404No case with that id was created by your organisation.
401The 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

A value that couldn’t be found is "" — 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.

KeyWhat it isExample
nameFull name, as a person writes itMuhammad Haider
father_nameFather's or husband's nameAbdul Rehman
cnic_numberCNIC number as printed, with its dashes42101-1234567-9
date_of_birthAs printed (the card uses DD.MM.YYYY)14.03.1994
date_of_issueAs printed17.08.2021
date_of_expiryAs printed; Lifetime on a lifetime card17.08.2031
genderM, F or ""M
country_of_stayAs printedPakistan

CNIC_BACK

The permanent address, usually printed only in Urdu.

KeyWhat it isExample
addressThe permanent address in EnglishHouse 12, Street 4, DHA Phase 5, Lahore
address_originalThe address exactly as printed, in its own scriptمکان نمبر 12، گلی نمبر 4، ڈی ایچ اے فیز 5، لاہور
address_scripturdu, english, mixed or ""urdu
document_dateA date printed on the back (the card's issue date), as printed17.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.

KeyWhat it isExample
nameFull name, given names first, as a person writes itMuhammad Haider
surnameAs printedHAIDER
given_namesAs printedMUHAMMAD
father_nameFather's or husband's name, if printedAbdul Rehman
passport_numberAs printedAB1234567
nationalityAs printedPAKISTANI
country_codeThree-letter codePAK
cnic_numberThe holder's CNIC, dashed. Always present; "" on a passport from any other country42101-1234567-9
citizenship_numberThe citizenship or national identity number, as printed42101-1234567-9
date_of_birthAs printed14 MAR 1994
place_of_birthAs printedLAHORE
date_of_issueAs printed17 AUG 2021
date_of_expiryAs printed16 AUG 2031
genderM, F, X or ""M
issuing_authorityAs printedPAKISTAN
mrz_line1First line of the code at the foot of the page, 44 characters. Evidence: don't show itP<PAKHAIDER<<MUHAMMAD<<<…
mrz_line2Second line, 44 characters. Evidence: don't show itAB12345671PAK9403143M3108…

PROOF_OF_ADDRESS

Utility bill, bank or wallet statement, tenancy agreement or government letter - the same keys for all of them.

KeyWhat it isExample
account_holder_nameThe name it is addressed to, as printedMUHAMMAD HAIDER
addressThe customer's own address, in one lineHouse # 181, Block E, Johar Town, Lahore
cityCity of that address, no province. Always presentLahore
postcodeFive digits: printed, else the city's main post office code. Always present54000
issuerThe organisation that issued itLahore Electric Supply Company
issue_dateDate issued, YYYY-MM-DD; "" when none is printed. Always present2026-10-01
document_dateThe same date, as printed01-Oct-2026
period_startStart of the period it covers, as printed01-Sep-2026
period_endEnd of the period it covers, as printed30-Sep-2026

SOURCE_OF_WEALTH

Bank statement, wallet statement, employment contract or employer letter. Amounts are strings, exactly as printed.

KeyWhat it isExample
account_holder_nameAs printedMUHAMMAD HAIDER
account_numberAs printed0123456789012
addressAs printedHouse 88, Street 19, DHA Phase 5, Lahore
bank_nameThe bank, wallet or employerBank Alfalah
opening_balanceAs printed50,000.00
closing_balanceAs printed300,000.00
period_startAs printed01 Jun 2026
period_endAs printed31 Aug 2026
page_numbersPage numbers printed on it - an array of integers[1, 2, 3]
transactionsEvery 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.

PASSPORT
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
}
PROOF_OF_ADDRESS
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
}
SOURCE_OF_WEALTH
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
}
CNIC_FRONT
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
}
CNIC_BACK
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

  1. Give a Veri admin an HTTPS address on your server that accepts POST, such as https://api.yourapp.com/veri/webhook. It must be reachable from the internet.
  2. 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 as VERI_WEBHOOK_SECRET, next to the API key.
  3. 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

The webhook tester gives you an address to use as the webhook URL and shows each event as it arrives, with its signature checked. Link an access code to your API key and run a verification on this website to watch the events for every document.

Events

typeWhen
document.processedA 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.
pingSent by the admin to test your endpoint. data holds your app’s name. Answer it like any other event.
What Veri sends
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 2xx within 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 as Veri-Event-Id) is unique per event: skip one you have already handled.
  • Events can arrive out of order. document.attempt and document.current say which upload is the one that counts; when in doubt, read API 5.

Keep a fallback

A webhook replaces fast polling, not the ability to check. If no event has arrived 30 seconds after API 4, call API 5 once: your endpoint may have been down, and its retry may still be waiting.

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).

  1. Install jq (brew install jq or apt install jq).
  2. Save the script as veri-test.sh and put five test files next to it: cnic_front.jpg, cnic_back.jpg, selfie.jpg, utility_bill.pdf, statement.pdf.
  3. Run VERI_API_KEY=veri_… bash veri-test.sh.
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

A compliance officer will see it. Use a reference_id starting with test- so it’s easy to recognise.

Scenarios

What to do in each situation your app will meet.

SituationWhat to do
User closes the app and comes back laterGet a new token for the same case (API 11), then API 5 and follow next_action.
Any call returns 401Get a new token (API 11), store it, retry the call once.
A document is rejectedShow feedback.message, then upload again with the same document_type (API 2–4).
Upload interruptedStart again from API 2. The previous good upload of that document stays until a new one lands.
ai.state is startingUploads are queued safely. Tell the user they can close the app and come back.
503, or ai.state is pausedVerification is switched off. Show the error message; try later. API 0 says when it is back.
The same user verifies againCreate a new case with the same reference_id (API 1). API 10 lists them all.
409 after submittingThe case is with compliance. Show the “under review” screen.
429 Too many requestsBack 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.

Example
HTTP/1.1 400 Bad Request
X-Request-Id: 3f6c2d1e-8a0b-4c55-9e7f-1b2a3c4d5e6f

{
  "error": "Some fields are invalid.",
  "fields": { "reference_id": "invalid" }
}
CodeMeaningWhat to do
400Invalid inputCheck fields
401Wrong or revoked API key, or expired session tokenServer: check the key. App: API 11
404Not found, or not yoursCheck the id
409Already submitted, not ready yet, or upload not foundRead the message; check API 5
413 / 415File too large / wrong typeCompress, or use JPG/PNG/PDF
429Too many requestsBack off and retry
503Verification pausedTry later

Statuses

Case status

DRAFTCreated, nothing uploaded yet
DOCUMENT_UPLOADWaiting for documents, or reading them
QUALITY_CHECK / EXTRACTION / FALLBACK_EXTRACTION / FIELD_VALIDATIONWorking on a document. Transient - next_action is "wait"
CUSTOMER_ACTION_REQUIREDA document must be retaken or replaced
CUSTOMER_DETAILS_CONFIRMATIONAll documents read; waiting for API 7
FINAL_REVIEW_AND_CONSENTDetails confirmed; waiting for API 8
SUBMITTEDConsent given; on its way to the review queue
COMPLIANCE_REVIEW / MANUAL_REVIEWSubmitted, being reviewed
APPROVED / REJECTEDFinal decision
MORE_INFORMATION_REQUIREDThe reviewer asked for more

Document status

PENDING_UPLOADAPI 2 done; waiting for API 3 and 4
UPLOADED / PROCESSINGBeing read; keep checking API 5
EXTRACTEDRead; see feedback
QUALITY_FAILED / FAILEDSent back: upload it again, as feedback says
SUPERSEDEDReplaced 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_id is your internal user id, never a CNIC, phone or email.
  • case_id from 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_headers entry exactly; photos are compressed first.
  • Retakes show feedback.message and re-upload the same document_type — and stop offering a retake when feedback.blocking is true.
  • 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_address when 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_number is 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 2xx goes back within 10 seconds, and an event id already handled is skipped.
  • Tested end to end with the cURL script, then with the app.

Need help?

Our own web app uses these exact calls, so you can compare its network requests with yours. When something fails, send us the X-Request-Id and case_id, never document images or personal data.