Webhook payloads
The JSON body of alert webhooks, the event types, and how to verify the signature
A webhook channel 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). |
incident.test |
Send test was clicked. |
alert.digest |
A digest 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
{
"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
}
deployis set when the incident started after a deploy marker; otherwisenull.commentis the comment text onincident.comment_added; otherwisenull.- Tag values (such as customer IDs) in
labelsshow as[hidden]unless the channel has Include tag values on.
Digest body
{
"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. 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
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
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; a 410 Gone disables the channel.