Guide to ComplyCube Webhooks: Features and Best Practices
ComplyCube runs identity verification and anti-money-laundering checks for regulated businesses: document and selfie checks, screening against sanctions and PEP lists, address verification and ongoing monitoring. Most checks finish seconds or minutes after the customer submits, and some need a human reviewer, so your onboarding flow can't simply wait for an API response. ComplyCube's webhooks tell your backend when a check completes and with what outcome, when a hosted workflow session moves on, and when client, document and address records change.
This guide covers ComplyCube's event types and their outcome variants, how to verify ComplyCube-Signature without falling into the trap in ComplyCube's own Node sample, and the retry limit that ends with a disabled webhook.
What are ComplyCube webhooks?
Each ComplyCube webhook is an HTTPS endpoint with a list of event types. When one of those events happens, ComplyCube sends an Event object to it. The event name is in the body's type field, resourceType says what kind of object is in payload, and id identifies the event so you can skip duplicates.
How much of the object you get depends on the event. A create event carries the full object, an update carries its ID and the changed fields, a delete carries only the ID, and check events carry the ID, status, outcome and timestamps. For anything more, fetch the object from the API.
Every delivery is signed with the endpoint's secret in ComplyCube-Signature, a hex HMAC-SHA256 of the body.
ComplyCube webhook features
| Feature | Details |
|---|---|
| Configuration | Web portal, or POST /webhooks |
| URL | HTTPS only |
| Subscription | Per endpoint, by event type. ["*"] subscribes to all |
| Signature | hex HMAC-SHA256 |
| Signature header | complycube-signature |
| Signed content | The request body |
| Secret | One per endpoint, returned once at creation |
| Body | JSON |
| Event routing | Body field type |
| Response window | A 20x within 15 seconds |
| Retries | Up to 10, with exponential backoff (60 s, 120 s, 480 s, ...) |
| After retries | The webhook is disabled |
| Ordering | Not guaranteed. Duplicates are possible |
Events and payloads
| Event | Triggered When | Common Use Cases |
|---|---|---|
workflow.session.started | A workflow session has been started | Track onboarding progress |
workflow.session.cancelled | A workflow session has been cancelled | Clean up pending onboarding |
workflow.session.processing | The customer finished all steps; checks are in progress | Show a "pending review" state |
workflow.session.completed | A workflow session has been completed | Approve or reject the customer |
workflow.session.updated | A workflow session outcome has been updated | Re-evaluate a decision |
check.pending | A check was created and is pending | Track check creation |
check.completed | A check completed with any outcome | Fetch the check and decide |
check.completed.clear | A check completed with a clear outcome | Approve the customer |
check.completed.attention | A check completed with an attention outcome | Route to manual review |
check.completed.rejected | A check completed with a rejected outcome | Decline the customer |
check.completed.match_ | A check completed with a match_confirmed outcome | Escalate a confirmed screening match |
check.monitoring.attention | A monitoring check completed with attention | Ongoing AML monitoring alerts |
check.failed | A check has failed | Retry or alert |
check.updated | A check has been updated | Sync state |
client.created | A client has been created | Sync customers |
client.updated | A client has been updated | Sync customers |
client.deleted | A client has been deleted | Sync customers |
document.created | A document has been created | Track uploads |
document.updated | A document has been updated | Track changes |
document.updated.image_ | A document got a new image | Trigger a document check |
document.updated.image_ | A document image was deleted | Track changes |
document.deleted | A document has been deleted | Sync state |
address.created | An address has been created | Track records |
address.updated | An address has been updated | Track records |
address.deleted | An address has been deleted | Track records |
A completed check fires check.completed and, if you subscribed to it, the outcome-specific type too. Subscribe only to the events you need.
Pick one style for checks. Either subscribe to check.completed and read payload.outcome, or subscribe to the outcome types you act on, such as check.completed.attention and check.completed.rejected. Subscribing to both means handling every completed check twice. workflow.session.updated matters if your compliance team overrides outcomes, because it reports a change to a decision you may already have acted on.
Event Payload Structure
id- unique event id; use it to skip duplicate deliveriestype- the event typeresourceType- the kind of object inpayloadpayload- the object (a check, client, document, address or workflow session)createdAt- when the event was created
Published payload example
This is the check.completed.clear sample from the webhook samples library. It is the one example payload ComplyCube publishes.
{
"id": "5ed170fe99c81a0007b9a3d3",
"type": "check.completed.clear",
"resourceType": "check",
"payload": {
"id": "5ed3918fbf06f30008e552c9",
"outcome": "clear",
"status": "complete",
"createdAt": "2020-01-01T11:14:23.964Z",
"updatedAt": "2020-01-01T11:14:23.964Z"
},
"createdAt": "2020-01-01T11:14:23.964Z"
}
The check event doesn't include the breakdown or the client. Fetch the check by payload.id to see why it came out the way it did.
Setting up ComplyCube webhooks
Webhooks can be created in the ComplyCube web portal or with the API (POST /webhooks).
- Provide the endpoint URL (HTTPS; plain HTTP is not accepted) and an optional description.
- Choose the events to receive, for example
check.completed,client.createdandworkflow.session.completed. ComplyCube recommends subscribing only to what you use rather than["*"]. - Make sure the webhook is enabled.
- Copy the webhook's
secret. It is only returned once, at creation. Store it asWEBHOOK_SECRETand treat it like a password. Each endpoint has its own secret.
You can change the subscribed events later in the portal or by updating the webhook through the API. If ComplyCube has disabled the webhook after failed retries, fix the endpoint and re-enable it in the portal or by updating it with enabled: true.
To test against ComplyCube's sandbox from your machine, forward deliveries with the Hookdeck CLI:
npx hookdeck-cli listen 3000 complycube --path /webhooks/complycube
Securing ComplyCube webhooks
Compute an HMAC-SHA256 of the body you received with the endpoint's secret, encode it as hex and compare it with ComplyCube-Signature in constant time. ComplyCube's doc pages don't name the encoding. Its official Node SDK decodes the header as hex. See the verification documentation.
const crypto = require('node:crypto');
function verifySignature(rawBody, headers, secret) {
if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
const signature = headers['complycube-signature'];
if (typeof signature !== 'string') return false;
const key = Buffer.from(secret, 'utf8');
const message = rawBody;
const candidates = [signature];
const expected = crypto.createHmac('sha256', key).update(message).digest();
return candidates.some((candidate) => {
if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
const received = Buffer.from(candidate, 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
The handler verifies against the raw bytes, skips event IDs it has seen, and records check outcomes for your onboarding flow. Everything else is stored for later processing.
const express = require('express');
function createComplyCubeApp({ seen, onboarding, store }) {
const app = express();
app.post('/webhooks/complycube', express.raw({ type: '*/*' }), async (req, res) => {
const secret = process.env.WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500);
if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
let event;
try {
event = JSON.parse(req.body.toString('utf8'));
} catch {
return res.sendStatus(400);
}
if (!event || typeof event !== 'object' || Array.isArray(event)) return res.sendStatus(400);
if (
!event.id ||
typeof event.type !== 'string' ||
!event.payload ||
typeof event.payload !== 'object'
) {
return res.sendStatus(400);
}
try {
if (await seen(event.id)) return res.sendStatus(200);
if (event.type === 'check.completed') {
await onboarding.recordCheck(event.payload.id, event.payload.outcome);
} else if (event.type === 'workflow.session.completed' || event.type === 'workflow.session.updated') {
await onboarding.recordSession(event.payload.id, event.payload);
} else {
await store(event);
}
} catch {
return res.sendStatus(503);
}
return res.sendStatus(200);
});
return app;
}
ComplyCube signs only the body and sends no timestamp header. Deduplicate on id to cover both retries and replays.
Hookdeck's ComplyCube source type verifies ComplyCube-Signature as a hex HMAC-SHA256 of the raw body with the endpoint's secret. Hookdeck always answers ComplyCube with 200, records a failed check as failed verification and does not forward that request. Hookdeck doesn't check any timestamp.
Make ComplyCube webhooks production-ready. Hookdeck Event Gateway answers ComplyCube inside its 15-second window, verifies each signature over the raw body, and keeps every check and workflow event so a compliance decision is never lost.
ComplyCube webhook limitations and pain points
The Node sample re-serialises the body
The Problem: Verification fails intermittently, or for some events and not others.
Why It Happens: ComplyCube's Node example parses the body with bodyParser.json() and then signs JSON.stringify(request.body). That only matches the original when ComplyCube's JSON happens to be compact and in the same key order. Its Python, PHP and .NET samples use the raw body.
Workarounds:
- Verify the raw bytes, as in the code above, and parse only afterwards.
- Make sure no JSON body parser runs on the webhook route first.
How Hookdeck Can Help: Hookdeck verifies against the exact bytes ComplyCube sent, so parsing on your side can't affect the check.
Ten retries, then the webhook is off
The Problem: Check results stop arriving and onboarding stalls.
Why It Happens: ComplyCube retries a failed delivery up to 10 times with exponential backoff, then disables the webhook. It stays disabled until someone re-enables it.
Workarounds:
- Reply
20xwithin 15 seconds and do the work later. - Monitor the webhook's
enabledstate through the API. - After re-enabling, list recent checks through the API to catch anything missed.
How Hookdeck Can Help: Hookdeck accepts each delivery as soon as it arrives, so failures in your own service don't use up ComplyCube's retries or get the webhook disabled.
A secret you see once
The Problem: You lose the secret, or need to rotate it, and can't read it again.
Why It Happens: The secret is only returned when the webhook is created.
Workarounds:
- Write it to your secret manager in the same step that creates the webhook.
- To rotate, create a new webhook, switch your receiver, then delete the old one.
How Hookdeck Can Help: Keep the secret on the Hookdeck source. During a rotation, run a second source for the new webhook and route both to the same destination.
Duplicates and reordering
The Problem: A rejection is overwritten by an earlier attention, or one check is processed twice.
Why It Happens: ComplyCube warns that duplicates are possible and order is not guaranteed, and a retry can arrive after newer events.
Workarounds:
- Deduplicate on the event
id. - Compare the check's
updatedAtwith what you stored, or fetch the check before acting.
How Hookdeck Can Help: Hookdeck's deduplication can drop repeated event IDs before they reach your service.
Best practices
Verify raw bytes, accept any content type
Read raw bytes: express.raw({ type: '*/*' }) in Express, request.arrayBuffer() in Next.js and await request.body() in FastAPI. Check the header is exactly 64 hex characters before decoding it, because Node's hex decoder stops at the first invalid character.
Choose generic or outcome events, not both
Decide whether your code reads outcome from check.completed or reacts to check.completed.*, and subscribe accordingly.
Treat attention as a human task
check.completed.attention and check.monitoring.attention usually mean a reviewer needs to look. Route them to a queue for your compliance team rather than auto-declining.
Keep personal data out of logs
ComplyCube events concern identity documents and screening results. Log the event id, type and object ID, not payloads.
Process asynchronously
Fifteen seconds is enough to verify and store, not to fetch a check and update several systems. See why to process webhooks asynchronously and our guide to webhook idempotency.
Conclusion
ComplyCube's webhooks drive onboarding decisions, so a missed or misread one has real cost. Verify the raw body rather than following the Node sample, pick one style of check event, deduplicate on id, and answer within 15 seconds so the webhook is never disabled.
Hookdeck Event Gateway verifies each ComplyCube delivery, absorbs retries and outages, and gives your compliance team a full record of every event received.
Get started with Hookdeck for free and handle ComplyCube webhooks reliably in minutes.