Signed webhook
The signed webhook connects Perstat to receivers 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 dedicated connectors of their own.
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 (owners and admins). Perstat generates a signing secret and shows it exactly once. Afterwards it appears masked, and you can rotate it at any time. Rotation replaces the secret immediately and the old one stops verifying at once, so update your receiver in the same step.
With webhooks:write, the REST API creates, updates, tests, and deletes webhooks and
rotates their secrets, so your provisioning scripts can manage them too.
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 receivers. 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 receives outage messages for critical incidents, from the steps that name it as a target, at minute 0 or later:
| 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 receive 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 grows 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, and 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 covers the body alone, without a timestamp or a delivery ID, so a captured request
verifies again when it is replayed. Deliver over TLS, and treat a repeated
(incident.id, event) pair as already handled: for an incident tool, closing an alert twice is
harmless, and opening it twice is noise.
Delivery rules
Section titled “Delivery rules”- A
2xxresponse counts as delivered. Any other status, including a3xx, counts as failed, and redirects are not followed. - The timeout is 10 seconds per delivery. Answer quickly and do the work afterwards.
- Perstat delivers each event once.
- 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.
Keep the receiver available, and reconcile missed events over the REST API: read an
incident by its incident.id, or list the organization’s open incidents with incidents:read.
- Alerting and on-call: put the endpoint into an escalation chain.
- API: manage webhooks and read incidents with a scoped key.