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
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.
| Event | Fires when | Includes decision/scores |
|---|---|---|
verification.submitted | Documents arrive at YeboVerify (before AI processing) | No — submission only |
verification.completed | AI processing finishes (individual KYC or KYB) | Yes — full result |
verification.deleted | An admin removes the verification on the YeboVerify side | No — 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:
{
"event": "verification.submitted",
"eventId": "evt_9f8e7d6c5b4a3928170f6e5d",
"verificationId": "vrf_id_abc123xyz",
"externalRef": "user_123",
"timestamp": "2026-05-08T12:00:00.000Z"
}Payload Fields:
| Field | Type | Description |
|---|---|---|
event | string | Always verification.submitted |
eventId | string | Stable id for this event — see Deduplicating Events |
verificationId | string | Unique verification ID |
externalRef | string | null | Your reference ID (if provided when calling /v1/verify or /v1/sessions/create) |
timestamp | string | ISO 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:
{
"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:
| Field | Type | Description |
|---|---|---|
event | string | Always verification.completed |
eventId | string | Stable id for this event — see Deduplicating Events |
type | string | individual or company. KYB sends company and a company block instead of faceScore/ocrConfidence/extractedData. |
verificationId | string | Unique verification ID |
externalRef | string | null | Your reference ID (if provided) |
status | string | completed, failed, or needs_review |
decision | string | approved, rejected, or needs_review |
confidence | string | high, medium, or low |
faceScore | number | Face similarity score (0-100) |
ocrConfidence | number | OCR confidence (0-100) |
livenessChecked | boolean | Whether 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). |
livenessScore | number | Composite liveness/anti-spoofing score (0-100, 0 when not checked) |
livenessDecision | string | live, spoofed, uncertain, or not_checked |
aml | object | AML/sanctions/PEP/watchlist screening summary — see AML screening result |
extractedData | object | Extracted document data — see Extracted Data Fields |
timestamp | string | ISO 8601 timestamp |
AML screening result
| Field | Type | Description |
|---|---|---|
aml.decision | string | clear (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.score | number | Highest match score across all hits (0-1) |
aml.hits | array | Up 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:
| Field | Type | Description |
|---|---|---|
surname | string | Family name |
names | string | Given names |
dateOfBirth | string | Date of birth (YYYY-MM-DD) |
sex | string | M / F / X |
idNumber | string | National ID / passport number |
nationality | string | ISO country code or country name |
issuingCountry | string | Country that issued the document |
issueDate | string | Date issued (YYYY-MM-DD) |
expiryDate | string | Date of expiry (YYYY-MM-DD) |
documentType | string | NATIONAL_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:
{
"event": "verification.deleted",
"eventId": "evt_5c4b3a2918f7e6d5c4b3a291",
"verificationId": "vrf_id_abc123xyz",
"externalRef": "user_123",
"timestamp": "2026-05-08T14:30:00.000Z"
}Payload Fields:
| Field | Type | Description |
|---|---|---|
event | string | Always verification.deleted |
eventId | string | Stable id for this event — see Deduplicating Events |
verificationId | string | The verification that was removed |
externalRef | string | null | Your reference ID (if provided) |
timestamp | string | ISO 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
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
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 kycStatusDeduplicating 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).
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)
| Attempt | Delay before this attempt |
|---|---|
| 1 | Immediate |
| 2 | 1 second |
| 3 | 2 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.
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:
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
- Install ngrok:
npm install -g ngrok - Start your local server:
npm run dev - Expose it:
ngrok http 3000 - 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):
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"
}'