API reference

The guided tour.

The endpoints that ship 95% of integrations. Request shape, response shape, and the errors we actually emit.

The interactive OpenAPI reference lives at https://api.karverifi.com/docs (in dev: http://localhost:8000/docs) — this page is the guided tour.

Auth

POST/v1/auth/login

Exchange an email + password (and optional MFA code) for a short-lived access token and a refresh token.

Request

{
  "email": "ada@example.com",
  "password": "…",
  "totp_code": "123456"   // optional if MFA enrolled
}

Response (data)

{
  "access_token": "eyJhbGciOi…",
  "refresh_token": "rt_…",
  "expires_in": 900,
  "user": { "id": "usr_…", "email": "ada@example.com" }
}

Common errors

invalid_credentialsmfa_requiredaccount_locked
POST/v1/auth/refresh

Trade a refresh token for a fresh access token. Refresh tokens rotate on every use (single-session enforcement).

Request

{ "refresh_token": "rt_…" }

Response (data)

{
  "access_token": "eyJhbGciOi…",
  "refresh_token": "rt_… (rotated)",
  "expires_in": 900
}

Common errors

session_expiredinvalid_credentials
POST/v1/auth/logout

Revoke the current session. Idempotent — safe to call even after the token has already expired.

Request

{}

Response (data)

{ "ok": true }

Workflows

POST/v1/workflows

Draft a new workflow. Drafts are freely editable; publishing freezes a version.

Request

{
  "name": "NG merchant onboarding",
  "steps": [
    { "kind": "bvn_verify", "required": true },
    { "kind": "phone_verify", "required": true },
    { "kind": "kyb", "required": false }
  ]
}

Response (data)

{
  "id": "wf_…",
  "slug": "wf_ng_merchant",
  "status": "draft",
  "version": null
}

Common errors

invalid_requestpermission_denied
POST/v1/workflows/{id}/publish

Freeze the current draft as an immutable version. New verifications route to the latest published version by default.

Request

{}

Response (data)

{
  "id": "wf_…",
  "slug": "wf_ng_merchant",
  "status": "published",
  "version": 3,
  "hosted_url": "https://kyc.karverifi.com/w/wf_ng_merchant"
}

Common errors

not_foundconflict
PATCH/v1/workflows/{id}/steps/{step_id} · Custom step forms

Compose the exact form your customer fills in for a step by setting input_schema.fields[]. Empty or legacy shapes fall back to the built-in per-service_code form (BVN, NIN, phone). Field key must match ^[a-z][a-z0-9_]*$; field types are: text, textarea, email, phone, number, date, select (needs options), file (needs accept + max_size_mb).

Request

{
  "input_schema": {
    "fields": [
      { "key": "first_name", "label": "First name", "type": "text", "required": true, "min_length": 2, "max_length": 60 },
      { "key": "email",      "label": "Email",      "type": "email", "required": false },
      { "key": "id_doc",     "label": "ID document", "type": "file", "required": true,
        "accept": ["image/jpeg", "image/png", "application/pdf"], "max_size_mb": 10 }
    ]
  }
}

Response (data)

{
  "id": "step_…",
  "workflow_id": "wf_…",
  "step_order": 0,
  "input_schema": { "fields": [ /* … as sent … */ ] }
}

Common errors

invalid_requestworkflow_not_editablenot_found

Customers

GET/v1/customers

List customers for the current tenant (cursor-paginated). Filters: status, search (email / phone / name / external ref), tag.

Response (data)

{
  "data": [
    {
      "id": "cus_…",
      "external_reference": "acme-42",
      "first_name": "Ada",
      "last_name": "Okoye",
      "email": "ada@example.com",
      "phone": "+2348012345678",
      "status": "active",
      "tags": ["priority"],
      "created_at": "2026-07-17T09:12:00Z"
    }
  ],
  "meta": { "next_cursor": "cur_…", "limit": 50 }
}
POST/v1/customers

Create a customer record. external_reference (if provided) must be unique per tenant. Free-form metadata is a JSON object.

Request

{
  "external_reference": "acme-42",
  "first_name": "Ada",
  "last_name": "Okoye",
  "email": "ada@example.com",
  "phone": "+2348012345678",
  "national_id": "12345678901",
  "tags": ["priority"],
  "metadata": { "cohort": "sme" }
}

