# Webhook payloads

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

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

A [webhook channel](https://www.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://www.apitraffic.io/docs/alerts/routing#reminders)). |
| `incident.test` | **Send test** was clicked. |
| `alert.digest` | A [digest](https://www.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" }],
      "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`.
- 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://www.apitraffic.io/docs/alerts/channels#retries); a `410 Gone` disables the channel.
