Skip to content

Webhooks

Need an alert that actually wakes you up? Browser push and email both get silently dropped by Do Not Disturb. See the Reliable alerts guide — wire your webhook to Pushover (bypasses Do Not Disturb on iOS and Android) or ntfy on Cloudflare Workers.

A webhook recipient is an account-level destination: create it once, then attach it to any number of monitors — see Choose who gets alerts for the attach/detach semantics. Use POST /api/v1/recipients with type: "webhook":

Terminal window
curl -X POST https://api.uptimemonitoring.com/api/v1/recipients \
-H "Authorization: Bearer $UPTIMEMONITORING_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"ops-webhook","type":"webhook","config":{"url":"https://hooks.example.com/incident"}}'

Response:

{
"id": 42,
"name": "ops-webhook",
"type": "webhook",
"config": {"url": "https://hooks.example.com/incident"},
"default": false,
"status": "active",
"events": ["down", "up", "flapping"],
"monitor_ids": [],
"created_at": "2026-05-19T12:00:00Z",
"updated_at": "2026-05-19T12:00:00Z",
"secret": "whs_live_…"
}

The plaintext HMAC secret is returned only in this creation response. GET /api/v1/recipients/{id} (and every other read of this recipient) omits it — copy the secret now, since UptimeMonitoring won’t show it again.

Attach the recipient to a monitor with PUT /api/v1/monitors/{monitorID}/recipients (full-replacement semantics) or at creation time via create_monitor’s recipient_ids/recipients arguments — see Choose who gets alerts.

Rotating a webhook recipient’s secret. There’s no rotate endpoint — a webhook recipient’s secret is set once, at creation, and never changes. To rotate it, create a replacement recipient and move every attached monitor to it. Done carelessly, this leaves every monitor that was on the old recipient unalerted for however long it takes to reattach them by hand — and deleting the old recipient too soon can silently discard a real queued alert, since DELETE /api/v1/recipients/{recipientID} deletes that recipient’s own pending/retrying deliveries along with the attachment (see Detaching a recipient vs. deleting it). The delivery log can only tell you what was queued as of your last read — it can’t prove nothing is queued at the instant you delete. A paginated scan that finds no pending/retrying rows reflects that moment only; a state transition that fires between your last scan and the DELETE call still queues a delivery that deletion discards outright, and there’s no API to freeze new deliveries while you check. Neither procedure below is atomic end-to-end, but both keep every monitor attached to a live recipient throughout. Treat an empty scan as a floor, not a proof — keep the old recipient around for at least the longest retry delay (2 hours; see Retry policy) after its last queued delivery appeared resolved before deleting it, for margin against a delivery that queues in that gap. Pick a procedure based on what your receiver can verify at once — there is no single procedure that closes both gaps for every receiver.

Use this when your receiver can verify two secrets at once.

  1. Prepare your receiver before creating anything. You’re about to have two valid secrets during the overlap — the one already in use, and the new one. Update your receiver to check an incoming signature against both candidates, each with its own crypto.timingSafeEqual (or language equivalent) comparison, for the whole rotation.
  2. Read and save the recipient being rotated. GET /api/v1/recipients/{recipientID} for its name, default, and events, plus its monitor_ids. For each attached monitor, GET /api/v1/monitors/{monitorID}/recipients and save this recipient’s down_delay_s from the structured recipients array.
  3. Create the replacement with the same name, default, and events. Copy its new secret from the creation response and install it at the receiver alongside the still-valid original secret — both must verify successfully right now.
  4. Attach the replacement to every saved monitor with its saved down_delay_s, using the read-merge-write recipients form described in Choose who gets alerts — read each monitor’s current recipients array first and submit the complete set, so no other recipient already on that monitor gets dropped. Note the wall-clock time this attach completes; step 7 needs it.
  5. Verify delivery reaches the replacement using POST /api/v1/recipients/{recipientID}/test against the replacement, or by triggering a controlled down/up transition on one affected monitor. Don’t wait for the next scheduled check as “verification” — a healthy monitor’s next check doesn’t by itself produce a delivery; webhooks fire only on a qualifying state transition or an explicit test send.
  6. Re-verify attachments before waiting out deliveries. Re-read every affected monitor’s own recipients array directly (not just the rotated recipient’s monitor_ids) and confirm the replacement is attached with the original’s down_delay_s on each one — retry the attach step for any monitor where it’s missing or has drifted. Separately, re-read the rotated recipient’s current monitor_ids, in case a monitor was created mid-rotation and auto-attached a default recipient that was never in your step 2 snapshot — attach the replacement there too, and add its id to the set of monitors you check in step 7: that set is the union of the step 2 snapshot and every id discovered here, never just the snapshot alone.
  7. Wait out the old recipient’s queued deliveries, then delete it. For every monitor in that union set — the step 2 snapshot plus every id discovered in step 6 — call GET /api/v1/webhook-deliveries?monitor_id={monitorID}&status=pending&limit=100, then the same with status=retrying. webhook-deliveries has no recipient-id filter, so read total from each response and keep increasing offset by the page size, re-querying until you’ve walked every page for that monitor and status — a single-page read can leave an older row for the old recipient unseen behind newer rows for other recipients on the same monitor. Across every page, look for rows whose webhook_id equals the old recipient’s id and whose created_at is before the attach time that applies to that monitor (step 4’s time for the original snapshot, step 6’s for anything discovered there). If any show up, wait and re-query — the retry schedule’s longest gap is 2 hours, so a row can take that long to resolve — repeating the full paginated scan each time, until a complete scan of every page finds none of them still pending/retrying (each has moved to delivered, failed, superseded, or cancelled). Immediately before deleting, re-read the rotated recipient’s monitor_ids once more; if anything new appeared since step 6, attach the replacement there too and wait out its deliveries with the same full paginated scan before continuing. This scan proves nothing was queued as of the last page read — it cannot prove nothing is queued at the instant the next step’s DELETE runs. Anything still queued at that instant is lost outright, not delivered late; for margin against that gap, don’t delete immediately on a clean scan — wait at least the 2-hour retry window past it first.
  8. Only now delete the rotated recipient (DELETE /api/v1/recipients/{recipientID}), and retire its secret at the receiver.