Response (data)

{
  "id": "cus_…",
  "external_reference": "acme-42",
  "first_name": "Ada",
  "last_name": "Okoye",
  "email": "ada@example.com",
  "status": "active",
  "created_at": "2026-07-17T09:12:00Z"
}

Common errors

invalid_requestexternal_reference_in_use
GET/v1/customers/{id}

Fetch a single customer by ID (tenant-scoped). 404 if the ID belongs to another tenant.

Response (data)

{
  "id": "cus_…",
  "first_name": "Ada",
  "last_name": "Okoye",
  "email": "ada@example.com",
  "status": "active",
  "last_verified_at": "2026-07-16T18:11:03Z"
}

Common errors

not_found
PATCH/v1/customers/{id}

Update customer fields. Omitted fields are left unchanged. Emails are lowercased server-side.

Request

{ "phone": "+2348099999999", "tags": ["priority", "vip"] }

Response (data)

{ "id": "cus_…", "phone": "+2348099999999", "tags": ["priority", "vip"] }

Common errors

not_foundexternal_reference_in_use
PATCH/v1/customers/{id}/{archive|restore|block|unblock}

Lifecycle actions: archive hides from default lists, restore reactivates, block rejects future verifications (reason required), unblock clears the block.

Request

// block only:
{ "reason": "chargeback flagged" }

Response (data)

{ "id": "cus_…", "status": "blocked", "blocked_reason": "chargeback flagged" }

Common errors

not_foundcustomer_blockednot_blockedreason_required
DELETE/v1/customers/{id}

Soft-delete a customer. The row is retained for audit; it just disappears from list results.

Response (data)

{ "deleted": true }

Common errors

not_found

Sending a workflow

POST/v1/customers/{id}/send-workflow

Create a hosted KYC session for the customer and email them the link. The customer completes the verification on their own device (mobile-friendly hosted UI). Requires an active customer with an email on file and a published workflow.

Request

curl -X POST https://api.karverifi.com/v1/customers/cus_.../send-workflow \
  -H "Authorization: Bearer $TENANT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "workflow_id": "wf_ng_merchant",
    "channel": "email",
    "external_reference": "onboarding-42",
    "message": "Please complete this before Friday."
  }'

Response (data)

{
  "verification_id": "ver_…",
  "session_token": "ses_…",
  "hosted_url": "https://kyc.karverifi.com/s/ses_…",
  "expires_at": "2026-07-18T14:00:00Z",
  "email_sent": true
}

Common errors

customer_not_activecustomer_missing_emailworkflow_not_publishedchannel_not_supportednot_found

Dispatching to multiple customers

POST/v1/workflows/{workflow_id}/dispatch

Fan a published workflow out to up to 200 customers in a single call. Returns 200 with a per-customer breakdown (sent / skipped / failed) — the endpoint never 4xxs on partial failure, that is the whole point. Larger batches should be split client-side. Requires role owner, admin, or ops. Without external_reference_prefix, retries mint fresh sessions per customer (duplicates possible) — pass a unique prefix when you need idempotency. When external_reference_prefix is supplied it doubles as the batch id — every session gets it stamped so the batch endpoints below can group them.

Request

curl -X POST https://api.karverifi.com/v1/workflows/wf_ng_merchant/dispatch \
  -H "Authorization: Bearer $TENANT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "customer_ids": [
      "cus_11111111-1111-1111-1111-111111111111",
      "cus_22222222-2222-2222-2222-222222222222",
      "cus_33333333-3333-3333-3333-333333333333"
    ],
    "channel": "email",
    "message": "Please complete this before Friday.",
    "external_reference_prefix": "batch-2026-07-17"
  }'

Response (data)

{
  "workflow_id": "wf_ng_merchant",
  "total": 3,
  "sent": 2,
  "skipped": 1,
  "failed": 0,
  "results": [
    {
      "customer_id": "cus_11111111-…",
      "status": "sent",
      "verification_id": "ver_…",
      "hosted_url": "https://kyc.karverifi.com/s/ses_…"
    },
    {
      "customer_id": "cus_22222222-…",
      "status": "sent",
      "verification_id": "ver_…",
      "hosted_url": "https://kyc.karverifi.com/s/ses_…"
    },
    {
      "customer_id": "cus_33333333-…",
      "status": "skipped",
      "reason": "customer_missing_email"
    }
  ]
}

