Skip to content

Developers

Three calls from sign-up to a signed decision.

Plain HTTPS and JSON, an x-api-key header, one error envelope, and a webhook you can verify in ten lines. No SDK to install, nothing to keep in sync.

calls from sign-up to a signed decision
3
public endpoints, one error envelope
12
persisted webhook attempts, then redeliver
8
SDKs to install or keep in sync
0

Step 01

Create a session

Your backend posts to /v1/sessions with an API key and gets back the hosted link and a session id.

POST /v1/sessions

curl -X POST https://kycverify.me/api/v1/sessions \
  -H "x-api-key: $KYC_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "vendor_data": "user-42",
    "contact": { "email": "anna@example.com" }
  }'

201 Created

{
  "session_id": "ses_0k3v9x2m4a7qhd8f1rtb",
  "status": "not_started",
  "url": "https://kycverify.me/verify/q3Xf…",
  "session_token": "q3Xf…",
  "workflow_id": "wf_0k3t1c8n5e2wpzr6g4ya",
  "vendor_data": "user-42",
  "expires_at": "2026-10-10T09:12:44Z"
}

Step 02

Verify the webhook

Each status change arrives as session.status_updated, signed with HMAC-SHA256 over the timestamp and the raw body. Ten lines in any language.

x-kyc-signature

import { createHmac, timingSafeEqual } from "node:crypto";

// x-kyc-signature: v1=<hex(HMAC-SHA256(secret, "{timestamp}.{raw_body}"))>
export function verifyKycWebhook(rawBody, headers, secret) {
  const ts = headers["x-kyc-timestamp"] ?? "";
  if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;

  const expected = createHmac("sha256", secret)
    .update(`${ts}.`)
    .update(rawBody) // the raw Buffer, before JSON.parse
    .digest("hex");
  const given = String(headers["x-kyc-signature"] ?? "").replace(/^v1=/, "");

  return given.length === expected.length &&
    timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}

Step 03

Use the decision

The webhook carries the decision when the session is final; fetch it any time with GET /v1/sessions/{id}/decision.

GET /v1/sessions/{id}/decision

curl https://kycverify.me/api/v1/sessions/ses_0k3v9x2m4a7qhd8f1rtb/decision \
  -H "x-api-key: $KYC_API_KEY"

200 OK

{
  "session_id": "ses_0k3v9x2m4a7qhd8f1rtb",
  "status": "approved",
  "decision_reason": null,
  "workflow_id": "wf_0k3t1c8n5e2wpzr6g4ya",
  "vendor_data": "user-42",
  "metadata": {},
  "identity": {
    "full_name": "ANNA MARIA ERIKSSON",
    "date_of_birth": "1974-08-12",
    "nationality": "UTO",
    "document_type": "passport",
    "document_number_masked": "L8989****",
    "expiry_date": "2032-04-15",
    "sources": { "full_name": "mrz", "date_of_birth": "mrz" }
  },
  "checks": [
    { "kind": "document",   "status": "passed", "score": 0.97 },
    { "kind": "liveness",   "status": "passed", "score": 0.91 },
    { "kind": "face_match", "status": "passed", "score": 0.612 },
    { "kind": "aml",        "status": "passed", "score": 0 }
  ],
  "review": null,
  "created_at": "2026-10-03T09:02:11Z",
  "submitted_at": "2026-10-03T09:11:58Z",
  "completed_at": "2026-10-03T09:12:44Z",
  "expires_at": "2026-10-10T09:02:11Z"
}

The whole loop

What happens between your two calls.

The person completes the hosted flow; the engine runs the background checks; your endpoint hears about every transition.

  1. 1Your backend → KYCVerify APIPOST /v1/sessionsx-api-key; returns url, session_id
  2. 2Your backend → Hosted flowSend the urlredirect, email or QR code
  3. 3Hosted flow → KYCVerify APIConsent, document, liveness/flow/{token}/… per enabled step
  4. 4Hosted flow → KYCVerify APIPOST /flow/{token}/submitstatus becomes processing
  5. 5KYCVerify APIBackground checksface match, AML, age, duplicate, IP
  6. 6KYCVerify API → Your webhooksession.status_updatedHMAC-SHA256 signed, decision included
  7. 7KYCVerify API → Hosted flowReturn to callback_url?session_id=…&status=…
  8. 8Your backend → KYCVerify APIGET /v1/sessions/{id}/decisionany time, optional
