Webhooks

Webhooks are your source of truth for the verification decision. Add an endpoint to your project in the dashboard, choose which events to receive, and Arkyc delivers each one, HMAC-SHA256 signed, with automatic retries and a deliveries log.

Events

  • verification.started, verification.document_submitted, verification.processing
  • verification.requires_review, verification.approved, verification.rejected
  • verification.completed, verification.expired, verification.cancelled

A verification lands in verification.requires_review when a signal is borderline low document quality or OCR confidence, a liveness/face-match near the threshold, or, when AI document processing is enabled, a best-effort tamper or screen-replay flag on the document. These hold for a human decision rather than auto-rejecting, so always treat requires_review as non-final.

Payload

Every delivery is a JSON body in this shape (snake_case). checks carries the per-stage summaries and assets holds signed, time-limited image URLs when any were captured.

payload.json
{
  "event": "verification.approved",
  "session_id": "ses_123",
  "organization_id": "org_123",
  "project_id": "prj_123",
  "user_reference": "user_456",
  "status": "approved",
  "decision_reason": "AUTO_APPROVED",
  "checks": { "document": { }, "liveness": { }, "face_match": { } },
  "assets": { "document_front": "https://…", "selfie": "https://…" },
  "created_at": "2026-06-24T10:00:00.000Z"
}

Verify the signature

Verify each delivery before trusting it: arkyc.webhooks.verify(...) recomputes the HMAC over `${timestamp}.${rawBody}` and checks the timestamp is within tolerance (5 min, so a captured body can’t be replayed). Pass the raw body string, not the parsed JSON, and the two signature headers.

webhook-handler.ts
import { Arkyc } from '@arkyc/sdk'

const arkyc = new Arkyc({ secretKey })

app.post('/webhooks/arkyc', async (req, res) => {
  const ok = arkyc.webhooks.verify({
    payload: req.rawBody, // the raw request body, as a string
    secret: WEBHOOK_SECRET, // the endpoint's signing secret
    signature: req.header('X-Arkyc-Signature'),
    timestamp: Number(req.header('X-Arkyc-Timestamp')),
  })

  if (!ok) return res.status(400).send('invalid signature')

  const event = JSON.parse(req.rawBody)
  if (event.event === 'verification.approved') {
    await activateUser(event.user_reference)
  }

  res.sendStatus(200)
})

Retries and deliveries

Non-2xx responses are retried with backoff. Every attempt is recorded in the project’s deliveries log in the dashboard, where you can inspect payloads and replay a delivery. Respond 2xx quickly and process asynchronously.

Delivery is at-least-once, so make your handler idempotent: the same event can arrive more than once (a retry after a slow response that actually succeeded). Key your side effects on session_id + event and make repeats a no-op; never grant access or bill twice. Don’t rely on arrival order either; trust the session’s current state (or re-fetch it) rather than the sequence of events. See Verification lifecycle for the status/decision model.