Common errors

workflow_not_foundworkflow_not_publishedchannel_not_supportedinsufficient_role

Grouping into batches

When you supply external_reference_prefix on the dispatch call, every resulting session gets that value stamped on it as its batch id. The endpoints below let you query the dispatch as a single batch — aggregate counts + drill-down. Untagged sessions (no prefix) do NOT appear here — they still show up on the individual /v1/verifications list as before.

GET/v1/verifications/batches

Cursor-paginated list of dispatch batches for the current tenant. One row per distinct batch_ref, ordered by most recent activity first. Aggregate counts land in a single round-trip so a large batches list stays cheap to poll. Requires role owner, admin, or ops. Optional workflow_id query filter narrows to batches that touched a specific workflow.

Request

curl -H "Authorization: Bearer $TENANT_TOKEN" \
  "https://api.karverifi.com/v1/verifications/batches?limit=25"

Response (data)

{
  "data": [
    {
      "batch_ref": "batch-2026-07-17",
      "workflow_ids": ["wf_ng_merchant"],
      "total": 42,
      "pending": 3,
      "processing": 1,
      "passed": 30,
      "failed": 5,
      "cancelled": 0,
      "manual_review": 2,
      "expired": 1,
      "first_created_at": "2026-07-17T08:15:22Z",
      "last_created_at": "2026-07-17T09:42:11Z"
    }
  ],
  "meta": { "next_cursor": null, "limit": 25 }
}

Common errors

invalid_cursorinsufficient_role
GET/v1/verifications/batches/{batch_ref}

Individual verifications belonging to a batch, newest first. Same VerificationOut shape as the main list endpoint — treat this as a scoped view of /v1/verifications filtered to one batch_ref. Cursor-paginated. Requires role owner, admin, or ops.

Request

curl -H "Authorization: Bearer $TENANT_TOKEN" \
  "https://api.karverifi.com/v1/verifications/batches/batch-2026-07-17?limit=50"

Response (data)

{
  "data": [
    {
      "id": "ver_…",
      "batch_ref": "batch-2026-07-17",
      "external_reference": "batch-2026-07-17-cus_1111",
      "workflow_id": "wf_ng_merchant",
      "status": "passed",
      "created_at": "2026-07-17T09:42:11Z",
      "steps": [
        { "step_order": 0, "service_code": "bvn", "status": "passed" }
      ]
    }
  ],
  "meta": { "next_cursor": "eyJ…", "limit": 50 }
}

Common errors

invalid_cursorinsufficient_role

Polling a batch until every session terminates

A batch is done when pending + processing + manual_review equals zero — everything else is a terminal state. Poll every 10–30s with exponential backoff for larger batches.

while true; do
  batch=$(curl -sH "Authorization: Bearer $TENANT_TOKEN" \
    "https://api.karverifi.com/v1/verifications/batches" \
    | jq --arg ref "batch-2026-07-17" \
        '.data[] | select(.batch_ref==$ref)')

  in_flight=$(echo "$batch" | jq '.pending + .processing + .manual_review')
  echo "in-flight: $in_flight"
  [ "$in_flight" = "0" ] && break
  sleep 15
done

Verifications

POST/v1/verifications

Kick off a verification against a published workflow. Use an Idempotency-Key header to safely retry.

Request

{
  "workflow": "wf_ng_merchant",
  "customer": { "email": "ada@example.com", "phone": "+2348012345678" },
  "metadata": { "order_id": "ord_9f2a" }
}

Response (data)

{
  "id": "ver_…",
  "status": "pending",
  "hosted_url": "https://kyc.karverifi.com/s/ses_…",
  "expires_at": "2026-07-18T14:00:00Z"
}

Common errors

invalid_requestnot_foundinsufficient_fundsrate_limited
GET/v1/verifications/{id}

Fetch the latest state of a verification, including per-step results and manual-review notes.

Response (data)