Between step 4 and step 8, both the rotated recipient and its replacement are attached, so any alert that fires in that window delivers twice; that’s the deliberate trade against a gap where nothing is attached.

Use this when your receiver can only hold one secret at a time — there is no gap-free path through the dual-secret overlap above, so take a short, explicit downtime window instead. Even here, never replace the old recipient on a monitor before the replacement already covers it: the old recipient stays attached, with its queued deliveries given time to resolve, until after the swap — the window in this path is at the receiver’s secret, not at the recipient attachment.

  1. Read and save the recipient being rotated — the same information as dual-secret step 2.
  2. Create the replacement with the same name, default, and events. Hold its new secret from the creation response; don’t install it at the receiver yet — the old secret needs to keep working through step 4.
  3. Attach the replacement alongside the old recipient on every saved monitor, one PUT per monitor, using the read-merge-write recipients form: submit the full current set with the replacement added (its own down_delay_s) and the old recipient still present (its saved down_delay_s) — this step never removes the old recipient. Note the wall-clock time the last of these PUTs completes; step 4 needs it.
  4. Re-verify attachments, then wait out the old recipient’s queued deliveries while its secret is still installed and the receiver is still running it — the same checks as dual-secret steps 6 and 7 (including their union-set coverage of any monitor discovered mid-rotation), substituting the attach time noted in step 3 for the attach time. Don’t proceed until every old-only queued delivery has resolved (delivered, failed, superseded, or cancelled) — and remember that scan is a snapshot, not a guarantee: it can’t see a delivery that queues after the last page was read.
  5. Take the receiver offline (stop the process, or block its route), swap its secret — remove the old one, install the new one — and bring it back online. Do this immediately after step 4’s scan comes back clean, to minimize the gap in which a new delivery could queue for the old recipient and be lost; step 4 cannot guarantee that gap is empty, only that it’s short.
  6. Verify delivery reaches the replacement using POST /api/v1/recipients/{recipientID}/test, or a controlled down/up transition — same caveat as dual-secret step 5.
  7. Only now detach the old recipient from every saved monitor (read-merge-write PUT, removing just the old id) and delete it (DELETE /api/v1/recipients/{recipientID}), and retire its secret at the receiver.

Between step 3 and step 5, both recipients are attached: the old recipient keeps delivering normally since its secret is still installed, while a delivery to the replacement fails signature verification at the receiver until step 5 switches the secret — that failure retries on the normal backoff and resolves once the secret is switched. That’s the explicit cost of this path instead of the dual-secret overlap’s double delivery. Unlike a swap that replaces old for new in one step, the old recipient here is never detached before the replacement already covers every monitor, so a crash at any point before step 7 still leaves every monitor attached to at least one recipient whose deliveries actually go through.

Both procedures assume no other writer changes any affected monitor’s recipient attachments for the entire duration, not only during monitor creation — nothing in the API enforces that assumption, so each procedure’s re-verify step is a mitigation against a concurrent change, not a guarantee against one. No step in either procedure is atomic with any other: a crash partway through can leave some monitors on the old recipient and some on the new one, so re-check every affected monitor’s attached recipients afterward if a step fails.

Legacy monitor-scoped webhook endpoint (deprecated)

Section titled “Legacy monitor-scoped webhook endpoint (deprecated)”

