Guide to Veriff Webhooks: Features and Best Practices
Veriff webhooks deliver the results of identity verification to your systems: whether a user's document and selfie were approved or declined, when a user starts or submits a verification, and the results of AML screening or Mexican registry checks. If you onboard customers through Veriff, webhooks are how a decision reaches your account, KYC or risk systems.
This guide covers which Veriff webhooks you need, how they are configured and signed, the shape of the decision and event payloads, Veriff's delivery guarantees, and the best practices for production.
What are Veriff webhooks?
Veriff does not have one webhook with a list of event types. Each product has its own webhook, its own URL field and its own payload:
- The decision webhook sends the result of a verification session. Most products use it, including document and selfie IDV, biometric authentication and liveness, age estimation and proof of address.
- The event webhook is optional. It tracks the user's progress through the flow, such as starting and submitting.
- The watchlist screening, INE, CURP and INE+CURP webhooks send the results of AML screening and Mexican registry checks.
- The user-defined statuses webhook fires when someone adds a custom status to an attempt in the Customer Portal, and the Full Auto webhook is for Self-Serve Essential Plan integrations.
They share no envelope, but they are all signed the same way: an X-HMAC-SIGNATURE header containing a hex HMAC-SHA256 of the raw body, keyed with your integration's shared secret key.
Veriff webhook features
| Feature | Details |
|---|---|
| Configuration | Customer Portal, All Integrations > your integration > Settings |
| Subscription | One URL field per webhook type. The fields shown depend on your products |
| URL requirements | HTTPS only, no custom ports |
| Signature header | x-hmac-signature |
| Scheme | HMAC-SHA256, hex digest, no prefix |
| Signed content | The raw request body |
| Secret | The integration's shared secret key, also used to sign your API requests |
| Other headers | x-auth-client, your integration's API key |
| Response window | A 200 within 5,000 ms |
| Retries | Resent for up to a week |
| Delivery | At least once, in no guaranteed order |
| Source IPs | Available from your Veriff Solutions Engineer |
Common events
Event webhook
The event webhook's action field names the event, and code gives it a number.
| Code | action | Fires when |
|---|---|---|
| 7001 | started | The user arrives in the verification flow and starts |
| 7002 | submitted | The user submits the attempt, before being sent to your callback URL |
| 7007 | waiting_complete | The user gets the decision on the "waiting for decision" page |
| 7008 | waiting_continued | The user clicks Continue on that page before the decision is ready |
| 7009 | flow_finished | The user reaches the final screen and clicks Continue |
| 7010 | flow_cancelled | The user abandons the flow |
| 7011 | document_ | The user chooses to use another document type |
Codes 7007 onwards are web-flow only and need extra configuration by Veriff, through your Solutions Engineer. Here is Veriff's submitted example:
{
"id": "f04bdb47-d3be-4b28-b028-a652feb060b5",
"attemptId": "e30122d1-740b-4764-853f-470374a7abf4",
"feature": "selfid",
"code": 7002,
"action": "submitted",
"vendorData": "12345678",
"endUserId": "a1b2c35d-e8f7-6d5e-3cd2-a1b2c35db3d4"
}
id is the session ID. vendorData and endUserId are the identifiers you set when creating the session, or null if you didn't.
Decision webhook
The decision webhook has no event-type field. Its top-level status is the status of the response, "success" in Veriff's examples, and the decision is in verification.status: approved, declined, resubmission_requested, expired or abandoned, plus review if you have agreed that with Veriff. verification.code is one of 9001, 9102, 9103, 9104 and 9121. Here is Veriff's mock decision, trimmed:
{
"status": "success",
"verification": {
"id": "12df6045-3846-3e45-946a-14fa6136d78b",
"code": 9001,
"person": {
"firstName": "SARAH",
"lastName": "MORGAN",
"dateOfBirth": "1967-03-30"
},
"reason": null,
"status": "approved",
"document": {
"type": "DRIVERS_LICENSE",
"number": "MORGA753116SM9IJ",
"country": "GB"
},
"reasonCode": null,
"vendorData": "12345678",
"decisionTime": "2019-11-06T07:18:36.916Z",
"acceptanceTime": "2019-11-06T07:15:27.000Z"
},
"technicalData": {
"ip": "186.153.67.122"
}
}
Decision webhooks are sent as soon as Veriff decides, with one exception. For SDK sessions where the user is asked to resubmit on the final screen, a resubmission_requested decision is held for 5 minutes, and only sent if the user hasn't resubmitted in that time.
The other webhooks use other carriers again: checkType (initial_result or updated_result) for watchlist screening, eventType for INE, INE+CURP, user-defined statuses and Full Auto, and type (curp_registry) for CURP.
Setting up Veriff webhooks
- In the Veriff Customer Portal, go to All Integrations and open your integration.
- On the Settings tab, find the webhook URL fields under Integration settings.
- Fill in the fields you need, for example the Webhook decisions URL and the Webhook events URL. Each must be HTTPS without a custom port.
- Copy the integration's API key and shared secret key, and store the secret server-side, for example as
VERIFF_SHARED_SECRET. - Test with the decision webhook. Create a session in a test integration, go through the flow, then force the session to
approvedordeclinedin the Veriff environment and check that your endpoint receives it.
You can point several fields at the same endpoint if your handler can tell the payloads apart, or give each its own path.
For local development, hookdeck listen 3000 veriff --path /webhooks/veriff gives you a public HTTPS URL to paste into the Customer Portal.
Securing Veriff webhooks
Compute an HMAC-SHA256 over the raw request body with your shared secret key, hex-encode it, and compare it with x-hmac-signature in constant time. Nothing else is signed, and there is no timestamp. x-auth-client carries your API key, which identifies the integration but doesn't prove anything on its own.
const crypto = require('crypto');
const express = require('express');
function verifyVeriffSignature(rawBody, signatureHeader, sharedSecret) {
if (!signatureHeader || !sharedSecret) return false;
const expected = crypto.createHmac('sha256', sharedSecret).update(rawBody).digest('hex');
const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
const computed = Buffer.from(expected, 'utf8');
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}
const app = express();
app.post('/webhooks/veriff', express.raw({ type: '*/*' }), (req, res) => {
const secret = process.env.VERIFF_SHARED_SECRET;
if (!secret) return res.sendStatus(500); // fail closed
if (!verifyVeriffSignature(req.body, req.get('x-hmac-signature'), secret)) {
return res.sendStatus(401);
}
const payload = JSON.parse(req.body.toString('utf8'));
if (payload.verification && payload.verification.status) {
// Decision webhook: payload.verification.status is approved, declined, ...
} else if (payload.action) {
// Event webhook: payload.action is started, submitted, ...
}
res.sendStatus(200);
});
The same check in Python:
import hashlib
import hmac
def verify_veriff_signature(raw_body: bytes, signature_header, shared_secret: str) -> bool:
if not signature_header or not shared_secret:
return False
expected = hmac.new(shared_secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature_header.strip().lower().encode("utf-8"), expected.encode("utf-8"))
# In FastAPI: raw_body = await request.body()
# verify_veriff_signature(raw_body, request.headers.get("x-hmac-signature"),
# os.environ["VERIFF_SHARED_SECRET"])
Veriff publishes a mock shared secret key, abcdef12-abcd-abcd-abcd-abcdef012345, for testing. With that key, this exact body:
{"status":"success","verification":{"id":"12df6045-3846-3e45-946a-14fa6136d78b","status":"approved"}}
should produce:
187d17aa03c35d8822412284dd515cf2739433e27ec5f605033ec0993d45fb51
Veriff's own mock decision request also verifies under that key, but only over the compact JSON. Veriff warns that its sample contains hard spaces, which some languages keep and others strip. If a real webhook fails, Veriff shows the exact raw body in the Customer Portal under Verifications > the session > Webhooks > Details.
Make Veriff webhooks production-ready. Hookdeck Event Gateway verifies
x-hmac-signature, answers Veriff inside its 5 second window, and durably queues every decision so a verification result is never lost to your own downtime.
Veriff webhook limitations and pain points
A 5 second window
The Problem: A handler that does real work before responding causes repeated deliveries of the same decision.
Why It Happens: Veriff expects a 200 within 5,000 ms. A slower response, or any non-200 code, counts as a failure and the webhook is resent. A handler that writes to several systems before answering can easily miss that window.
Workarounds:
- Verify, store the payload, return
200, and process afterwards. - Return exactly
200. Veriff's docs ask for "a 200 code".
How Hookdeck Can Help: Hookdeck responds to Veriff as soon as the request is stored, then delivers to your service with its own timeout and retries, so slow processing on your side never triggers a resend from Veriff.
Many webhooks, no common envelope
The Problem: One handler receiving several webhook types misreads them, or treats a decision as an event.
Why It Happens: Each product sends its own shape, and the field that identifies the event changes between them: action, verification.status, checkType, eventType or type. The decision webhook has no event-type field at all.
Workarounds:
- Give each webhook type its own URL or path.
- If they share a URL, detect the shape first, then dispatch.
How Hookdeck Can Help: Hookdeck filters can route on body fields, for example sending anything with verification.status to your decisions service and anything with action to your analytics service, from one source URL.
Duplicates and out-of-order delivery
The Problem: A user is approved twice, or a late submitted event overwrites an approved decision in your records.
Why It Happens: Veriff guarantees at-least-once delivery and says it does not deliver webhooks in the order they are generated. Retries continue for up to a week.
Workarounds:
- Deduplicate on the session ID and attempt ID, together with the decision status or event code.
- Store decisions separately from progress events, and never let an event webhook overwrite a decision.
How Hookdeck Can Help: Hookdeck's deduplication can drop repeated deliveries based on the fields you choose, such as verification.id and verification.status.
One secret for API and webhooks
The Problem: Rotating the key for your API calls also breaks webhook verification.
Why It Happens: The integration's shared secret key signs both the API requests you make and the webhooks Veriff sends.
Workarounds:
- Load the secret from a store your API client and your webhook receiver both read.
- Update both at the same time.
How Hookdeck Can Help: With Hookdeck in front, the webhook side of the secret lives on the Hookdeck source, so rotating it is a single update there.
Best practices
Verify against the raw body
Veriff signs the raw body. Mount express.raw() on the route in Express, use await request.text() in a Next.js route handler, or await request.body() in FastAPI. Veriff's own sample server uses body-parser's JSON parser, which is fine for logging but not for verification.
Link results to users by session ID
Store verification.id from your POST /sessions response, and match on it. vendorData and endUserId are useful too, but one user can have several sessions, so they can be ambiguous on their own.
Handle personal data carefully
Decision payloads contain names, dates of birth, addresses and document numbers. Keep raw payloads out of general logs, restrict access to stored events, and apply your retention rules to any copy of the webhook data.
Expect new fields and event types
Veriff treats adding properties to webhook payloads, adding new event types and changing property order as backwards compatible, and asks listeners to handle unfamiliar event types gracefully. Parse leniently and acknowledge anything you don't recognise.
Be ready for a week of retries
Because Veriff retries for up to a week, an old decision can arrive long after a newer one. Compare decisionTime before you overwrite stored state.
Conclusion
Veriff sends a separate webhook for each product, but signs every one of them with a hex HMAC-SHA256 of the raw body in x-hmac-signature. Verify the raw bytes with your shared secret key, detect which payload you have before dispatching, respond with a 200 inside 5 seconds, and deduplicate on the session and attempt IDs, because Veriff delivers at least once and in any order.
Hookdeck Event Gateway verifies every Veriff signature, answers within Veriff's window, routes decisions and events to the right services, and keeps every result until your systems have processed it.
Get started with Hookdeck for free and handle Veriff webhooks reliably in minutes.