Skip to main content
Silent Outage

Start free

Outbound webhooks

What we post to a URL of yours when an incident opens, is acknowledged or closes, and how to check the signature on it.

Signed outbound webhooks, on an incident being triggered, acknowledged or resolved.

Everything on this page is a rule the product enforces rather than a description of how it happens to behave today.

What you get

A webhook channel is a free-tier alert destination alongside email, Slack and Discord. When an incident is triggered, acknowledged or resolved, Silent Outage POSTs one signed JSON body to your endpoint.

MethodPOST
Content typeapplication/json
Eventsincident.triggered, incident.acknowledged, incident.resolved
SignatureSilentOutage-Signature: t=<unix-seconds>,v1=<hex>
Delivery idSilentOutage-Delivery: <dedup key> — also in the body
Protocol versionSilentOutage-Protocol-Version: 2026-08-20
Successany 2xx. A 3xx is not a success; redirects are not followed
Retriesup to 6 attempts per alert — immediately, then 10s, 30s, 1m, 2m, 5m (plus jitter)

Everything Silent Outage needs from you is a 2xx. Answer it before you do any work: an endpoint that takes fifteen seconds to answer is an endpoint whose alerts get retried.

Adding an endpoint

Adding one gives you a signing secret (whsec_rg_…), shown once and never again. Store it the way you store any other secret. Losing it means deleting the channel and adding it again, which means redeploying your handler with the new secret — there is no rotate-in-place, because a secret you can read back from a screen is a secret that ends up in a screenshot.

The endpoint starts inert. Nothing is sent to it until it has proved it is listening, and it proves that by answering one challenge:

  1. Silent Outage POSTs a signed body with "event": "endpoint.verification" and a challenge string.
  2. Your endpoint answers 2xx with that challenge as the response body — either the bare string, or {"challenge":"…"}.
  3. The channel becomes verified, and alerts start.

The challenge body carries no incident and no check name. An endpoint that has not proved it is listening is not told what you monitor.

Verifying the signature

The scheme is deliberately the same one Stripe uses, because you probably already have a handler that implements it. It is HMAC-SHA256 over ` ${timestamp}.${rawBody} `, hex-encoded.

SilentOutage-Signature: t=1755648000,v1=6f3a…c1

To verify:

  1. Split the header on , and read t and every v1.
  2. Reject if |now - t| is more than 300 seconds. This is what makes a body captured off the wire un-replayable, so do not skip it.
  3. Compute HMAC-SHA256(secret, "<t>.<raw body bytes>"), hex.
  4. Compare it against each v1 in constant time. Any match is a pass.

Use the raw request body, exactly as it arrived. Re-serializing a parsed body changes key order and whitespace, and every legitimate delivery will fail.

More than one v1 may appear. That is a secret roll in progress; matching either is correct.

Node

import { createHmac, timingSafeEqual } from 'node:crypto';

export function verify(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.trim().split('=', 2)));
  const t = Number(parts.t);
  if (!Number.isFinite(t)) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;

  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
  const provided = Buffer.from(parts.v1 ?? '', 'hex');
  return provided.length === expected.length && timingSafeEqual(provided, expected);
}

Python

import hashlib, hmac, time

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.strip().split("=", 1) for p in header.split(","))
    try:
        t = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(
        secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

Making retries safe

Delivery is at-least-once. A network blip, a 500 from your side, or a slow answer all produce a retry, and a retry carries the same body and the same signature timestamp as the attempt before it.

Every payload carries dedup_key — inside the signed body, not only in the header. It is stable for one incident, one channel and one state:

<incident id>:webhook:triggered

A repeat page about a still-open incident — those are rate-limited, so you will not get one per tick — appends #1, #2 and so on, so a genuine second page is a different key and a redelivery is not.

Deduplicate on `dedup_key`, from the body. The header is a convenience for routing; a value read off an unsigned header is a value a replay can rewrite.

The payload

{
  "protocol_version": "2026-08-20",
  "event": "incident.triggered",
  "dedup_key": "8f2c…:webhook:triggered",
  "incident_id": "8f2c…",
  "state": "triggered",
  "severity": "major",
  "check": { "name": "stripe-revenue", "type": "revenue" },
  "project": { "name": "acme" },
  "detected_at": "2026-08-20T14:02:00.000Z",
  "last_good_at": "2026-08-20T13:26:00.000Z",
  "dollar_loss": {
    "low_cents": 4000,
    "high_cents": 9000,
    "currency": "usd",
    "confidence": "medium",
    "basis": "your typical Tuesday 14:00–15:00 revenue"
  },
  "below_detection_floor": false,
  "attribution": {
    "verdict": "likely_provider",
    "evidence": ["Stripe status: degraded since 14:01"]
  },
  "evidence": [
    { "label": "last payment event", "value": "2026-08-20T13:26:00.000Z", "signal": "last_ping" },
    { "label": "webhook gap", "value": "36m 0s", "signal": "webhook_gap" }
  ],
  "incident_url": "https://app.example.test/projects/c17da073…/incidents/8f2c…",
  "runbook_hint": "Check Stripe first: …",
  "triage_summary_advisory": null
}

Field notes that are rules rather than description:

  • `dollar_loss` is `null` on everything that is not a revenue outage. It is never 0. When volume is under the detection floor it is null and below_detection_floor is true — which means "we cannot estimate", not "nothing was lost".
  • `dollar_loss` is always a range with a confidence label. There is no point-estimate field.
  • `incident_url` is project-scoped, because an incident's screen lives under its project. Use the field as it arrives rather than reconstructing it from incident_id alone; a path without the project in it is not a page.
  • `attribution.verdict` is one of likely_you, likely_provider, inconclusive. inconclusive is a normal, expected answer.
  • `triage_summary_advisory` is a model's prose, is often null, and is not a diagnosis. The field name carries the word advisory for that reason; do not act on it automatically.
  • `state` is `ack`, matching the REST API, while event is incident.acknowledged. They are the same thing.
  • protocol_version changes only when the shape breaks. Unknown fields may be added at any time — ignore what you do not recognise.

What we will not send to

Refused when the channel is added, and again before every send:

  • anything that is not https
  • loopback, RFC1918, link-local, .local/.internal hosts, and single-label hostnames
  • a URL carrying credentials (https://user:pass@…)

Redirects are not followed. If your endpoint moved, change the channel.

When it does not arrive

Every attempt leaves a receipt, and the receipts are on the incident's Alert deliveries screen in the dashboard: the attempt number, when it was made, the outcome, how long you took to answer, and the status or error line we got back. An alert that runs out of attempts is recorded as abandoned — a non-delivery we admit to, counted against Silent Outage's own alert-path availability figure. It is never silently dropped.

A receipt records that we sent and never what we sent. There is no copy of the payload in the database.

Outbound webhooks · Silent Outage