{
  "id": "ver_…",
  "status": "passed",
  "steps": [
    { "kind": "bvn_verify", "status": "passed", "result": { … } },
    { "kind": "phone_verify", "status": "passed", "result": { … } }
  ],
  "completed_at": "2026-07-18T12:04:31Z"
}

Common errors

not_found
POST/v1/verifications/{id}/cancel

Cancel a pending verification. Idempotent — cancelling a terminal verification is a no-op that returns the current state.

Request

{ "reason": "customer_abandoned" }

Response (data)

{ "id": "ver_…", "status": "cancelled" }

Common errors

not_foundconflict
POST/public/sessions/{session_token}/steps/{step_order}/fields/{field_key}/upload

Customer-facing multipart upload for a workflow step that declares a form-builder type=file field. Session token in the URL is the credential (no auth header). Enforces the field’s per-tenant accept mime list plus max_size_mb (default 10 MB). Returns an opaque file_ref the hosted UI stores in the field’s key when it submits the step; the ref lands in the step’s request_payload verbatim so admins can preview via the platform-admin streaming endpoint.

Request

multipart/form-data with a single field named "file"

Response (data)

{
  "file_ref": "step-fields/{session_token}/{step_order}/{field_key}/{uuid}",
  "file_name": "id.jpg",
  "file_size_bytes": 218421,
  "content_type": "image/jpeg",
  "url": "/storage/step-fields/…"
}

Common errors

field_not_uploadableinvalid_field_keyupload_too_largeupload_type_not_allowedupload_extension_bannedempty_uploadsession_terminalsession_expired

Products

GET/v1/me/products

List every KYC product enabled for this tenant, with cached prices and current status (enabled / gated / requested).

Response (data)

{
  "products": [
    { "code": "bvn_verify", "name": "BVN Verify", "price_ngn": 50, "status": "enabled" },
    { "code": "nin_verify", "name": "NIN Verify", "price_ngn": 40, "status": "enabled" },
    { "code": "kyb",        "name": "KYB",        "price_ngn": 500, "status": "gated" }
  ]
}
POST/v1/products/{code}/requests

Request access to a gated product (e.g. KYB, BVN Registration). Ops reviews within one business day.

Request

{ "note": "We onboard SMEs and need KYB." }

Response (data)

{
  "id": "prq_…",
  "status": "pending",
  "product_code": "kyb"
}

Common errors

not_foundconflict

Wallet

GET/v1/wallet

Fetch the current wallet balance (in kobo / minor units), the tier, and per-env context. Sandbox and live wallets are separate — pick with the X-Env header (sandbox is default in dev keys).

Response (data)

{
  "balance_minor": 4250000,
  "currency": "NGN",
  "env": "live",
  "low_balance_threshold_minor": 500000
}
GET/v1/wallet/ledger

Cursor-paginated append-only ledger of every credit (topup) and debit (verification charge). Filter by verification_id to reconcile a single verification.

Response (data)

{
  "data": [
    {
      "id": "led_…",
      "kind": "debit",
      "amount_minor": 5000,
      "verification_id": "ver_…",
      "step_order": 0,
      "reason": "verification_step_debit",
      "at": "2026-07-18T12:04:31Z"
    },
    {
      "id": "led_…",
      "kind": "credit",
      "amount_minor": 5000000,
      "topup_id": "tup_…",
      "reason": "wallet_topup",
      "at": "2026-07-15T09:00:00Z"
    }
  ],
  "meta": { "next_cursor": "cur_…", "limit": 50 }
}
GET/v1/wallet/topups

List completed and pending topups (payment attempts). Useful for reconciling against your Paystack / Flutterwave dashboards.

Response (data)

{
  "data": [
    {
      "id": "tup_…",
      "amount_minor": 5000000,
      "status": "completed",
      "provider": "paystack",
      "provider_reference": "ps_ref_…",
      "created_at": "2026-07-15T08:59:12Z",
      "completed_at": "2026-07-15T09:00:00Z"
    }
  ],
  "meta": { "next_cursor": "cur_…", "limit": 50 }
}
POST/v1/wallet/topups

Start a topup — returns a hosted checkout URL. Wallet is credited only when the provider webhook confirms payment. Idempotent per (tenant, provider_reference) so a retried POST does not double-charge.

Request