Before account-level recipients existed, each monitor had exactly one webhook slot set directly on the monitor. PUT/GET/DELETE /api/v1/monitors/{monitorID}/webhook still work today as a deprecated alias implemented on top of the recipient model above, but two things differ from an account-level webhook recipient:

  • One webhook per monitor, not reusable across monitors. This alias can’t attach the same webhook destination to a second monitor — use the account-level recipient endpoint above for that.
  • The secret is readable on every GET, not shown once. Unlike the account-level recipient’s create-only secret, GET /api/v1/monitors/{monitorID}/webhook returns the plaintext secret every time it’s called — treat this endpoint as a standing credential surface (anyone who can authenticate to the account with at least member access can recover the signing key by calling GET).

Terminal window
curl -X PUT https://api.uptimemonitoring.com/api/v1/monitors/1287/webhook \
-H "Authorization: Bearer $UPTIMEMONITORING_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://hooks.example.com/incident"}'

Response:

{
"id": 42,
"monitor_id": 1287,
"url": "https://hooks.example.com/incident",
"secret": "whs_live_…",
"created_at": "2026-05-19T12:00:00Z"
}

Replacing the URL on an existing webhook keeps the existing secret. To rotate the secret, DELETE the webhook then PUT it again — but this alias has exactly one slot per monitor and no read-merge-write equivalent to the account-level recipient’s rotation procedure above: deleting it leaves the monitor with no webhook at all until the new PUT succeeds. A GET beforehand can capture the existing url and secret — unlike the account-level recipient’s create-only secret, this alias’s GET returns the plaintext secret every time (see below). But that saved secret doesn’t help you recover: recreating the webhook with PUT always issues a brand-new secret, never the one you saved, so the receiver still needs its signing secret updated either way, and if the new PUT fails partway through there is no atomic rollback to the monitor’s previous working webhook. For a new setup, use the account-level recipient above instead — it’s reusable across monitors and its rotation procedure keeps a live, verified recipient attached throughout instead of leaving the monitor with no webhook at all mid-rotation.

Terminal window
# Read the current webhook for a monitor (returns the plaintext secret too, every time)
curl https://api.uptimemonitoring.com/api/v1/monitors/1287/webhook \
-H "Authorization: Bearer $UPTIMEMONITORING_API_KEY"
# Remove the webhook (monitor stays; just no notifications)
curl -X DELETE https://api.uptimemonitoring.com/api/v1/monitors/1287/webhook \
-H "Authorization: Bearer $UPTIMEMONITORING_API_KEY"
{
"event": "monitor.down",
"monitor_id": 30,
"monitor_name": "myapp-healthz",
"monitor_url": "https://example.com/health",
"account_id": 1,
"occurred_at": "2026-04-12T14:23:11Z",
"reason": "http_5xx",
"delivery_id": 3,
"attempt": 1
}

Events: monitor.down, monitor.up, monitor.flapping.

Field Type Description
event string One of monitor.down, monitor.up, monitor.flapping.
monitor_id integer ID of the monitor that changed state.
monitor_name string Display name of the monitor.
monitor_url string | omitted The URL being monitored. Omitted if the monitor has no URL.
account_id integer Account that owns the monitor.
occurred_at RFC3339 string When the state transition was detected.
reason string | omitted Error class for monitor.down (e.g. http_5xx, timeout), flap reason for monitor.flapping, empty/omitted for monitor.up.
delivery_id integer Stable per logical state transition. Use this field to deduplicate retried deliveries — the same transition always carries the same delivery_id.
attempt integer (1–5) Which delivery attempt this is (1 = first try).

Every webhook delivery includes an X-UptimeMonitoring-Signature header containing an HMAC-SHA256 hex digest. To verify it, compute the HMAC over the raw request bytes (not a re-serialized parse), then compare with a constant-time function. Using a regular string comparison (=== / !==) leaks timing information that lets an attacker recover the expected signature byte-by-byte.

During a secret rotation, your receiver must accept either of two candidate secrets at once — the one already in use and the new one — each checked with its own constant-time comparison, for the whole overlap. The examples below check a list of one or two candidate secrets rather than a single fixed value, so they work unchanged during a rotation. A receiver that can only hold one secret can’t take the gap-free rotation path; it needs a documented downtime window instead.