The integration in eight steps. Your backend makes steps 1 and 8; everything else is KYCVerify and the person.

Webhooks

Signed, retried, redeliverable.

Deliveries live in the database, so a restart loses nothing. Each attempt is signed when it is sent, with a fresh timestamp.

  • Any 2xx within 10 seconds counts as delivered; redirects are never followed.
  • Private, loopback and link-local addresses are refused, and the connection is pinned to the vetted address.
  • delivery_id makes your handler idempotent; the console redelivers any event.
  • Test vector: whsec_test, 1700000000, {"a":1} → v1=3887…3789.
Webhooks reference
  1. Attempt 1

    0

    on the status change

  2. Attempt 2

    +30 s

    30 s after the last

  3. Attempt 3

    +2 m 30 s

    2 min after the last

  4. Attempt 4

    +12 m 30 s

    10 min after the last

  5. Attempt 5

    +42 m 30 s

    30 min after the last

  6. Attempt 6

    +1 h 42 m

    1 h after the last

  7. Attempt 7

    +4 h 42 m

    3 h after the last

  8. Attempt 8

    +10 h 42 m

    6 h after the last

Any 2xx within 10 s

delivery succeeded; no more attempts

Anything else

non-2xx, timeout, refused: next attempt is scheduled

After attempt 8 fails

marked failed; redeliver from the console

Eight attempts over about 10 h 43 min, then the delivery is marked failed and waits for a redelivery.

Errors

One envelope, eight codes.

Every error from every endpoint is { error: { code, message } }. Branch on code; show message.

  • 400bad_request

    A field failed validation. The message names it.

  • 401unauthorized

    Missing, unknown or revoked key.

  • 403forbidden

    Your console role may not do this.

  • 404not_found

    Not in your app. Never leaks other apps' ids.

  • 409conflict

    Wrong state, e.g. deciding a session not in review.

  • 422unprocessable

    Understood but refused, e.g. no face in the image.

  • 429rate_limited

    Slow down; the window is one minute.

  • 500internal

    Logged on our side, safe to retry.

Errors and limits

The service

One base URL: kycverify.me/api.

Your backend calls the hosted API with a key, people verify at kycverify.me/verify/{token}, and decisions come back to you as signed webhooks. Uploads are encrypted at rest and purged on your retention period.

First call

# your backend's environment
KYC_API_BASE=https://kycverify.me/api
KYC_API_KEY=kyc_test_…          # Console → Developers → API keys, shown once
KYC_WEBHOOK_SECRET=whsec_…      # Console → Webhooks → Rotate secret, shown once

# then
curl -X POST "$KYC_API_BASE/v1/sessions" \
  -H "x-api-key: $KYC_API_KEY" -H "content-type: application/json" \
  -d '{"vendor_data": "user-42"}'
  • End user's browser

    hosted flow at kycverify.me/verify/{token}

  • Console users

    reviewers, admins, owners

  • Your backend

    x-api-key calls to kycverify.me/api/v1/…

KYCVerify · kycverify.me

HTTPS front

terminates TLS; /api → Rust API, everything else → Next.js

Next.js app

console, hosted flow; proxies /api/* to the API

kyc-api

axum; engine, webhook, AML and retention workers

  • SQLite

    data/kyc.db

  • Uploads

    AES-256-GCM, data/files

  • Models

    YuNet, SFace, ocrs

  • UIDAI certs

    certs/uidai

Only outbound calls

  • Email codes

    from no-reply@kycverify.me, if the email step is on

  • Your webhook URL

    signed; private addresses refused

  • OFAC and UN list URLs

    public sanctions files; checked every 6 h, re-downloaded when older than 7 days

The KYCVerify service. The only outbound calls are code emails, your webhook URL and the public sanctions-list downloads.

Conventions

Boring in the right places.

  • Test and live keys

    kyc_test_ and kyc_live_ keys belong to separate apps with separate data.

  • One error shape

    { error: { code, message } } with eight documented codes.

  • Idempotent receivers

    Every webhook carries a delivery_id and can be redelivered from the console.

  • Evidence in the payload

    Decisions include identity, sources per field and every check with its warnings.

Get a test key in a minute.

Sign up, create a key under Developers, and run the quickstart against the sandbox.