Skip to content

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.

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.

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.

One POST per event, with a JSON body:

POST <your URL>
content-type: application/json
user-agent: perstat-webhook/1.0
x-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.

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 hashlib
import 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.

  • A 2xx response counts as delivered. Anything else, including a 3xx, 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.