Skip to content

Webhooks

Webhooks are scoped to a single monitor — each monitor exposes one webhook slot. Use PUT /api/v1/monitors/{monitorID}/webhook to set it or replace its URL:

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"
}

The response includes the webhook record and the plaintext HMAC secret. The same shape is returned on subsequent GET reads — the API does NOT redact the secret on read, so the endpoint must be treated as a credential surface (anyone who can authenticate to the account can recover the signing key).

Replacing the URL on an existing webhook keeps the existing secret. To rotate the secret, DELETE the webhook then PUT it again.

Terminal window
# Read the current webhook for a monitor (returns the plaintext secret too)
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.

FieldTypeDescription
eventstringOne of monitor.down, monitor.up, monitor.flapping.
monitor_idintegerID of the monitor that changed state.
monitor_namestringDisplay name of the monitor.
monitor_urlstring | omittedThe URL being monitored. Omitted if the monitor has no URL.
account_idintegerAccount that owns the monitor.
occurred_atRFC3339 stringWhen the state transition was detected.
reasonstring | omittedError class for monitor.down (e.g. http_5xx, timeout), flap reason for monitor.flapping, empty/omitted for monitor.up.
delivery_idintegerStable per logical state transition. Use this field to deduplicate retried deliveries — the same transition always carries the same delivery_id.
attemptinteger (1–3)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.

const crypto = require('crypto');
const express = require('express');
const app = express();
// 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 expected = crypto
.createHmac('sha256', process.env.UPTIMEMONITORING_WEBHOOK_SECRET)
.update(req.body) // req.body is a Buffer here
.digest('hex');
const a = Buffer.from(sig, 'hex');
const b = Buffer.from(expected, 'hex');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
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__)
SECRET = os.environ["UPTIMEMONITORING_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/uptimemonitoring")
def receive():
raw = request.get_data() # bytes, before any parsing
sig = request.headers.get("X-UptimeMonitoring-Signature", "")
expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
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:

AttemptDelay
1Immediate
21 minute
35 minutes
430 minutes
52 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"
QueryTypeDescription
monitor_idintegerRestrict to deliveries for one monitor.
sinceRFC3339 datetimeOnly deliveries dispatched at or after this time.
statusstringFilter by delivery status: pending, retrying, delivered, or failed.
limitinteger (1–100)Page size. Default 50.
offsetintegerSkip 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, monitor_id, event_type, status (pending|retrying|delivered|failed), attempt_count, last_response_code, last_attempt_at, next_attempt_at, and created_at. There is no per-webhook delivery endpoint — GET /api/v1/webhooks/{id}/deliveries does not exist.