Documentation

Ship KYC in a week.

One API for BVN, NIN, phone, account, and KYB. Compose them into workflows and expose them via a hosted link, an embed, or a server-to-server call.

Quickstart

Three steps from zero to your first verification.

  1. 1

    Grab a sandbox API key

    In your dashboard, open API keys and create a sandbox key. Sandbox spends test wallet credits — never real money.

  2. 2

    Publish a workflow

    In Workflows, order the checks you want (say: BVN → Phone → KYB) and click Publish. Publishing freezes the version and returns a workflow slug.

  3. 3

    Call POST /v1/verifications

    Point at your workflow slug, pass a customer email, and poll the returned verification ID — or subscribe to theverification.completedwebhook to skip polling.

curl -X POST https://api.karverifi.com/v1/verifications \
  -H "Authorization: Bearer $KV_SANDBOX_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "workflow": "wf_ng_merchant",
    "customer": { "email": "ada@example.com" }
  }'

Authentication

KarVerifi accepts two credential types on the tenant API:

  • Bearer JWT

    Short-lived access tokens issued by POST /v1/auth/login. Use these for dashboard-driven flows. Refresh via POST /v1/auth/refresh; single-session enforcement rotates the refresh token on every use.

  • API keys

    Long-lived, machine-to-machine credentials scoped to a tenant and environment. Prefixed kv_live_… or kv_test_…. Send as Authorization: Bearer <key>. Rotate any time from the dashboard — old keys keep working for a 24h grace window.

Sandbox and live keys route to entirely separate ledgers, webhook subscriptions, and workflow drafts. Test to your heart's content — sandbox never charges real money and never contacts real KYC registries.

Response envelope

Every JSON response — success or failure — comes wrapped in a consistent envelope. You always parse the same shape, then branch on error.

{
  "data": { ... } | null,
  "meta": {
    "next_cursor": "cur_abc123" | null,
    "limit": 50 | null,
    "request_id": "req_9f2a…",
    "extra": {}
  },
  "error": { "code": "invalid_request", "message": "…" } | null
}

Always include the meta.request_id when opening a support ticket — it lets us find the exact trace in seconds.

Error codes

Human-readable strings, not opaque numbers. Handle these explicitly.

CodeHTTPMeaning
invalid_credentials401Wrong email/password or bad API key.
session_expired401Access token expired — call /v1/auth/refresh.
invalid_request400Body failed schema validation.
permission_denied403Authenticated but the role lacks the permission.
not_found404Resource ID does not exist in this tenant.
conflict409Idempotency-Key reuse with a different payload.
rate_limited429Above the tenant rate limit.
insufficient_funds402Wallet balance below cost of the requested check.
service_unavailable503Upstream registry timing out — retry with backoff.

Rate limits

Default: 60 requests per minute per tenant, across all API keys. Bursts up to 30 requests are allowed within a rolling one-second window. Configurable per tenant in Platform Settings → Rate limits.

When you exceed the ceiling, we return 429 with error.code = "rate_limited" and a Retry-After header (seconds). Back off with jitter — do not hammer.

Webhooks

Every event we emit is delivered with signed headers so you can trust the source before you trust the body.

HeaderPurpose
X-KarVerifi-Signaturet=<unix>,v1=<hex> — HMAC-SHA256 of "{t}.{body}" keyed by your endpoint secret.
X-KarVerifi-DeliveryUnique delivery ID. Use it to dedupe retries.
X-KarVerifi-EventEvent name, e.g. verification.completed.

Verify in Node

import crypto from 'node:crypto';

export function verifyKarverifi(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(
    header.split(',').map((p) => p.split('=')),
  );
  const t = Number(parts.t);
  const v1 = parts.v1;
  if (!t || !v1) return false;

  if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.${rawBody}`)
    .digest('hex');

  return crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}

Verify in Python

import hmac
import hashlib
import time

def verify_karverifi(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts.get("t", "0"))
    v1 = parts.get("v1", "")
    if not t or not v1:
        return False
    if abs(time.time() - t) > tolerance:
        return False
    signed = f"{t}.{raw_body.decode()}".encode()
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(v1, expected)

We retry failed deliveries with exponential backoff for up to 24 hours (5 attempts). Re-push manually from the dashboard at any time.

Looking for the endpoint list? Head to the API reference for the guided tour of the most-used tenant endpoints.