Docs / Alerts

Webhook payloads

Alerts 5 min read

The request Uptimeprobe sends to a webhook destination, how to verify its signature, and how to handle retries.

A webhook destination receives an HTTPS POST whenever a probe changes status. This page is the reference for what arrives, so you can route alerts into whatever you already run: a chat app, an on-call tool, a script that restarts something.

The request

Every notification is a POST with a JSON body:

{
    "id": "6f8d1c2e-4b9a-4f3e-9c21-8ad4f2b71e05",
    "type": "probe.status_changed",
    "occurredAt": "2026-07-26T04:11:09.000Z",
    "workspace": { "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6" },
    "probe": {
        "id": "9c1f0b6a-2e77-4c3d-bf25-1d0a7e4c9b31",
        "name": "API health",
        "type": "http",
        "target": "https://api.example.com/health",
        "region": "Seattle, USA"
    },
    "fromStatus": "up",
    "toStatus": "down",
    "detectedAt": "2026-07-26T04:11:09.000Z",
    "lastCheckedAt": "2026-07-26T04:10:52.000Z"
}
FieldTypeWhat it is
idstringThe event’s id. Stable across retries of the same event, so it’s the right key to deduplicate on
typestring"probe.status_changed". New event types may be added later, so switch on this rather than assuming
occurredAtstringWhen the change happened, ISO 8601 UTC
workspace.idstringThe workspace the probe belongs to
probe.idstringThe probe’s id, stable for its lifetime
probe.namestringThe probe’s name as it was when the change was detected
probe.typestring"http", "domain", or "tcp"
probe.targetstringWhat’s being checked: a URL, a domain, or host:port
probe.regionstringThe region the check ran from
fromStatusstring or nullThe status before the change: "up", "degraded", or "down". Null on the first status ever recorded for a probe
toStatusstringThe status after the change
detectedAtstringWhen the change was detected, ISO 8601 UTC
lastCheckedAtstring or nullThe check that drove the change, ISO 8601 UTC

Field values are a snapshot from when the change was detected. Renaming a probe later doesn’t rewrite alerts that already went out.

Headers

HeaderValue
Content-Typeapplication/json
User-Agentuptimeprobe.dev-webhooks/1
X-Uptimeprobe-Event-IdSame as the body’s id. Repeated on every retry of that event
X-Uptimeprobe-Delivery-IdUnique per attempt. Quote it if you contact support
X-Uptimeprobe-TimestampUnix seconds, signed along with the body
X-Uptimeprobe-Signaturev1= followed by the signature, in lowercase hex

Verifying the signature

Anyone who learns your endpoint URL can POST to it. The signature is how you tell a real notification from a forged one, so verify it before acting on the payload.

The signed value is the timestamp, a full stop, then the raw request body:

v1=HMAC-SHA256(signing secret, "<X-Uptimeprobe-Timestamp>.<raw body>")

Sign the raw bytes you received. Parsing the JSON and re-serializing it changes whitespace and key order, and the signature won’t match.

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

// `rawBody` must be the unparsed request body, e.g. express.raw({ type: "application/json" }).
export const isFromUptimeprobe = (rawBody, headers, signingSecret) => {
    const timestamp = headers["x-uptimeprobe-timestamp"];
    const received = headers["x-uptimeprobe-signature"];
    if (timestamp === undefined || received === undefined) {
        return false;
    }

    // Reject anything older than five minutes, so a captured request can't be replayed.
    const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
    if (!Number.isFinite(ageSeconds) || ageSeconds > 300) {
        return false;
    }

    const mac = createHmac("sha256", signingSecret).update(`${timestamp}.${rawBody}`).digest("hex");
    const expected = Buffer.from(`v1=${mac}`);
    const actual = Buffer.from(received);
    return expected.length === actual.length && timingSafeEqual(expected, actual);
};
import hashlib, hmac, time

def is_from_uptimeprobe(raw_body: bytes, headers, signing_secret: str) -> bool:
    timestamp = headers.get("X-Uptimeprobe-Timestamp")
    received = headers.get("X-Uptimeprobe-Signature")
    if not timestamp or not received:
        return False

    # Reject anything older than five minutes, so a captured request can't be replayed.
    if abs(time.time() - int(timestamp)) > 300:
        return False

    mac = hmac.new(
        signing_secret.encode(),
        f"{timestamp}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(f"v1={mac}", received)

Compare in constant time, as both examples do. A plain string comparison leaks how much of the signature was correct.

The v1= prefix names the signature scheme. If we ever add a second scheme, it’ll carry a different prefix, so match on the one you expect rather than stripping whatever comes before the =.

Responding

Reply 2xx once you’ve accepted the notification. Anything else is treated as a failure and retried.

Answer quickly: if your endpoint takes more than about ten seconds, the attempt is recorded as a timeout and retried. Queue slow work rather than doing it before you reply.

Redirects aren’t followed. A 3xx is treated as a permanent failure, so point the destination at the final URL.

Retries and duplicates

A failed delivery is retried several times with a widening gap between attempts, over a few hours. Attempts that will never succeed (a 404, a rejected authentication) stop straight away instead. Either way, the outcome is in the destination’s delivery history.

Delivery is at-least-once, so your endpoint should tolerate seeing the same event twice. Deduplicate on id (or the X-Uptimeprobe-Event-Id header, which matches it) rather than on the delivery id, which is different for every attempt.

Two more things worth designing for:

  • Order isn’t guaranteed. If a probe flaps quickly, a recovery can arrive before the outage it followed. Use detectedAt to order events, not arrival time.
  • Very old events are dropped. If your endpoint is unreachable long enough, we’d rather drop a stale alert than page you about an outage that already ended.

Requirements for the endpoint

  • Reachable from the public internet over HTTPS. Private and internal addresses are rejected when you save the destination and re-checked before every send.
  • No credentials in the URL. Put anything secret in your own verification instead.
  • Up to 2048 characters.

Next steps

Add a destination and send it a test from Destinations, or see Alerts for which status changes get sent in the first place.