{
  "amount_minor": 5000000,
  "provider": "paystack"
}

Response (data)

{
  "id": "tup_…",
  "status": "pending",
  "amount_minor": 5000000,
  "checkout_url": "https://checkout.paystack.com/…"
}

Common errors

invalid_requestprovider_unavailable

Webhooks

POST/v1/webhooks

Register an endpoint. Returns a signing secret — store it, we never show it again.

Request

{
  "url": "https://your-app.com/webhooks/karverifi",
  "events": ["verification.completed", "verification.failed"]
}

Response (data)

{
  "id": "wh_…",
  "url": "https://your-app.com/webhooks/karverifi",
  "signing_secret": "whsec_…",
  "events": ["verification.completed", "verification.failed"]
}

Common errors

invalid_requestconflict
GET/v1/webhooks/deliveries

Paginated list of delivery attempts across all your endpoints. Filter by endpoint ID, status, or event name.

Response (data)

{
  "deliveries": [
    {
      "id": "whd_…",
      "webhook_id": "wh_…",
      "event": "verification.completed",
      "http_status": 200,
      "attempts": 1,
      "delivered_at": "2026-07-18T12:04:32Z"
    }
  ],
  "next_cursor": "cur_…"
}

Products — fulfillment lifecycle

Every request submitted via POST /v1/products/{code}/requests parks at pending and is dispatched asynchronously by a fulfillment worker. The state machine is a strict linear progression with three terminal states.

State diagram

  pending
    | (worker picks up)
    v
  processing
    |               \ (handler error / provider error / wallet_insufficient)
    | (success)      \
    v                 v
  succeeded         failed
                (retry under same
                 external_reference)

  pending
    | (tenant DELETE)
    v
  cancelled  (only allowed while pending)

The worker debits the tenant wallet on the succeeded transition using the cost_minor stamped on the row at submission — the price you saw is the price you pay, even if the catalog price changes afterwards.

Polling vs webhooks

For interactive UIs, poll GET /v1/product-requests/{id} every ~3 seconds while status is pending or processing. Stop polling on the first terminal status.

For backend integrations, subscribe an endpoint to product_request.succeeded and product_request.failed.product_request.started fires when the worker begins fulfillment — useful for surfacing an in-progress indicator.

Error codes on status = failed

error_codeMeaning
handler_missingNo fulfillment handler is registered for the requested product code. This is an ops / config issue on our side.
wallet_insufficientThe tenant wallet couldn't cover the stamped cost_minor. Top up and retry under the same external_reference.
provider_unreachableThe upstream provider returned a network / 5xx error. Safe to retry.
data_mismatchThe submitted input contradicts data the provider already holds (name mismatch, DOB mismatch, etc.). Fix the input before retrying.
input_invalidThe submitted input failed provider-side validation (e.g. impossible date). Not retryable without changing the payload.
address_invalidCard / physical fulfillment couldn't route to the supplied delivery_address. Correct the address and retry.
handler_errorThe handler raised an unexpected exception. Logged for investigation; safe to retry after we ship a fix.

Failed requests can be retried under the same external_reference — the idempotency lane only de-dupes live (non-failed) requests. Successful debits are keyed on product_request:{id} so a request that is dispatched twice only debits the wallet once.

Manual step review

When a step needs a human decision — or you need more info from the customer — a platform admin can request a document, or approve/reject the step directly. These endpoints act on a single step; they are distinct from the whole-verification approve/reject.

POST/platform/v1/backoffice/verifications/{id}/steps/{step_id}/request-document

Blocks the step, creates a doc-request row, emails the customer, emits verification.document.requested. Permission: verifications.document.request.

Request

{ "reason": "Please upload a utility bill from the last 3 months." }
POST/platform/v1/backoffice/verifications/{id}/steps/{step_id}/manual-review

Approve or reject a single step manually (no document involved). Approve marks the step passed and lets the orchestrator continue; reject marks the step failed with manual_reject and fails the verification. Permission: verifications.manual_review.

Request

{ "approve": true, "reason": "Verified against internal record." }
POST/platform/v1/backoffice/verifications/{id}/steps/{step_id}/documents/{document_id}/review

