Skip to content

Webhooks

YeboVerify can send webhook notifications to your server when verification events occur. This is useful for async verification flows or for keeping your system in sync with verification results. Everything on this page applies identically to individual KYC (/v1/verify, /v1/sessions/*) and Company Verification (KYB) — KYB payloads carry type: "company" and a company block instead of person-specific fields, but sign, retry, and dedupe exactly the same way.

Setting Up Webhooks

Configure Your Webhook URL

bash
curl -X PUT https://api.yeboverify.com/v1/account/webhook \
  -H "X-API-Key: your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "webhookUrl": "https://yourapp.com/webhooks/yeboverify",
    "webhookSecret": "whsec_your_secret_here"
  }'

Webhook Secret

The webhook secret is used to sign every outbound webhook using HMAC-SHA256. Always verify the signature to ensure the webhook is genuinely from YeboVerify — see Webhook Signature Verification.


Webhook Events

YeboVerify currently emits three events. Listen for all of them — your handler should switch on event and ignore unknown values gracefully so future additions don't break your integration.

EventFires whenIncludes decision/scores
verification.submittedDocuments arrive at YeboVerify (before AI processing)No — submission only
verification.completedAI processing finishes (individual KYC or KYB)Yes — full result
verification.deletedAn admin removes the verification on the YeboVerify sideNo — verificationId only

Every payload carries an eventId — a stable id for that specific event, safe to use as a dedupe key. See Deduplicating Events.

verification.submitted

Fires the moment a verification is created — the user has uploaded their documents and processing is about to begin. Use this to flip a "pending" / "in progress" state in your UI without lying about progress before the user has actually submitted.

Payload:

json
{
  "event": "verification.submitted",
  "eventId": "evt_9f8e7d6c5b4a3928170f6e5d",
  "verificationId": "vrf_id_abc123xyz",
  "externalRef": "user_123",
  "timestamp": "2026-05-08T12:00:00.000Z"
}

Payload Fields:

FieldTypeDescription
eventstringAlways verification.submitted
eventIdstringStable id for this event — see Deduplicating Events
verificationIdstringUnique verification ID
externalRefstring | nullYour reference ID (if provided when calling /v1/verify or /v1/sessions/create)
timestampstringISO 8601 timestamp

Out-of-order safety

On rare network conditions, verification.submitted can arrive after verification.completed. Handle this by refusing to downgrade your local state from a final decision (verified/rejected) back to "in progress." Keep a kycStatus guard like if currentStatus is one of {NONE, PENDING} before flipping to PENDING.


verification.completed

Sent when processing finishes — the verification has a final decision (approved, rejected, or needs human review). The same event covers both individual KYC and company (KYB) verifications; check type to know which shape you're looking at.

Individual KYC payload:

json
{
  "event": "verification.completed",
  "eventId": "evt_1a2b3c4d5e6f7a8b9c0d1e2f",
  "type": "individual",
  "verificationId": "vrf_id_abc123xyz",
  "externalRef": "user_123",
  "status": "completed",
  "decision": "approved",
  "confidence": "high",
  "faceScore": 92.3,
  "ocrConfidence": 85,
  "livenessChecked": true,
  "livenessScore": 88,
  "livenessDecision": "live",
  "aml": {
    "decision": "clear",
    "score": 0,
    "hits": []
  },
  "extractedData": {
    "surname": "SMITH",
    "names": "JOHN MICHAEL",
    "dateOfBirth": "1990-05-15",
    "idNumber": "9005151234567",
    "documentType": "NATIONAL_ID"
  },
  "timestamp": "2026-05-08T12:00:05.000Z"
}

Payload Fields:

FieldTypeDescription
eventstringAlways verification.completed
eventIdstringStable id for this event — see Deduplicating Events
typestringindividual or company. KYB sends company and a company block instead of faceScore/ocrConfidence/extractedData.
verificationIdstringUnique verification ID
externalRefstring | nullYour reference ID (if provided)
statusstringcompleted, failed, or needs_review
decisionstringapproved, rejected, or needs_review
confidencestringhigh, medium, or low
faceScorenumberFace similarity score (0-100)
ocrConfidencenumberOCR confidence (0-100)
livenessCheckedbooleanWhether an anti-spoofing/liveness signal was actually evaluated. false means the decision carries no anti-spoofing assurance (e.g. a single-shot upload for a business that doesn't require liveness).
livenessScorenumberComposite liveness/anti-spoofing score (0-100, 0 when not checked)
livenessDecisionstringlive, spoofed, uncertain, or not_checked
amlobjectAML/sanctions/PEP/watchlist screening summary — see AML screening result
extractedDataobjectExtracted document data — see Extracted Data Fields
timestampstringISO 8601 timestamp

AML screening result

FieldTypeDescription
aml.decisionstringclear (no match), hit (potential match — routes to needs_review), error (screening couldn't complete — routes to needs_review), or not_screened (provider not configured)
aml.scorenumberHighest match score across all hits (0-1)
aml.hitsarrayUp to 10 matches: { name, score, topics }

Extracted Data Fields

When decision is approved, extractedData includes whatever YeboVerify could read from the ID. All fields are optional — partial OCR is normal:

FieldTypeDescription
surnamestringFamily name
namesstringGiven names
dateOfBirthstringDate of birth (YYYY-MM-DD)
sexstringM / F / X
idNumberstringNational ID / passport number
nationalitystringISO country code or country name
issuingCountrystringCountry that issued the document
issueDatestringDate issued (YYYY-MM-DD)
expiryDatestringDate of expiry (YYYY-MM-DD)
documentTypestringNATIONAL_ID, PASSPORT, DRIVERS_LICENSE, etc.

For the company-shaped payload (registered name, signatory decision, document checklist), see KYB → Webhook Payload.


verification.deleted

Fires when an admin removes a verification on the YeboVerify side — typically used to reset a user's KYC state so they can re-verify.

Payload:

json
{
  "event": "verification.deleted",
  "eventId": "evt_5c4b3a2918f7e6d5c4b3a291",
  "verificationId": "vrf_id_abc123xyz",
  "externalRef": "user_123",
  "timestamp": "2026-05-08T14:30:00.000Z"
}

Payload Fields:

FieldTypeDescription
eventstringAlways verification.deleted
eventIdstringStable id for this event — see Deduplicating Events
verificationIdstringThe verification that was removed
externalRefstring | nullYour reference ID (if provided)
timestampstringISO 8601 timestamp

Use this to reset the user's KYC state (e.g., flip kycStatus back to NONE) so they can re-submit.


Webhook Signature Verification

Every webhook is signed and sent with three headers. Use X-YeboVerify-Signature-V2 — it's replay-resistant and is the only scheme that will keep receiving updates going forward.

X-YeboVerify-Signature:    sha256=<hex>     — legacy, body-only, no replay protection
X-YeboVerify-Timestamp:    <unix seconds>   — when this request was signed
X-YeboVerify-Signature-V2: sha256=<hex>     — HMAC over "<timestamp>.<raw body>"

X-YeboVerify-Signature (no -V2) has no replay protection

It's HMAC-SHA256(body, secret) — the body alone. Anyone who ever observes one legitimate delivery (a compromised log, a proxy, a misconfigured analytics tool) can replay that exact request to your endpoint indefinitely and it will verify successfully forever. It exists only so existing integrations don't break outright; do not build new verification against it, and migrate off it if you're currently using it.

X-YeboVerify-Signature-V2 is HMAC-SHA256("${timestamp}.${rawBody}", secret) — the timestamp is inside what's signed, so a captured request can't be replayed once you reject stale timestamps (see the skew check below). This mirrors how YeboPay signs its own callbacks.

Verify on raw bytes, not a re-stringified parsed object

JSON re-serialization can change whitespace or key ordering, which silently invalidates the signature. Always run HMAC against the exact bytes you received over the wire — capture the raw body before any JSON parser runs (Express: mount express.raw({ type: 'application/json' }) on your webhook route before express.json()).

Node.js Verification

javascript
const crypto = require('crypto');
const express = require('express');

const MAX_TIMESTAMP_SKEW_SECONDS = 5 * 60; // reject anything signed >5 minutes ago

function verifyWebhookSignatureV2(rawBody, timestampHeader, signatureV2Header, secret) {
  const timestamp = parseInt(timestampHeader || '', 10);
  if (!Number.isFinite(timestamp)) return false;

  // Replay protection: a captured, still-validly-signed request older than
  // the skew window is rejected outright, timestamp or not.
  const ageSeconds = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
  if (ageSeconds > MAX_TIMESTAMP_SKEW_SECONDS) return false;

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

  const sigBuf = Buffer.from(signatureV2Header || '');
  const expBuf = Buffer.from(expected);
  if (sigBuf.length !== expBuf.length) return false;
  return crypto.timingSafeEqual(sigBuf, expBuf);
}

// Mount BEFORE express.json() so the body is captured as a Buffer
app.post(
  '/webhooks/yeboverify',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const timestamp = req.headers['x-yeboverify-timestamp'];
    const signatureV2 = req.headers['x-yeboverify-signature-v2'];
    const webhookSecret = process.env.YEBOVERIFY_WEBHOOK_SECRET;

    if (!verifyWebhookSignatureV2(req.body, timestamp, signatureV2, webhookSecret)) {
      console.error('Invalid or stale webhook signature');
      return res.status(401).send('Invalid signature');
    }

    const event = JSON.parse(req.body.toString('utf8'));

    switch (event.event) {
      case 'verification.submitted':
        handleSubmitted(event);
        break;
      case 'verification.completed':
        handleCompleted(event);
        break;
      case 'verification.deleted':
        handleDeleted(event);
        break;
      default:
        console.log('Unknown event type:', event.event);
    }

    res.status(200).send('OK');
  }
);

function handleSubmitted(event) {
  console.log(`Verification ${event.verificationId} submitted by ${event.externalRef}`);
  // Flip the user's local kycStatus to PENDING / "in progress"
}

function handleCompleted(event) {
  console.log(`Verification ${event.verificationId} (${event.type ?? 'individual'}) → ${event.decision}`);
  // Update your database with the final decision + extracted data
}

function handleDeleted(event) {
  console.log(`Verification ${event.verificationId} deleted`);
  // Reset the user's local kycStatus so they can re-verify
}

Python Verification

python
import hmac
import hashlib
import json
import time
from flask import Flask, request, abort

app = Flask(__name__)
WEBHOOK_SECRET = 'your_webhook_secret'
MAX_TIMESTAMP_SKEW_SECONDS = 5 * 60


def verify_signature_v2(raw_body: bytes, timestamp: str, signature_v2: str, secret: str) -> bool:
    try:
        ts = int(timestamp)
    except (TypeError, ValueError):
        return False

    if abs(int(time.time()) - ts) > MAX_TIMESTAMP_SKEW_SECONDS:
        return False

    signed_payload = f'{ts}.'.encode() + raw_body
    expected = 'sha256=' + hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature_v2 or '')


@app.route('/webhooks/yeboverify', methods=['POST'])
def handle_webhook():
    timestamp = request.headers.get('X-YeboVerify-Timestamp')
    signature_v2 = request.headers.get('X-YeboVerify-Signature-V2')
    raw_body = request.get_data()  # raw bytes, BEFORE Flask parses JSON

    if not verify_signature_v2(raw_body, timestamp, signature_v2, WEBHOOK_SECRET):
        abort(401)

    event = json.loads(raw_body)
    event_type = event.get('event')

    if event_type == 'verification.submitted':
        handle_submitted(event)
    elif event_type == 'verification.completed':
        handle_completed(event)
    elif event_type == 'verification.deleted':
        handle_deleted(event)

    return 'OK', 200


def handle_submitted(event):
    print(f"Verification {event['verificationId']} submitted by {event.get('externalRef')}")
    # Flip the user's local kycStatus to PENDING


def handle_completed(event):
    print(f"Verification {event['verificationId']} ({event.get('type', 'individual')}) → {event['decision']}")
    # Update your database with the final decision


def handle_deleted(event):
    print(f"Verification {event['verificationId']} deleted")
    # Reset the user's local kycStatus

Deduplicating Events

Webhooks may be delivered more than once — a retry, a redelivery-sweep re-send, or a manual resend. Every payload carries an eventId: the same logical event always produces the same eventId, across every retry and redelivery, so use it as your dedupe key instead of verificationId (a single verification legitimately fires multiple different events over its lifetime — submitted, completed, and potentially a later re-fire if a review or an AML rescreen changes the decision — each with its own eventId).

javascript
const processedEventIds = new Set(); // use a persistent store in production (Redis/DB), not an in-memory Set

function handleWebhook(event) {
  if (processedEventIds.has(event.eventId)) {
    console.log('Duplicate delivery, skipping', event.eventId);
    return;
  }

  processedEventIds.add(event.eventId);
  // Process the event...
}

Falling back to verificationId

If you're on an older integration that doesn't yet key off eventId, deduping on verificationId alone for verification.completed is still reasonably safe as long as you also key on decision — a genuine re-fire (e.g. an AML rescreen flipping a cleared verification to needs_review) is a materially different event you do want to process, not a duplicate to skip.


Webhook Retry Policy

If your webhook endpoint returns 5xx or times out, YeboVerify retries the webhook with a short exponential backoff. verification.completed is retried because final decisions must reach you. verification.submitted and verification.deleted are fire-and-forget — they're best-effort UI signals, and the eventual verification.completed will always bring your state to truth.

verification.completed (3 attempts, ~6s total)

AttemptDelay before this attempt
1Immediate
21 second
32 seconds

If your endpoint returns 4xx (client error), retries stop immediately — fix your handler and we'll back-fill via the next webhook on a new verification. After 3 failed attempts the webhook is marked failed and no further retries are attempted from the in-process path — terminal verification.completed deliveries that are still unmarked webhookSent are also picked up by a background redelivery sweep over a longer horizon.

verification.submitted and verification.deleted (1 attempt)

Single best-effort send. If delivery fails, no retry. Your handler should be idempotent and stateless on these — verification.completed is the source of truth for the final state.


Best Practices

1. Return 200 Quickly

Always return a 200 OK response as quickly as possible. Process the webhook data asynchronously.

javascript
app.post('/webhooks/yeboverify', (req, res) => {
  // Respond immediately
  res.status(200).send('OK');
  
  // Process asynchronously
  processWebhookAsync(req.body).catch(console.error);
});

2. Deduplicate on eventId

See Deduplicating Events above.

3. Verify Signatures with X-YeboVerify-Signature-V2

Always verify the V2 signature (and its timestamp skew) before processing. This prevents both spoofed and replayed webhooks — see Webhook Signature Verification.

4. Use HTTPS

Always use HTTPS for your webhook endpoint. YeboVerify will not send webhooks to HTTP URLs.

5. Handle Timeouts

Your webhook endpoint should respond within 30 seconds. If processing takes longer, use a queue:

javascript
const { Queue } = require('bullmq');

const webhookQueue = new Queue('webhooks');

app.post('/webhooks/yeboverify', async (req, res) => {
  // Add to queue for async processing
  await webhookQueue.add('verification.completed', req.body);
  
  res.status(200).send('OK');
});

Testing Webhooks

Using ngrok for Local Development

  1. Install ngrok: npm install -g ngrok
  2. Start your local server: npm run dev
  3. Expose it: ngrok http 3000
  4. Update your webhook URL to the ngrok URL

Manual Webhook Test

You can simulate a webhook for testing (a hand-built test signature won't verify against a real secret — this is for exercising your handler's routing/parsing, not signature checks):

bash
curl -X POST http://localhost:3000/webhooks/yeboverify \
  -H "Content-Type: application/json" \
  -H "X-YeboVerify-Timestamp: $(date +%s)" \
  -H "X-YeboVerify-Signature-V2: sha256=test_signature" \
  -d '{
    "event": "verification.completed",
    "eventId": "evt_test000000000000000000",
    "type": "individual",
    "verificationId": "vrf_test_123",
    "status": "completed",
    "decision": "approved",
    "confidence": "high",
    "faceScore": 92.3,
    "aml": { "decision": "clear", "score": 0, "hits": [] },
    "timestamp": "2026-05-08T12:00:00.000Z"
  }'

Identity Verification API for Africa