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.
Create an account-level webhook recipient
Section titled “Create an account-level webhook recipient”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":
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.
Dual-secret overlap
Section titled “Dual-secret overlap”Use this when your receiver can verify two secrets at once.
- 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. - Read and save the recipient being rotated.
GET /api/v1/recipients/{recipientID}for itsname,default, andevents, plus itsmonitor_ids. For each attached monitor,GET /api/v1/monitors/{monitorID}/recipientsand save this recipient’sdown_delay_sfrom the structuredrecipientsarray. - Create the replacement with the same
name,default, andevents. Copy its newsecretfrom the creation response and install it at the receiver alongside the still-valid original secret — both must verify successfully right now. - Attach the replacement to every saved monitor with its saved
down_delay_s, using the read-merge-writerecipientsform described in Choose who gets alerts — read each monitor’s currentrecipientsarray 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. - Verify delivery reaches the replacement using
POST /api/v1/recipients/{recipientID}/testagainst 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. - Re-verify attachments before waiting out deliveries. Re-read every affected monitor’s own
recipientsarray directly (not just the rotated recipient’smonitor_ids) and confirm the replacement is attached with the original’sdown_delay_son each one — retry the attach step for any monitor where it’s missing or has drifted. Separately, re-read the rotated recipient’s currentmonitor_ids, in case a monitor was created mid-rotation and auto-attached adefaultrecipient 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. - 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 withstatus=retrying.webhook-deliverieshas no recipient-id filter, so readtotalfrom each response and keep increasingoffsetby 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 whosewebhook_idequals the old recipient’s id and whosecreated_atis 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 stillpending/retrying(each has moved todelivered,failed,superseded, orcancelled). Immediately before deleting, re-read the rotated recipient’smonitor_idsonce 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’sDELETEruns. 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. - 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.
Downtime window
Section titled “Downtime window”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.
- Read and save the recipient being rotated — the same information as dual-secret step 2.
- Create the replacement with the same
name,default, andevents. Hold its newsecretfrom the creation response; don’t install it at the receiver yet — the old secret needs to keep working through step 4. - Attach the replacement alongside the old recipient on every saved monitor, one
PUTper monitor, using the read-merge-writerecipientsform: submit the full current set with the replacement added (its owndown_delay_s) and the old recipient still present (its saveddown_delay_s) — this step never removes the old recipient. Note the wall-clock time the last of thesePUTs completes; step 4 needs it. - 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, orcancelled) — and remember that scan is a snapshot, not a guarantee: it can’t see a delivery that queues after the last page was read. - 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.
- 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. - 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}/webhookreturns 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 callingGET).
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.
# 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"Payload format
Section titled “Payload format”{ "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). |
Signature verification
Section titled “Signature verification”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 hmacimport hashlibimport osfrom 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).
Retry policy
Section titled “Retry policy”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.
Delivery log account-wide
Section titled “Delivery log account-wide”The delivery log is exposed at the account scope, not per-webhook. Filter with monitor_id if you only want one monitor’s deliveries:
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.