Approve or reject an uploaded document. Approve resumes the orchestrator; reject creates a fresh doc-request cycle (new row) with the rejection reason and re-emails the customer. Permission: verifications.document.review.

Request

{ "approve": false, "reason": "Document is unreadable — please re-upload with better lighting." }

Tenant manual review

Since v1.0.6 tenant owners and admins can review verifications from their own dashboard — no platform-admin round-trip. The endpoints below mirror the platform-admin surface exactly and call the same underlying use-cases, only scoped to the caller's tenant. Role gate: owner or admin (server-enforced via role, not the permission catalog).

Note: both realms (platform + tenant) can review the same verification. The row records who acted via requested_by_realm (on new document requests) and reviewer_realm (on the document row after review), each either "platform" or "tenant".

POST/v1/verifications/{id}/review/approve

Approve a manual_review / failed / expired verification. Charges the wallet for every passed step (same billing semantics as an auto-passed run).

Request

{ "note": "Verified against our internal customer file." }
POST/v1/verifications/{id}/review/reject

Reject a manual_review / failed / expired verification with a required reason. No wallet debit is applied.

Request

{ "reason": "Customer identity could not be confirmed." }
POST/v1/verifications/{id}/steps/{step_id}/request-document

Block the step, create a doc-request row (stamped requested_by_realm="tenant"), email the customer.

Request

{ "reason": "Please upload a utility bill from the last 3 months." }
POST/v1/verifications/{id}/steps/{step_id}/documents/{document_id}/review

Approve or reject an uploaded document. The row is stamped with reviewer_realm="tenant" so the audit trail records who acted.

Request

{ "approve": false, "reason": "Document is unreadable — please re-upload with better lighting." }
POST/v1/verifications/{id}/steps/{step_id}/manual-review

Approve or reject a single step manually (no document involved). Approve resumes the orchestrator; reject fails the verification.

Request

{ "approve": true, "reason": "Verified against internal record." }
GET/v1/verifications/{id}/steps/{step_id}/documents/{document_id}/file

Stream an uploaded document back to the tenant dashboard. Role gate: owner, admin, or ops. Response is the raw file bytes with the stored content-type.

Documents

The customer uploads via the hosted URL — the session token in the URL is the credential. Files are capped at 10 MB and must be one of application/pdf, image/jpeg, image/png, image/webp.

MVP note: uploads land on local disk under apps/api/var/uploads/{tenant_id}/{document_id} — no S3 yet. A follow-up (v1.1) moves them to object storage with presigned URLs.

POST/public/sessions/{session_token}/documents/{document_id}/upload

Multipart upload. No auth (the session token is the credential). Rejected when the doc-request status isn't awaiting_customer (already uploaded / already reviewed).

Common errors

upload_too_largeupload_type_not_allowedupload_extension_banneddocument_not_awaiting_uploadsession_not_found
GET/platform/v1/backoffice/documents/{tenant_id}/{document_id}

Streams the file back to the admin UI with the correct Content-Type and Content-Disposition so the browser opens PDFs / images inline. Enforces tenant scope on the row; superadmins can view any tenant. Permission: verifications.document.review.

Text info requests

Sometimes you don't need a document — you just need an answer ("what's your middle name?", "explain the address mismatch"). Pass response_type when creating the request; the customer sees a textarea instead of a drag-drop uploader on the hosted page.

POST/v1/verifications/{id}/steps/{step_id}/request-document

Same endpoint as a file request; add "response_type": "text" in the body. Body shape:

{
  "reason": "What is your middle name?",
  "response_type": "text"
}

Omitting response_type defaults to "document" — the existing file upload flow. Also available under the platform-admin path /platform/v1/backoffice/verifications/{id}/steps/{step_id}/request-document.

POST/public/sessions/{session_token}/text-info-requests/{document_id}/submit

Public endpoint the customer's browser calls when submitting a text answer. No auth — the session token is the credential. Single-shot: once submitted, further calls return 409 already_submitted (reviewer must reject-and-loopback to open a new cycle).

POST /public/sessions/{session_token}/text-info-requests/{document_id}/submit
Content-Type: application/json

{ "text": "Adebayo" }

Common errors

text_response_invalidalready_submittedfield_type_mismatchdocument_not_foundsession_not_found

Webhook

