Signed webhook
The signed webhook is the connector for far ends of your own: an incident tool that accepts generic alert sources, an automation you run yourself, or a script. Chat destinations, PagerDuty and Opsgenie have connectors of their own; this page covers only the generic kind. Everything here describes what ships today.
Creating an endpoint
Section titled “Creating an endpoint”Under Integrations in the app, create a connector of the kind Webhook and give it a URL (owner and admin only). Perstat generates a signing secret and shows it exactly once; afterwards it is displayed masked and can only be rotated. Rotation replaces the secret immediately. There is no overlap period in which both are valid.
The secret has the form whsec_ followed by 64 lowercase hexadecimal characters. Treat
the whole string as an opaque key.
http:// URLs are accepted for local far ends; use https:// for anything that crosses
a network you do not own. The target must be publicly routable: a host that resolves to a
private, loopback or link-local address is refused at every send.
When a delivery goes out
Section titled “When a delivery goes out”What an endpoint receives depends on whether your organization has an active escalation chain: a plan with the on-call rotation (Sentinel and up, see plans and limits), on-call switched on, and the chain enabled.
Without an active chain (the default on every other plan, or with on-call or the chain switched off), every active endpoint receives the events it subscribes to under Integrations:
| Event | When |
|---|---|
incident.opened |
An incident opens, critical or degraded. An alarm held back by a dependency rule is delivered once it is released. |
incident.resolved |
The incident closes. |
incident.test |
You press Test on the connector. Delivered synchronously, to this endpoint only. |
With an active chain, the chain owns outgoing incident messages. An endpoint hears about an outage only when a step names it as a target, at minute 0 or later, and only for critical incidents:
| Event | When |
|---|---|
incident.escalation |
A step that names the endpoint fires. This is also the first message for a step at minute 0: the event says “this incident is unacknowledged”, not “a new one just opened”. |
incident.resolved |
The incident closes. It goes to every endpoint a step alerted, whether or not it subscribed, and to every active endpoint subscribed to it. What the chain opens, the chain closes. |
incident.test |
As above. |
With an active chain, the subscription to incident.opened has no effect. A step skips
an endpoint that is switched off, and it skips all endpoints while the incident is
acknowledged, while its monitor is disabled or archived, and while the monitor sits
inside a maintenance window. An endpoint switched off mid-incident still receives the
all-clear for an incident it was alerted about.
The request
Section titled “The request”One POST per event, with a JSON body:
POST <your URL>content-type: application/jsonuser-agent: perstat-webhook/1.0x-perstat-signature: sha256=<64 lowercase hex>{ "event": "incident.escalation", "incident": { "id": "inc_9f3c2a7e1b4d48c0a6e5f1d2c3b4a596", "severity": "critical", "cause": "HTTP 503", "summary": "API does not answer", "monitor": { "id": "mon_4b1e8d2c7a9f4e6b8c0d1e2f3a4b5c6d", "name": "API" }, "started_at_unix": 1757930400, "resolved_at_unix": null }, "organization": { "id": "org_2d7a9c4e1f3b4a5c8d6e7f8a9b0c1d2e", "name": "Acme" }}| Field | Meaning |
|---|---|
event |
One of the events above. |
incident.id |
Stable across all events of one incident. Use it to open and close the same case on your side; PagerDuty receives it as dedup_key, Opsgenie as alias. |
incident.severity |
critical or degraded. Through an active chain you only ever see critical; the test sends critical too. |
incident.cause |
Free text: the detail of the first failed check, or the cause entered on a manual incident. test on a test delivery. |
incident.summary |
Optional, null when empty. |
incident.monitor |
id and name of the linked monitor. null for a manual incident without a monitor and for the test. |
incident.started_at_unix |
Unix seconds. |
incident.resolved_at_unix |
Unix seconds, null until the incident is resolved. |
organization |
id and name of the organization that owns the endpoint. |
The body is stable and is extended only additively: parse what you need and ignore fields you do not know.
Verifying the signature
Section titled “Verifying the signature”The header carries sha256= followed by the HMAC-SHA256 of the raw request body,
keyed with the secret string exactly as it was shown to you: its ASCII bytes are the key,
it is not decoded. Compute the MAC over the bytes you received, before any JSON parsing,
and compare in constant time.
Node:
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, rawBody, header) { const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex"); return expected.length === header.length && timingSafeEqual(Buffer.from(expected), Buffer.from(header));}Python:
import hashlibimport hmac
def verify(secret: str, raw_body: bytes, header: str) -> bool: expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, header)A body that fails verification did not come from Perstat, or was altered on the way.
Answer it with 401 and do nothing else.
The signature carries no timestamp and no delivery ID. A captured request can
therefore be replayed and will verify again. Deliver over TLS, and treat a repeated
(incident.id, event) pair as already handled: for an incident tool, closing an alert
twice is harmless, opening it twice is noise.
Delivery rules
Section titled “Delivery rules”- A
2xxresponse counts as delivered. Anything else, including a3xx, counts as failed; redirects are not followed. - The timeout is 10 seconds per delivery. Answer quickly and do the work afterwards.
- One attempt per event. There is no retry queue; a failed delivery stays failed.
- The outcome of the last delivery, with the error text if there was one, is shown on the connector under Integrations and beside the step in the chain.
Because there is no retry, a receiver that is briefly down misses that event. If you need
the full picture rather than the last message, read the incident through the
API using incident.id.