# 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_confirmed` | 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_uploaded` | A document got a new image | Trigger a document check |
| `document.updated.image_deleted` | 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 deliveries
* `type` - the event type
* `resourceType` - the kind of object in `payload`
* `payload` - 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](https://github.com/hookdeck/webhook-samples/blob/main/providers/complycube/latest/check.completed.clear.json). It is the one example payload ComplyCube publishes.

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

1. Provide the endpoint URL (HTTPS; plain HTTP is not accepted) and an optional description.
2. Choose the events to receive, for example `check.completed`, `client.created` and `workflow.session.completed`. ComplyCube recommends subscribing only to what you use rather than `["*"]`.
3. Make sure the webhook is enabled.
4. Copy the webhook's `secret`. It is **only returned once, at creation**. Store it as `WEBHOOK_SECRET` and 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](/docs/cli):

```bash
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](https://docs.complycube.com/documentation/integration-resources/webhooks).

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

```javascript
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](/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 `20x` within 15 seconds and do the work later.
* Monitor the webhook's `enabled` state 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 `updatedAt` with 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](/webhooks/guides/why-implement-asynchronous-processing-webhooks) and our [guide to webhook idempotency](/webhooks/guides/implement-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](https://hookdeck.com) verifies each ComplyCube delivery, absorbs retries and outages, and gives your compliance team a full record of every event received.

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