The same verification.document.uploaded event fires when a customer submits either a file or a text answer. The payload includes response_type so subscribers can branch: "document" rows expose file_name / file_size_bytes, "text" rows expose text_response.

Save & resume later

Session state persists naturally in verification_requests — customers can hit the hosted URL again at any time before the session expires and pick up where they left off. This endpoint just emails the URL back to them.

POST/public/sessions/{session_token}/email-resume-link

No body. Emails the hosted URL to the session's customer. Always returns 200 — silently no-ops when no email is on file so the endpoint doesn't reveal whether a customer address exists.

Billing (Wallet & pricing)

Every verification a customer completes debits the tenant wallet. Credit flows in via topups; debits flow out per step. The loop is wallet-first — insufficient balance stops a verification cold rather than accruing debt.

Price resolution order

  1. Tenant override on the (country, service) pair, if configured.
  2. Global catalog price for the same pair.
  3. If neither exists, the debit is skipped — the verification runs free and the event is logged as verification.debit.skip_no_price. This is intentional: a missing price is a "still-being-priced" signal, not an error.

Idempotency

Every debit is keyed by:

verification:{verification_id}:step:{step_order}

Replays (retried steps, provider retries, dead-letter reprocessing) de-dupe against this key — no double-charging.

Insufficient balance

When the wallet cannot cover the next step:

  • The verification flips to failed with error_code: wallet_insufficient.
  • No further steps run.
  • No new webhook events fire beyond verification.completed (the failed terminal state).

Recommendation: poll GET /v1/wallet and wire a Slack / email alert at 20% and 5% of your typical monthly burn.

Sandbox vs live

The sandbox wallet is fully isolated from the live wallet — topups and debits in sandbox are fake money and test verifications are free of charge. Switch envs via the X-Env request header or by using a sandbox API key.

How pricing is set

The platform admin curates the global catalog at /platform/pricing. Tenant-specific overrides live on the same page — pick a tenant, pick the (country, service) pair, set the override. Overrides win over the global entry per the resolution order above.

The wallet endpoints — GET /v1/wallet, GET /v1/wallet/ledger, GET /v1/wallet/topups, and POST /v1/wallet/topups — are documented in the Wallet group above.

Webhook events catalog

Tenants opt in per-endpoint via event_types on POST /v1/webhooks. Existing endpoints default to verification.completed only — enable the rest as your integration matures.

EventWhen it fires
verification.startedThe first step begins executing (pending → processing).
verification.step_completedAny single step reaches a terminal state (passed / failed / skipped / manual review).
verification.manual_reviewA step or the whole session pauses awaiting a human decision.
verification.cancelledThe session is cancelled by the tenant or by admin action.
verification.expiredA pending / processing session TTL elapses without completion.
verification.completedThe session reaches a terminal passed or failed state.
verification.document.requestedA platform admin blocks a step and asks the customer for a document.
verification.document.uploadedThe customer uploads a file via the hosted URL — awaiting admin review.
verification.document.reviewedAn admin approves or rejects the uploaded document (payload carries `approve` and `review_reason`).
verification.step.manual_reviewedAn admin manually approves/rejects a single step with no document involved (payload carries `approve` and `review_reason`).
product_request.startedA product request transitions from pending to processing — the fulfillment worker has picked it up.
product_request.succeededA product request is fulfilled successfully; payload includes `result` and the debited `cost_minor`.
product_request.failedA product request fails; payload includes `error_code` (see the Products fulfillment lifecycle) and `error_message`. No debit.

Example payload · verification.step_completed

{
  "api_version": "1",
  "event_type": "verification.step_completed",
  "delivery_id": "whd_…",
  "tenant_id": "ten_…",
  "verification_id": "ver_…",
  "customer_id": "cus_…",
  "workflow_id": "wf_…",
  "channel": "hosted_link",
  "status": "processing",
  "external_reference": "onboarding-42",
  "session_token": "ses_…",
  "step_results": [
    { "step_order": 0, "service_code": "BVN", "status": "passed" }
  ],
  "outcome_summary": { "status": "processing" },
  "occurred_at": "2026-07-18T12:04:31Z"
}

Need webhooks or embedding? Webhooks and Embed guide walk through the full flow.