---
title: "Webhooks"
description: "Monitor-scoped webhooks, payload format, signature verification, retry policy, and the account-wide delivery log."
doc_version: "1"
last_updated: "2026-06-02"
---

## Set or replace the webhook on a monitor

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:

```bash
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:

```json
{
  "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.

## Inspect or delete a webhook

```bash
# 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"
```

## Payload format

```json
{
  "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–3) | Which delivery attempt this is (1 = first try). |

## 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.

:::caution
Always verify against the raw body bytes, not a re-serialized parse of the body. Always use a constant-time comparison — `===` or `!==` is not safe here.
:::

```js
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);
```

```python
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)](https://cwe.mitre.org/data/definitions/208.html).

## 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)

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

```bash
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`, or `failed`. |
| `limit` | integer (1–100) | Page size. Default `50`. |
| `offset` | integer | Skip the first N results. |

Response shape:

```json
{
  "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.