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:
{
"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:
createdAtMsis a UTC string in the formyyyy-MM-dd HH:mm:ss.fff, not an epoch number.correlationIdis 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:
- Name the webhook and choose HTTP address as the receiver.
- Enter the HTTPS Target URL.
- Choose the Type. You need one webhook per type you want to receive.
- Choose the Applicant types: individuals, companies or both.
- Keep the generated Secret key or enter your own, and pick the Signature algorithm (SHA256 by default, or SHA512).
- Turn on Resend failed webhooks several times. Without it, a failed delivery is not retried.
- Leave Rollout percentage at 100 unless you deliberately want only a share of events.
- 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.
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:
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 verifies
x-payload-digestwith 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
400for a failed signature, and make sure no auth middleware on the route answers401or403. - 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
200before 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,kytTxnIdor the case ID) andcreatedAtMs. - 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 and our guide to 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 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 for free and handle Sumsub webhooks reliably in minutes.