# Guide to Sumsub Webhooks: Features and Best Practices

Sumsub webhooks report what happens during identity verification: an applicant is created, submits documents, is approved or rejected, a transaction is flagged by monitoring, or a case changes status. If your product gates accounts, payouts or features on KYC or KYB results, these webhooks are how the decision reaches your systems.

This guide covers how Sumsub webhooks are configured and signed, the event types and payloads, the retry and failure rules, and the practices that keep a Sumsub receiver reliable in production.

## What are Sumsub webhooks?

Sumsub webhooks are set up in the Webhook manager in the Sumsub Dashboard. Each webhook has one type, one receiver and its own Secret key. When an event of that type happens, Sumsub sends a JSON `POST` to your HTTPS endpoint. The same manager can also deliver to Slack, Telegram or email, but this guide is about HTTP receivers.

Deliveries are signed with an HMAC of the raw body in `x-payload-digest`. The hash algorithm is a per-webhook setting, and Sumsub tells you which one it used in a second header, `X-Payload-Digest-Alg`. Sumsub describes verification as optional; for a system that acts on verification results, treat it as required.

Webhooks carry IDs and statuses, not personal data. To get an applicant's details you call the API with the `applicantId` from the webhook.

## Sumsub webhook features

| Feature | Details |
| --- | --- |
| Configuration | Dashboard: Dev space > Webhooks > Webhook manager |
| Subscription model | One webhook type per webhook, up to 50 active webhooks |
| Target | HTTPS only, TLS 1.2 or higher |
| Signature header | `x-payload-digest` |
| Algorithm header | `X-Payload-Digest-Alg`: `HMAC_SHA256_HEX` (default), `HMAC_SHA512_HEX` or `HMAC_SHA1_HEX` (legacy) |
| Signed content | The raw request body, nothing else |
| Secret | One per webhook, generated by Sumsub or set by you |
| Replay window | None. No timestamp is signed |
| Response window | Accept the connection in 1 second, respond in 10 seconds |
| Retries | Five resends, if "Resend failed webhooks several times" is on |
| Permanent failures | `401`, `403`, `404`: no retry |
| Auto-disable | After continuous failures, with an email to admins |
| Test sends | **Test webhook** button, body includes `"testMode": true` |
| Source IPs | Sumsub says not to allowlist them, as they may change |

## Common events

Sumsub documents 50 webhook types in four families. The top-level `type` field names the event; no header carries it.

| Family | Examples |
| --- | --- |
| User verification (30) | `applicantCreated`, `applicantPending`, `applicantReviewed`, `applicantOnHold`, `applicantReset`, `applicantLevelChanged`, `applicantDeactivated`, `applicantActionReviewed`, `applicantWorkflowCompleted`, `videoIdentStatusChanged` |
| Transaction monitoring (12) | `applicantKytTxnCreated`, `applicantKytTxnApproved`, `applicantKytTxnRejected`, `applicantKytOnHold` |
| Case management (10) | `amlCaseApproved`, `amlCaseRejected`, `kytCaseCreated`, `kytCaseStatusChanged`, `kytCaseV2Created`, `kytCaseV2StatusChanged` |
| Fraud Network Detection (2) | `applicantFraudNetworkCreated`, `applicantFraudNetworkModified` |

`kytCaseReviewed` is marked deprecated. Case Management 1.0 cases send `kytCase*` events and 2.0 cases send `kytCaseV2*` events.

`applicantReviewed` is the one most integrations care about. It fires for approvals and rejections alike, so the outcome is in the body, not the type. Here is Sumsub's example of a rejection the applicant can retry:

```json
{
  "applicantId": "69e5d579e37291e569b75ce5",
  "inspectionId": "69e5d579e37291e569b75ce5",
  "applicantType": "individual",
  "correlationId": "ffdf14510f0bdde909a2e5f9988f92ec",
  "levelName": "1doc",
  "sandboxMode": false,
  "externalUserId": "dash-6ccde837-640a-49b9-a2f9-f096f561744c",
  "type": "applicantReviewed",
  "reviewResult": {
    "reviewAnswer": "RED",
    "rejectLabels": ["BAD_PROOF_OF_IDENTITY"],
    "reviewRejectType": "RETRY",
    "buttonIds": ["badDocument_suspiciousDocument", "badDocument"]
  },
  "reviewStatus": "completed",
  "createdAtMs": "2026-04-30 08:04:23.379",
  "clientId": "coolClientId"
}

```

`reviewResult.reviewAnswer` is `GREEN` or `RED`, and a `RED` result's `reviewRejectType` is `RETRY` (the applicant can resubmit) or `FINAL`.

Bodies are flat. Every type has `type`, `correlationId`, `sandboxMode` and `createdAtMs`, and the rest depends on the family: `applicantId` and `reviewStatus` for applicants, `kytTxnId` for transactions, `kytCaseId` or `amlCaseId` for cases. Two fields need care:

* `createdAtMs` is a UTC string in the form `yyyy-MM-dd HH:mm:ss.fff`, not an epoch number.
* `correlationId` is shared by every event that one operation triggers, so it is not a unique event ID.

If a verification level uses a workflow, `applicantReviewed` waits until the workflow completes, and workflow events such as `applicantAwaitingUser` and `applicantWorkflowCompleted` can also arrive.

## Setting up Sumsub webhooks

In the Dashboard, open **Dev space** > **Webhooks** > **Webhook manager** and click **Create webhook**:

1. Name the webhook and choose **HTTP address** as the receiver.
2. Enter the HTTPS **Target** URL.
3. Choose the **Type**. You need one webhook per type you want to receive.
4. Choose the **Applicant types**: individuals, companies or both.
5. Keep the generated **Secret key** or enter your own, and pick the **Signature algorithm** (SHA256 by default, or SHA512).
6. Turn on **Resend failed webhooks several times**. Without it, a failed delivery is not retried.
7. Leave **Rollout percentage** at 100 unless you deliberately want only a share of events.
8. Click **Test webhook** to send a delivery with `"testMode": true`.

Webhooks created before SHA256 became the default may still use SHA1 and announce `HMAC_SHA1_HEX`. Sumsub suggests editing them and selecting SHA256 or SHA512.

## Securing Sumsub webhooks

Read `X-Payload-Digest-Alg`, map it to a hash function, compute an HMAC of the raw body with the webhook's Secret key, hex-encode it, and compare it with `x-payload-digest` in constant time. Reject any algorithm value you do not recognise.

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

const ALGORITHMS = {
  HMAC_SHA1_HEX: 'sha1',
  HMAC_SHA256_HEX: 'sha256',
  HMAC_SHA512_HEX: 'sha512',
};

