Quickstart
Three steps from zero to your first verification.
- 1
Grab a sandbox API key
In your dashboard, open API keys and create a
sandboxkey. Sandbox spends test wallet credits — never real money. - 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
Call
POST /v1/verificationsPoint at your workflow slug, pass a customer email, and poll the returned verification ID — or subscribe to the
verification.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 viaPOST /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_…orkv_test_…. Send asAuthorization: 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.
| Code | HTTP | Meaning |
|---|---|---|
invalid_credentials | 401 | Wrong email/password or bad API key. |
session_expired | 401 | Access token expired — call /v1/auth/refresh. |
invalid_request | 400 | Body failed schema validation. |
permission_denied | 403 | Authenticated but the role lacks the permission. |
not_found | 404 | Resource ID does not exist in this tenant. |
conflict | 409 | Idempotency-Key reuse with a different payload. |
rate_limited | 429 | Above the tenant rate limit. |
insufficient_funds | 402 | Wallet balance below cost of the requested check. |
service_unavailable | 503 | Upstream 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.
| Header | Purpose |
|---|---|
X-KarVerifi-Signature | t=<unix>,v1=<hex> — HMAC-SHA256 of "{t}.{body}" keyed by your endpoint secret. |
X-KarVerifi-Delivery | Unique delivery ID. Use it to dedupe retries. |
X-KarVerifi-Event | Event 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.