# Webhook payloads

> The JSON body of alert webhooks, the event types, and how to verify the signature

Source: https://dev.apitraffic.io/docs/alerts/webhook-payloads

A [webhook channel](https://dev.apitraffic.io/docs/alerts/channels#webhook) receives a `POST` with a JSON body for each incident event. Bodies are versioned: fields may be added within a version, never renamed or removed.

## Headers

| Header | Value |
|---|---|
| `content-type` | `application/json` |
| `webhook-id` | The notification's ID. The same on every retry, so you can drop duplicates. |
| `webhook-timestamp` | When this attempt was sent, in seconds since the epoch. |
| `webhook-signature` | `v1,`. During a secret rotation, two signatures separated by a space. |
| `apitraffic-trace-id` | Quote this to support to trace a delivery end to end. |

## Event types

| `type` | Sent when |
|---|---|
| `incident.triggered` | An incident opens. |
| `incident.escalated` | It gets more severe, or a later escalation step runs. |
| `incident.acknowledged` | Someone acknowledges it. |
| `incident.resolved` | It resolves. |
| `incident.reopened` | It fires again after resolving. |
| `incident.flapping` / `incident.stabilized` | An alert keeps changing state / settles down. |
| `incident.comment_added` | Someone comments. |
| `incident.reminder` | A critical incident is still unacknowledged ([reminders](https://dev.apitraffic.io/docs/alerts/routing#reminders)). |
| `incident.test` | **Send test** was clicked. |
| `alert.digest` | A [digest](https://dev.apitraffic.io/docs/alerts/routing#digests) is sent. |

`seq` increases with every event of an incident. If events arrive out of order, keep the one with the highest `seq`.

## Incident event body

```json
{
  "type": "incident.triggered",
  "version": 1,
  "id": "ntf_2xY…",
  "seq": 1,
  "occurredAt": "2026-10-20T10:00:00.000Z",
  "traceId": "8f0c…",
  "test": false,
  "account": { "sid": "acc_…" },
  "incident": {
    "sid": "inc_…",
    "number": 142,
    "reference": "INC-142",
    "title": "POST /v1/checkout is failing 18.4% of the time",
    "severity": "critical",
    "status": "open",
    "url": "https://app.apitraffic.io/account/alerts/incidents/inc_…",
    "bucketSid": "bkt_…",
    "environmentSid": "env_…",
    "openedAt": "2026-10-20T10:00:00.000Z",
    "onsetAt": "2026-10-20T09:55:00.000Z",
    "acknowledgedAt": null,
    "acknowledgedBy": null,
    "resolvedAt": null,
    "sources": ["attention"],
    "labels": { "signal_type": "error_hotspot", "endpoint": "POST /v1/checkout" }
  },
  "alerts": [
    {
      "fingerprint": "as_…",
      "source": "attention",
      "severity": "critical",
      "status": "firing",
      "title": "POST /v1/checkout is failing 18.4% of the time",
      "subject": { "type": "endpoint", "key": "POST /v1/checkout", "label": "POST /v1/checkout" },
      "metrics": [{ "name": "server_error_rate", "value": 18.4, "unit": "percent" }],
      "summary": "",
      "firstFiredAt": "2026-10-20T10:00:00.000Z",
      "resolvedAt": null
    }
  ],
  "alertCount": 1,
  "deploy": { "sid": "evt_…", "name": "v2.14.0", "commitSha": "a1b2c3d", "author": "sam" },
  "comment": null
}
```

- `deploy` is set when the incident started after a deploy marker; otherwise `null`.
- `comment` is the comment text on `incident.comment_added`; otherwise `null`.
- `alerts[].summary` is what the alert says about the problem and what to do (for a certificate alert: the hosts, the expiry date and the vendor to contact). It can be empty. Summaries of other alerts can name tag values, so they are sent only when the channel has **Include tag values** on.
- Tag values (such as customer IDs) in `labels` show as `[hidden]` unless the channel has **Include tag values** on.

## Digest body

```json
{
  "type": "alert.digest",
  "version": 1,
  "id": "ntf_…",
  "occurredAt": "2026-10-20T09:00:00.000Z",
  "traceId": "…",
  "test": false,
  "account": { "sid": "acc_…" },
  "digest": {
    "route": { "sid": "rt_…", "name": "Everything else" },
    "from": "2026-10-20T08:01:00.000Z",
    "to": "2026-10-20T08:52:00.000Z",
    "incidentCount": 2,
    "eventCount": 3,
    "open": 1
  },
  "incidents": [
    {
      "sid": "inc_…", "number": 141, "reference": "INC-141",
      "title": "GET /v1/orders/{id} is 140% slower", "severity": "warning", "status": "open",
      "url": "https://app.apitraffic.io/account/alerts/incidents/inc_…",
      "openedAt": "…", "resolvedAt": null,
      "events": [{ "type": "opened", "verb": "Triggered", "seq": 1, "at": "…" }]
    }
  ],
  "moreIncidents": 0
}
```

Each incident appears once, worst first, with its status when the digest was sent. At most 50 are listed; `moreIncidents` counts the rest. Digests never include tag values.

## Verifying the signature

Signatures follow [Standard Webhooks](https://www.standardwebhooks.com/). The signed content is `<webhook-id>.<webhook-timestamp>.`, signed with HMAC-SHA256. The key is your signing secret with the `whsec_` prefix removed, base64-decoded.

**Node.js**

```js
const crypto = require('crypto');

function verify(secret, headers, rawBody) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  // Reject old deliveries (replays): 5 minutes of tolerance.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = crypto.createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest('base64');
  return headers['webhook-signature'].split(' ').some((sig) => {
    const [, value] = sig.split(',');
    return value && value.length === expected.length
      && crypto.timingSafeEqual(Buffer.from(value), Buffer.from(expected));
  });
}
```

**Python**

```python
import base64, hashlib, hmac, time

def verify(secret, headers, raw_body: bytes) -> bool:
    msg_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]
    if abs(time.time() - int(timestamp)) > 300:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    return any(
        hmac.compare_digest(sig.split(",", 1)[1], expected)
        for sig in headers["webhook-signature"].split(" ") if "," in sig
    )
```

Verify against the **raw** request body, before parsing it as JSON. The Standard Webhooks libraries for most languages work as well.

## Responding

Answer with any `2xx` status within 10 seconds. Other answers are retried as described in [Retries](https://dev.apitraffic.io/docs/alerts/channels#retries); a `410 Gone` disables the channel.