const crypto = require('crypto');
const express = require('express');
const app = express();
// Parsed once at startup, never per-request. A blank or trailing-comma
// entry would otherwise add an empty-string HMAC key, and an attacker who
// knows that can compute a valid signature against it — so a config typo
// must abort startup instead of silently accepting forged requests.
function loadCandidateSecrets() {
const raw = process.env.UPTIMEMONITORING_WEBHOOK_SECRETS || '';
const candidates = raw.split(',').map((s) => s.trim());
if (candidates.length > 2 || candidates.some((s) => s.length === 0)) {
throw new Error(
'UPTIMEMONITORING_WEBHOOK_SECRETS must be one or two nonempty, comma-separated secrets (no empty entries, no trailing comma)',
);
}
return candidates;
}
const CANDIDATE_SECRETS = loadCandidateSecrets();
// Register this route BEFORE any global express.json() or body-parser middleware.
// Earlier middleware consumes the stream first; express.raw() must run here, not after.
app.post(
'/webhooks/uptimemonitoring',
express.raw({ type: 'application/json' }),
(req, res) => {
if (!Buffer.isBuffer(req.body)) {
return res.status(400).send('Expected raw body — check middleware order');
}
const sig = req.header('X-UptimeMonitoring-Signature') || '';
if (!/^[a-f0-9]{64}$/i.test(sig)) {
return res.status(401).send('Invalid signature');
}
const a = Buffer.from(sig, 'hex');
const matches = CANDIDATE_SECRETS.some((secret) => {
const expected = crypto.createHmac('sha256', secret).update(req.body).digest('hex');
const b = Buffer.from(expected, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
if (!matches) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
// …handle event.event === 'monitor.down' / 'monitor.up' / 'monitor.flapping'…
res.status(204).end();
},
);
app.listen(3000);
import hmac
import hashlib
import os
from flask import Flask, request, abort
app = Flask(__name__)
def load_candidate_secrets():
# Parsed once at startup, never per-request. A blank or trailing-comma
# entry would otherwise add an empty-string HMAC key, and an attacker
# who knows that can compute a valid signature against it — so a
# config typo must abort startup instead of silently accepting forged
# requests.
raw = os.environ.get("UPTIMEMONITORING_WEBHOOK_SECRETS", "")
candidates = [s.strip() for s in raw.split(",")]
if len(candidates) > 2 or any(not s for s in candidates):
raise RuntimeError(
"UPTIMEMONITORING_WEBHOOK_SECRETS must be one or two nonempty, "
"comma-separated secrets (no empty entries, no trailing comma)"
)
return [s.encode() for s in candidates]
# One candidate secret in steady state; two during a rotation overlap.
CANDIDATE_SECRETS = load_candidate_secrets()
@app.post("/webhooks/uptimemonitoring")
def receive():
raw = request.get_data() # bytes, before any parsing
sig = request.headers.get("X-UptimeMonitoring-Signature", "")
matches = any(
hmac.compare_digest(sig, hmac.new(secret, raw, hashlib.sha256).hexdigest())
for secret in CANDIDATE_SECRETS
)
if not matches:
abort(401)
# event = json.loads(raw)
return "", 204
if __name__ == "__main__":
app.run(port=3000)

Background: OWASP — Observable Timing Discrepancy (CWE-208).

Failed deliveries (non-2xx response or timeout) are retried with exponential backoff:

Attempt Delay
1 Immediate
2 1 minute
3 5 minutes
4 30 minutes
5 2 hours

After 5 failed attempts, the delivery is marked as failed.

The delivery log is exposed at the account scope, not per-webhook. Filter with monitor_id if you only want one monitor’s deliveries:

Terminal window
curl "https://api.uptimemonitoring.com/api/v1/webhook-deliveries?monitor_id=1287&limit=20" \
-H "Authorization: Bearer $UPTIMEMONITORING_API_KEY"
Query Type Description
monitor_id integer Restrict to deliveries for one monitor.
since RFC3339 datetime Only deliveries dispatched at or after this time.
status string Filter by delivery status: pending, retrying, delivered, failed, superseded, or cancelled.
limit integer (1–100) Page size. Default 50.
offset integer Skip the first N results.

Response shape:

{
"deliveries": [
{
"id": 9001,
"webhook_id": 42,
"monitor_id": 1287,
"account_id": 14,
"event_type": "monitor.down",
"status": "delivered",
"attempt_count": 1,
"last_response_code": 200,
"last_attempt_at": "2026-05-07T09:14:05Z",
"next_attempt_at": "2026-05-07T09:14:05Z",
"created_at": "2026-05-07T09:14:02Z"
}
],
"total": 1,
"limit": 50,
"offset": 0
}

Each delivery record includes: id, webhook_id (a recipient id — the field name is kept for wire compatibility, but it identifies a webhook, email, Slack, or PagerDuty recipient, whichever type the delivery was for), monitor_id, event_type, status (pending|retrying|delivered|failed|superseded|cancelled), attempt_count, last_response_code, last_attempt_at, next_attempt_at, cancelled_at (set only when status is cancelled), and created_at. A superseded row means an older queued delivery for the same recipient+monitor pair was replaced by a newer one; a cancelled row means a delayed down delivery’s outage recovered before its down_delay_s elapsed, or its recipient’s events filter narrowed to drop down while the row was still queued — either way, pending/retrying is where delivery guarantees apply, not these two. There is no per-webhook delivery endpoint — GET /api/v1/webhooks/{id}/deliveries does not exist.