> ## Documentation Index
> Fetch the complete documentation index at: https://docs.use.observer/llms.txt
> Use this file to discover all available pages before exploring further.

# Subscriber webhook reference

> Payloads, headers, and signature verification for webhooks connected from a public status page.

A visitor can connect a webhook from a status page's subscribe form (see
[Subscribe to a status page](/docs/guides/status-page-subscriptions#connect-a-webhook)).
This page describes what that endpoint receives.

<Info>
  **Subscriber webhooks vs. operator webhooks**

  Subscriber webhooks are created by visitors on the public page and only
  carry incident and maintenance updates for that page. Operator webhooks are
  created on the **Alerts** page as a **Generic webhook** integration, carry
  more event types, and use a different body. See the
  [webhook payload reference](/docs/reference/webhook-payloads) for those.
  Both use the same signature scheme.
</Info>

## Request

Every delivery is an HTTPS `POST` with a JSON body.

```text theme={null}
Content-Type: application/json
User-Agent: observer-status/1.0
X-Observer-Event: <event type>
X-Observer-Signature: sha256=<hex>
X-Observer-Signature-Legacy: sha256=<hex>
X-Observer-Timestamp: <unix-millis>
```

| Header | Meaning |
| - | - |
| `X-Observer-Event` | The event type, the same value as `event` in the body. |
| `X-Observer-Signature` | `sha256=` followed by the hex HMAC-SHA256 of `${timestamp}.${rawBody}`. See [Verify the signature](#verify-the-signature). |
| `X-Observer-Signature-Legacy` | `sha256=` followed by the hex HMAC-SHA256 of the raw body alone. Deprecated; it carries no replay protection. |
| `X-Observer-Timestamp` | When the delivery was signed, in milliseconds since the Unix epoch. |

Every subscriber webhook has a signing secret, so the signature headers are
always present.

## Events

| Event | Sent when | `headline` prefix |
| - | - | - |
| `subscription.connected` | Once, when the webhook is connected, and again if the same URL is submitted while already connected. | (no headline) |
| `incident.published` | An incident is published. | `Investigating:` |
| `incident.message_added` | An update is posted on a published incident or maintenance. | `Update (<type>):` |
| `incident.resolved` | An incident is resolved. | `Resolved:` |
| `maintenance.scheduled` | A maintenance window is published. | `Scheduled maintenance:` |
| `maintenance.starting_soon` | A published maintenance window starts in about an hour. | `Maintenance starting soon:` |
| `maintenance.completed` | A maintenance window completes. | `Maintenance complete:` |
| `maintenance.canceled` | A maintenance window is canceled. | `Maintenance canceled:` |

There is no separate event when a maintenance window starts:
`maintenance.starting_soon` covers it. Incident edits that are not a posted
update, deletions, and anything on a draft send nothing. When an update
closes an incident (a **Resolved** update), only `incident.resolved` (or
`maintenance.completed`) is sent for it, not an additional
`incident.message_added`.

The [subscriber notification events](/docs/reference/subscriber-events)
reference explains which subscribers receive each event.

## Payloads

### Updates

Every incident and maintenance event uses the same shape.

```json incident.published theme={null}
{
  "event": "incident.published",
  "event_id": "4f7c2a9e-1b3d-4c8e-9a6f-2d5e7b8c9a10",
  "incident_id": "b1e2c3d4-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
  "page": { "name": "Acme Cloud", "url": "https://acme.use.observer" },
  "title": "Elevated API errors",
  "headline": "Investigating: Elevated API errors",
  "body": "We are investigating elevated error rates on the public API.",
  "severity": "major",
  "message_type": null,
  "affected_services": ["API", "Dashboard"],
  "url": "https://acme.use.observer/incidents/b1e2c3d4-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
  "manage_url": "https://acme.use.observer/preferences?token=…",
  "occurred_at": "2026-10-07T14:02:11.000Z"
}
```

```json incident.message_added theme={null}
{
  "event": "incident.message_added",
  "event_id": "8a1d0c55-2e7b-4f3a-9b6c-1d2e3f4a5b6c",
  "incident_id": "b1e2c3d4-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
  "page": { "name": "Acme Cloud", "url": "https://acme.use.observer" },
  "title": "Elevated API errors",
  "headline": "Update (Identified): Elevated API errors",
  "body": "A faulty deploy was identified and is being rolled back.",
  "severity": "major",
  "message_type": "Identified",
  "affected_services": ["API", "Dashboard"],
  "url": "https://acme.use.observer/incidents/b1e2c3d4-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
  "manage_url": "https://acme.use.observer/preferences?token=…",
  "occurred_at": "2026-10-07T14:20:45.000Z"
}
```

```json maintenance.scheduled theme={null}
{
  "event": "maintenance.scheduled",
  "event_id": "c0ffee00-1234-4abc-8def-0123456789ab",
  "incident_id": "6d5c4b3a-2f1e-4d0c-9b8a-7f6e5d4c3b2a",
  "page": { "name": "Acme Cloud", "url": "https://acme.use.observer" },
  "title": "Database upgrade",
  "headline": "Scheduled maintenance: Database upgrade",
  "body": "We will upgrade the primary database. Writes may pause for up to two minutes.",
  "severity": "maintenance",
  "message_type": null,
  "affected_services": ["API"],
  "url": "https://acme.use.observer/incidents/6d5c4b3a-2f1e-4d0c-9b8a-7f6e5d4c3b2a",
  "manage_url": "https://acme.use.observer/preferences?token=…",
  "occurred_at": "2026-10-07T09:00:00.000Z"
}
```

`incident.resolved`, `maintenance.starting_soon`, `maintenance.completed`,
and `maintenance.canceled` have the same fields; only `event`, `headline`,
and the content differ.

| Field | Meaning |
| - | - |
| `event` | The event type. |
| `event_id` | Identifier of this event. It is the same for every subscriber of the event and does not change if the event is processed again. Use it to discard duplicates. |
| `incident_id` | The incident or maintenance the event belongs to. Every event for the same incident or maintenance carries the same value. |
| `page.name` | The status page's name. |
| `page.url` | The status page's URL, or `null` when it is not known. |
| `title` | The incident or maintenance title. |
| `headline` | A ready-to-display line: the event's prefix (see [Events](#events)) followed by the title. |
| `body` | The text of the update, or `null`. For `incident.published` and `maintenance.scheduled` this is the first update; for `incident.resolved` and `maintenance.completed` it is the closing **Resolved** update when there is one. |
| `severity` | `minor`, `major`, or `critical` for incidents; `maintenance` (or `null`) for maintenance windows. |
| `message_type` | The update type for `incident.message_added`, for example `Investigating`, `Identified`, `Monitoring`, or `Resolved`. Usually `null` for other events. |
| `affected_services` | Names of the affected services. Empty when the update names none. |
| `url` | The update's permalink on the status page (`/incidents/<id>`). |
| `manage_url` | The subscription's preferences page, where it can be changed or disconnected. Treat it as a secret: anyone with it can manage the subscription. |
| `occurred_at` | When the event happened (ISO 8601, UTC). |

### Test message

Sent when the webhook is connected. The subscription is created only if your
endpoint answers this request with a 2xx status.

```json subscription.connected theme={null}
{
  "event": "subscription.connected",
  "page": { "name": "Acme Cloud", "url": "https://acme.use.observer/" },
  "text": "Connected to Acme Cloud status updates",
  "manage_url": "https://acme.use.observer/preferences?token=…",
  "occurred_at": "2026-10-07T13:55:02.000Z"
}
```

It carries no `event_id` or `incident_id`. It is signed like every other
delivery.

## Verify the signature

The signing secret is shown once, in the subscribe form, when the webhook is
connected. It starts with `whsec_`. Use the whole string, prefix included, as
the HMAC key.

To verify a delivery:

1. Read `X-Observer-Timestamp`. Reject the delivery if it is more than five
   minutes from your server's clock. This bounds replay of a captured
   request.
2. Compute HMAC-SHA256 with the secret as the key over
   `${timestamp}.${rawBody}`: the timestamp header value, a period, and the
   request body exactly as received. Hex-encode the digest and prepend
   `sha256=`.
3. Compare the result with `X-Observer-Signature` using a constant-time
   comparison. Reject the delivery if they differ.

Hash the raw request bytes. Parsing the JSON and serializing it again can
change whitespace or key order and break the signature.

<Tabs>
  <Tab title="Node.js">
    ```js server.js theme={null}
    import crypto from "node:crypto";
    import express from "express";

    const SECRET = process.env.OBSERVER_WEBHOOK_SECRET; // whsec_...
    const MAX_SKEW_MS = 5 * 60 * 1000;

    const app = express();

    // express.raw keeps the body as bytes, so the signature is computed over
    // exactly what Observer sent.
    app.post("/hooks/status", express.raw({ type: "application/json" }), (req, res) => {
      const timestamp = req.get("X-Observer-Timestamp") ?? "";
      const signature = req.get("X-Observer-Signature") ?? "";
      const rawBody = req.body.toString("utf8");

      const age = Math.abs(Date.now() - Number(timestamp));
      if (!Number.isFinite(age) || age > MAX_SKEW_MS) return res.sendStatus(400);

      const expected =
        "sha256=" +
        crypto.createHmac("sha256", SECRET).update(`${timestamp}.${rawBody}`).digest("hex");
      const a = Buffer.from(expected);
      const b = Buffer.from(signature);
      if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);

      const event = JSON.parse(rawBody);
      console.log(event.event, event.headline ?? event.text);
      res.sendStatus(204);
    });

    app.listen(3000);
    ```
  </Tab>

  <Tab title="Python">
    ```python app.py theme={null}
    import hashlib
    import hmac
    import os
    import time

    from flask import Flask, abort, request

    SECRET = os.environ["OBSERVER_WEBHOOK_SECRET"].encode()  # whsec_...
    MAX_SKEW_MS = 5 * 60 * 1000

    app = Flask(__name__)


    @app.post("/hooks/status")
    def status_hook():
        timestamp = request.headers.get("X-Observer-Timestamp", "")
        signature = request.headers.get("X-Observer-Signature", "")
        raw_body = request.get_data()  # bytes, exactly as received

        try:
            age = abs(time.time() * 1000 - int(timestamp))
        except ValueError:
            abort(400)
        if age > MAX_SKEW_MS:
            abort(400)

        expected = "sha256=" + hmac.new(
            SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256
        ).hexdigest()
        if not hmac.compare_digest(expected, signature):
            abort(401)

        event = request.get_json()
        print(event["event"], event.get("headline") or event.get("text"))
        return "", 204
    ```
  </Tab>
</Tabs>

## Delivery behaviour

* **Timeout**: your endpoint must answer within 10 seconds.
* **Success**: any 2xx status. The response body is ignored.
* **Failure**: any other status, a timeout, a network error, or a redirect.
  Redirects are never followed; configure the final URL.
* **No retries**: each event is sent once per subscription. A failed delivery
  is recorded and is not retried, and the subscription stays connected for
  the next event. If you cannot afford to miss an update, poll the page's
  [feed](/docs/reference/feed) or
  [JSON status](/docs/guides/status-badges-and-api#json-status) as a backstop.
* **Duplicates**: rare, but possible if processing is interrupted after a
  delivery. Use `event_id` to discard repeats.
* **Address checks**: the URL must resolve to a public address on every
  delivery. A URL that later resolves to a private address is refused.

## Rotate the secret or disconnect

Open the subscription's `manage_url` (also linked as **Manage subscription**
after connecting) and click **Disconnect Webhook**. Disconnecting stops all
deliveries and deletes the stored URL and signing secret.

There is no in-place secret rotation. To get a new secret, disconnect the
subscription, then connect the same URL again from the status page's
subscribe form. The new subscription has a new secret and a new
`manage_url`. Submitting a URL that is still connected does not issue a new
secret; it only sends another `subscription.connected` test message.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.