# 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_type_other_selected` | 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:

```json
{
  "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:

```json
{
  "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

1. In the Veriff Customer Portal, go to **All Integrations** and open your integration.
2. On the **Settings** tab, find the webhook URL fields under **Integration settings**.
3. 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.
4. Copy the integration's API key and shared secret key, and store the secret server-side, for example as `VERIFF_SHARED_SECRET`.
5. Test with the decision webhook. Create a session in a test integration, go through the flow, then force the session to `approved` or `declined` in 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.

```javascript
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:

```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](/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](/docs/destinations#set-a-delivery-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](/docs/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](/docs/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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Veriff webhooks reliably in minutes.