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"
}
| Field | Type | What it is |
|---|---|---|
id | string | The event’s id. Stable across retries of the same event, so it’s the right key to deduplicate on |
type | string | "probe.status_changed". New event types may be added later, so switch on this rather than assuming |
occurredAt | string | When the change happened, ISO 8601 UTC |
workspace.id | string | The workspace the probe belongs to |
probe.id | string | The probe’s id, stable for its lifetime |
probe.name | string | The probe’s name as it was when the change was detected |
probe.type | string | "http", "domain", or "tcp" |
probe.target | string | What’s being checked: a URL, a domain, or host:port |
probe.region | string | The region the check ran from |
fromStatus | string or null | The status before the change: "up", "degraded", or "down". Null on the first status ever recorded for a probe |
toStatus | string | The status after the change |
detectedAt | string | When the change was detected, ISO 8601 UTC |
lastCheckedAt | string or null | The 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
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | uptimeprobe.dev-webhooks/1 |
X-Uptimeprobe-Event-Id | Same as the body’s id. Repeated on every retry of that event |
X-Uptimeprobe-Delivery-Id | Unique per attempt. Quote it if you contact support |
X-Uptimeprobe-Timestamp | Unix seconds, signed along with the body |
X-Uptimeprobe-Signature | v1= 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
detectedAtto 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.