> ## Documentation Index
> Fetch the complete documentation index at: https://docs.didit.me/llms.txt
> Use this file to discover all available pages before exploring further.

# Alert webhooks

> Send your team's Didit alerts to your own HTTPS endpoint: reviews, AML monitoring hits, billing, failing webhooks and service status, signed with HMAC-SHA256 and retried for 24 hours.



Alert Webhook Prompt
        Copy prompt to clipboard



      
      

\<pre>

\`Add an endpoint that receives Didit alert webhooks (team alerts, not verification results).

\## What to build
A public HTTPS POST endpoint (e.g. /api/didit/alerts) that:
1\. Reads the raw request body as bytes (do NOT parse and re-serialize the JSON before verifying)
2\. Checks that X-Timestamp is within 300 seconds of now
3\. Recomputes X-Signature-V2 = hex(HMAC-SHA256(secret, X-Timestamp + "." + rawBody)), where secret is the FULL signing secret including its "whsec_" prefix
4\. Compares the two signatures in constant time
5\. Parses the JSON, ignores an alert_id it already handled, answers 2xx within 5 seconds, then processes the alert asynchronously
6\. Answers a failed check with 400, not 401 (401, 403, 404 and 410 mark the destination for reconnection)

\## Headers
\- Content-Type: application/json
\- User-Agent: Didit-Alerts/1.0
\- X-Timestamp: Unix seconds, new on every attempt
\- X-Signature-V2: hex HMAC-SHA256 described above
This is NOT the signature scheme of verification webhooks. Alert webhooks send no X-Signature and no X-Signature-Simple.

\## Body (compact JSON, keys sorted)
\{
"alert_id": "alt_<16 hex characters>",  // the same on every retry: deduplicate on it
"created_at": 1791884460,                // Unix seconds when Didit created the alert
"data": { "event": "session_in_review", "label": "Session", "value": "#1042" },
"organization": { "id": "<uuid>", "name": "<your organization>" },
"severity": "info" | "warning" | "critical",
"subtitle": "<English explanation, can be empty>",
"title": "<English headline>",
"url": "<link to the Didit console>",
"webhook_type": "alert.<type>"
}

\## webhook_type values
\- alert.verification.in_review, alert.business_verification.in_review
\- alert.transaction.held, alert.aml.monitoring_hit
\- alert.billing.low_balance, alert.billing.autorefill_failed (data.paused), alert.billing.backup_card_charged
\- alert.webhook.delivery_failing
\- alert.service.degraded (data.status "degraded" or "down"), alert.service.recovered (data.status "recovered")
\- alert.test (sent from the console's Send test button)
Ignore webhook_type values and data keys you do not recognize.

\## Retries
\- 2xx = delivered. Redirects are not followed.
\- Timeout (5 s), connection failure, 5xx, 408, 425 or 429 = retried 5 times, after 1 min, 5 min, 30 min, 3 h and 20 h 24 min (about 24 h).
\- Any other response is final.
\- Test alerts are never retried.

\## Secret rotation
Rotating the secret in the console replaces it at once. There is no overlap and no header signed with the previous secret: deploy the new secret immediately.

Docs: /integration/alert-webhooks`

\</pre>



      
      

Paste into