function verifySumsubDigest(rawBody, digestHeader, algHeader, secret) {
  const algorithm = ALGORITHMS[algHeader];
  if (!algorithm || !digestHeader || !secret) return false;

  const expected = crypto.createHmac(algorithm, secret).update(rawBody).digest('hex');

  const received = Buffer.from(digestHeader.trim().toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/sumsub', express.raw({ type: '*/*' }), (req, res) => {
  const ok = verifySumsubDigest(
    req.body,
    req.get('x-payload-digest'),
    req.get('x-payload-digest-alg'),
    process.env.SUMSUB_WEBHOOK_SECRET
  );
  // 400, not 401: Sumsub treats 401, 403 and 404 as permanent and never retries them.
  if (!ok) return res.sendStatus(400);

  const event = JSON.parse(req.body.toString('utf8'));
  // Route on event.type. Fetch applicant details from the API by event.applicantId.
  res.sendStatus(200);
});

```

Note the lowercase header names. Sumsub's own JavaScript example reads `req.headers['X-Payload-Digest-Alg']`, but Node lowercases incoming header names, so that lookup is always undefined and the example rejects every request as written.

The same check in Python:

```python
import hashlib
import hmac
import os

ALGORITHMS = {
    "HMAC_SHA1_HEX": hashlib.sha1,
    "HMAC_SHA256_HEX": hashlib.sha256,
    "HMAC_SHA512_HEX": hashlib.sha512,
}

def verify_sumsub_digest(raw_body: bytes, digest_header, alg_header, secret: str) -> bool:
    digestmod = ALGORITHMS.get(alg_header)
    if digestmod is None or not digest_header or not secret:
        return False
    expected = hmac.new(secret.encode("utf-8"), raw_body, digestmod).hexdigest()
    received = digest_header.strip().lower().encode("utf-8")
    return hmac.compare_digest(received, expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_sumsub_digest(raw_body, request.headers.get("x-payload-digest"),
#                      request.headers.get("x-payload-digest-alg"),
#                      os.environ["SUMSUB_WEBHOOK_SECRET"])

```

To check your code, use the secret `sumsub-test-secret-key` and this exact body:

```
{"applicantId":"5cb56e8e0a975a35f333cb83","type":"applicantReviewed","reviewStatus":"completed"}

```

With `HMAC_SHA256_HEX` the digest should be:

```
0b8697e5d4ae19997c0800de23eb88f31359601f80e11d93e717c61f6f2d408f

```

and with `HMAC_SHA1_HEX`:

```
15d91918152a3eaeb83e4685cf08e895b24b25a4

```

Sumsub also has a `testDigest` API endpoint that returns the digest it would compute for a body and key. Its `digestAlg` parameter defaults to `HMAC_SHA1_HEX`, so pass `HMAC_SHA256_HEX` explicitly when comparing against a SHA256 webhook.

In Hookdeck, paste the webhook's Secret key into the source. Hookdeck reads `X-Payload-Digest-Alg` and uses the algorithm it names. Sumsub says it always sends that header; if it is ever missing, Hookdeck accepts a valid SHA256 or SHA1 digest.

> **Make Sumsub webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies `x-payload-digest` with whichever algorithm the webhook uses, answers inside Sumsub's 10-second window, and queues every verification result until your service has processed it.

## Sumsub webhook limitations and pain points

### A `401` loses the delivery

**The Problem:** An auth error from your endpoint means that delivery is never sent again.

**Why It Happens:** For HTTP receivers, Sumsub treats `401`, `403` and `404` as permanent: "the webhook is marked as failed immediately and is not retried". Many frameworks answer `401` for a failed signature check by default, and a `404` from a bad deploy or a renamed route has the same effect. Sumsub also disables webhooks that keep failing.

**Workarounds:**

* Return `400` for a failed signature, and make sure no auth middleware on the route answers `401` or `403`.
* Keep the webhook route stable across deploys.
* Resend failed webhooks manually from the webhook logs once the cause is fixed.

**How Hookdeck Can Help:** Sumsub talks to Hookdeck rather than to your service, so status codes from your application, including a `401` from middleware or a `404` during a deploy, never reach Sumsub. Hookdeck keeps the event and retries it against your service.

### Retries are off unless you turn them on

**The Problem:** A brief outage drops verification results.

**Why It Happens:** Resends only happen if "Resend failed webhooks several times" is enabled on the webhook. When it is, Sumsub resends five times: 30 seconds, 30 seconds, 1 minute, 5 minutes and 5 minutes apart for `applicantActionPending`, `applicantActionReviewed` and `applicantActionOnHold`, and 1 minute, 5 minutes, 1 hour, 5 hours and 18 hours apart for everything else. The applicant action schedule is used up in about 12 minutes.

**Workarounds:**

* Turn the setting on for every webhook.
* Reconcile applicant status from the API after any downtime.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as soon as Sumsub sends it, so Sumsub's retry budget is not spent while your service is down. Hookdeck then retries on a schedule you control and lets you replay any event.

### Tight connection limits

**The Problem:** Slow endpoints are treated as failures even when they would have succeeded.

**Why It Happens:** Your endpoint must accept the connection within 1 second and respond within 10 seconds. A cold start, a slow TLS handshake or a synchronous API call to fetch applicant data can exceed either limit.

**Workarounds:**

* Verify, store the event and return `200` before fetching anything from Sumsub's API.
* Keep the receiver warm, or put it behind something that is always up.

**How Hookdeck Can Help:** Hookdeck accepts the connection and responds straight away, then delivers the event to your service as a separate step, so your service's response time no longer counts against Sumsub's limits.

### Several secrets for one endpoint

**The Problem:** One URL receiving several types has to know which secret to use.

**Why It Happens:** Each webhook covers one type and has its own Secret key. If ten types point at one URL, there are ten secrets.

**Workarounds:**

* Give each webhook its own path, or add a custom HTTP header that names it, and choose the secret from that.
* Or set the same Secret key yourself on every webhook that points at one endpoint.

**How Hookdeck Can Help:** Create a Hookdeck source per Sumsub webhook, each with its own secret, and route them all to one service that only needs to trust Hookdeck.

### No unique event ID

**The Problem:** Retries and resends repeat events, and nothing in the body identifies one delivery.

**Why It Happens:** No delivery ID header or event ID field is documented, and `correlationId` is shared across related events. Retries spaced hours apart can also arrive after later events for the same applicant.

**Workarounds:**

* Deduplicate on a key built from `type`, the resource ID (`applicantId`, `kytTxnId` or the case ID) and `createdAtMs`.
* Treat the API as the source of truth for current status, and ignore events older than the state you already hold.

**How Hookdeck Can Help:** Hookdeck's deduplication can drop repeated deliveries before they reach your service, based on the fields you choose.

## Best practices

### Verify against the raw body

Sumsub says to use the payload "without any alteration or conversion to JSON". Read the raw body with `express.raw()`, `Buffer.from(await request.arrayBuffer())` in a Next.js route handler, or `await request.body()` in FastAPI.

### Read the algorithm from every request

The algorithm can differ between webhooks, and existing webhooks may still use SHA1. Look it up per request and reject unknown values instead of assuming SHA256.

### Ignore test and sandbox traffic in production

Skip deliveries with `"testMode": true`, and check `sandboxMode` so Sandbox results never change production accounts.

### Fetch details from the API

Webhooks carry no personal data. Use `applicantId` to fetch the applicant and its review status, and act on what the API returns.

### Acknowledge fast and process asynchronously

Verify, persist, return `200`, and do the work afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks) and our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

Verifying a Sumsub webhook means choosing the hash from `X-Payload-Digest-Alg` on each request, not hardcoding one. The operational rules need as much attention: retries only happen when you enable them, `401`, `403` and `404` end a delivery for good, and the endpoint has one second to accept the connection.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every digest, and holds each verification result until your service has processed it, so a deploy or an outage does not cost you a KYC decision.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Sumsub webhooks reliably in minutes.