Alert webhooks send your team's alerts to an HTTPS endpoint you own.
Use them to feed an on-call tool, a ticket queue or your own operations dashboard.Alert webhooks are separate from [verification webhooks](/integration/webhooks.md).
Verification webhooks send session, entity and transaction results to your application.
Alert webhooks tell your team that something needs attention: a verification waiting for review, a failed payment, a service incident.
They have their own payload, signing secret, signature and retry schedule.## Set up a destinationYou configure alert webhooks in the Business Console, not through the API.
Members who can manage organization settings can add, change and test destinations.**1. Add the destination**In **Organization settings** > **Team notifications**, add an **Alert webhook** destination with your endpoint's URL.
The URL must use `https://` and resolve to a public address.**2. Store the signing secret**Didit shows the full signing secret, which starts with `whsec_`, only once.
Store it now: afterwards the console shows only its last four characters.**3. Route alerts to it**Adding a destination does not send anything to it yet.
Add the destination to the routing rules of the alerts you want it to receive.**4. Send a test**Click **Send test** to send an `alert.test` alert, and check that your endpoint accepts it.An organization can have up to 25 destinations in **Team notifications**.Only live activity reaches a destination.
Alerts about sandbox verifications, business verifications, transactions and AML hits are never sent to it.## Alert types| `webhook_type` | `severity` | When Didit sends it |
| - | - | - |
| `alert.verification.in_review` | `warning` | A verification was completed and flagged for manual review. |
| `alert.business_verification.in_review` | `warning` | A business verification needs manual review. |
| `alert.transaction.held` | `warning` | Your transaction monitoring rules flagged a transaction for review. |
| `alert.aml.monitoring_hit` | `warning` | Ongoing AML monitoring found a new hit. |
| `alert.billing.low_balance` | `warning` | Your organization's balance dropped below the threshold you set. |
| `alert.billing.autorefill_failed` | `critical` | An automatic top-up of your balance failed because the card was declined. |
| `alert.billing.backup_card_charged` | `warning` | An automatic charge was declined on one card and paid with a backup card. |
| `alert.webhook.delivery_failing` | `warning` | Deliveries to one of your [verification webhook](/integration/webhooks.md) endpoints are failing. |
| `alert.service.degraded` | `critical` | A service your organization uses is degraded or down. |
| `alert.service.recovered` | `info` | That service is back to normal. |
| `alert.test` | `info` | Someone clicked **Send test** on the destination. |## PayloadEvery alert is a `POST` with a JSON body.
Didit sends the body as compact JSON with its keys sorted.
This example is formatted for reading:```json theme={null}
{
  "alert_id": "alt_9f2c4e1a7b3d5c80",
  "created_at": 1791884460,
  "data": {
    "event": "session_in_review",
    "label": "Session",
    "value": "#1042"
  },
  "organization": {
    "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "name": "Example Org"
  },
  "severity": "warning",
  "subtitle": "A KYC or KYB verification was completed and flagged for manual review.",
  "title": "Verification in review",
  "url": "https://business.didit.me/console/3fa85f64-5717-4562-b3fc-2c963f66afa6/9b2d1c4e-8f3a-4e6b-a1d2-5c7e9f0b3a14/verifications?session-id=1b4e28ba-2fa1-41d2-883f-0016d3cca427",
  "webhook_type": "alert.verification.in_review"
}
```| Field | Type | Description |
| - | - | - |
| `webhook_type` | string | `alert.` followed by the alert type. See [Alert types](#alert-types). |
| `alert_id` | string | `alt_` followed by 16 hexadecimal characters. Every retry of an alert carries the same `alert_id`, so use it to ignore duplicates. |
| `created_at` | integer | Unix seconds when Didit created the alert. It stays the same on retries. |
| `severity` | string | `info`, `warning` or `critical`. |
| `organization` | object | Your organization's `id` (UUID) and `name`. |
| `title` | string | Short English headline. |
| `subtitle` | string | English explanation. Can be empty. |
| `data` | object | What the alert is about. See below. |
| `url` | string | Link to the related page in the Didit console. |`data` always contains:- `event`: the event key, for example `session_in_review`.
- `label` and `value`: what the alert is about, for example `Session` and `#1042`.Some alert types add keys to `data`:- `alert.billing.autorefill_failed` adds `paused`, a boolean that is `true` when auto-refill is now paused until you update the card.
- `alert.service.degraded` and `alert.service.recovered` add `service`, `status`, `incident_id`, `service_name`, `state`, `affected`, `country_name`, `started_at`, `resolved_at`, `duration_minutes`, `title` and `body`.
  `status` is `degraded` or `down` on `alert.service.degraded`, and `recovered` on `alert.service.recovered`.
  `service` uses the service keys of [`GET /v3/status/`](/api-reference/service-status), and `incident_id` matches an incident `id` there.Alerts never include end-user personal data or document data.
They identify a session, transaction or other item by its number and link to it in the console.
Ignore `webhook_type` values and `data` keys you do not recognize, so new ones do not break your endpoint.## Request headers| Header | Example | Meaning |
| - | - | - |
| `Content-Type` | `application/json` | The body is JSON. |
| `User-Agent` | `Didit-Alerts/1.0` | Identifies Didit's alert sender. |
| `X-Timestamp` | `1791884462` | Unix seconds when Didit signed this attempt. New on every attempt. |
| `X-Signature-V2` | 64 hexadecimal characters | HMAC-SHA256 signature of this attempt. |## Verify the signature`X-Signature-V2` is the hex-encoded HMAC-SHA256 of the string `<X-Timestamp>.<raw body>`, keyed with your destination's signing secret.* Use the full secret exactly as Didit showed it, including the `whsec_` prefix.
* Sign the raw request bytes as you received them. Parsing the JSON and serializing it again can change the bytes and break the check.
* Compare the signatures in constant time.
* Reject a request whose `X-Timestamp` is more than 5 minutes away from your clock. Didit signs every attempt, including each retry, with a fresh timestamp, so this window never rejects a genuine retry.
* Answer a failed check with `400`. A `401`, `403`, `404` or `410` response marks the destination for reconnection (see [Retries](#retries)).> [!WARNING]
>
> Alert webhooks are not signed like verification webhooks.
> The header is also called `X-Signature-V2`, but the signed string is different, and alert webhooks send no `X-Signature` or `X-Signature-Simple`.
> Do not reuse your verification webhook verifier.### Node.js (Express)```javascript theme={null}
const express = require("express");
const crypto = require("crypto");

const app = express();
// The full secret Didit showed when you added the destination, including "whsec_".
const ALERT_SECRET = process.env.DIDIT_ALERT_SECRET;
const TOLERANCE_SECONDS = 300;

// express.raw keeps the body as the exact bytes Didit sent.
app.post("/api/didit/alerts", express.raw({ type: "application/json" }), (req, res) => {
  const timestamp = req.get("X-Timestamp") || "";
  const signature = Buffer.from(req.get("X-Signature-V2") || "", "utf8");

  const now = Math.floor(Date.now() / 1000);
  if (!/^\d+$/.test(timestamp) || Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) {
    return res.status(400).send("Stale or missing timestamp");
  }

  const expected = Buffer.from(
    crypto.createHmac("sha256", ALERT_SECRET).update(`${timestamp}.`).update(req.body).digest("hex"),
    "utf8",
  );
  if (signature.length !== expected.length || !crypto.timingSafeEqual(signature, expected)) {
    return res.status(400).send("Invalid signature");
  }

  const alert = JSON.parse(req.body.toString("utf8"));
  res.sendStatus(204); // answer within 5 seconds, then process
  handleAlert(alert); // skip an alert_id you already handled
});
```### Python (Flask)```python theme={null}
import hashlib
import hmac
import json
import os
import time

from flask import Flask, request

app = Flask(__name__)
# The full secret Didit showed when you added the destination, including "whsec_".
ALERT_SECRET = os.environ["DIDIT_ALERT_SECRET"].encode("utf-8")
TOLERANCE_SECONDS = 300


@app.post("/api/didit/alerts")
def didit_alert():
    raw_body = request.get_data()  # the exact bytes Didit sent
    timestamp = request.headers.get("X-Timestamp", "")
    signature = request.headers.get("X-Signature-V2", "")

    try:
        too_old = abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS
    except ValueError:
        too_old = True
    if too_old:
        return "Stale or missing timestamp", 400

    expected = hmac.new(ALERT_SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected.encode(), signature.encode()):
        return "Invalid signature", 400

    alert = json.loads(raw_body)
    handle_alert(alert)  # skip an alert_id you already handled; keep this fast
    return "", 204
```### Rotate the signing secretYou rotate the secret on the destination in **Team notifications**.
Didit shows the new secret once and signs every later attempt with it, including retries of alerts created before the rotation.
The old secret stops working at once.
There is no overlap period, and no header carries a signature made with the previous secret.
Deploy the new secret to your endpoint as soon as you rotate it.## RetriesDidit treats any `2xx` response as delivered.
Each attempt waits up to 5 seconds for your response and does not follow redirects.Didit retries an alert after a timeout, a connection failure, a `5xx` response, or a `408`, `425` or `429` response.
It retries up to 5 times, over about 24 hours:| Retry | Delay after the previous attempt |
| - | - |
| 1st | 1 minute |
| 2nd | 5 minutes |
| 3rd | 30 minutes |
| 4th | 3 hours |
| 5th | 20 hours 24 minutes |Every other response is final, including `3xx` and any other `4xx`.
A test sent with **Send test** gets a single attempt and is never retried.A `401`, `403`, `404` or `410` response also marks the destination for reconnection: the console shows it with a **Reconnect** status.
So does a URL that resolves to a private or otherwise blocked address when Didit sends.
Didit keeps sending new alerts to the destination, and it becomes active again after the next successful delivery or when you update its URL.**Recent notifications** in **Team notifications** lists each delivery with its result, and you can retry a failed delivery from there.## Related- [Verification webhooks](/integration/webhooks.md): session, entity and transaction results for your application.
- [Service status](/api-reference/service-status.md): the public feed behind `alert.service.degraded` and `alert.service.recovered`.
- [Manual review](/console/manual-review.md): what to do with a verification in review.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.