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

# Webhooks

> Get notified when a stream starts and stops, without polling

Register a URL and Daydream will POST to it when a stream starts and when it
stops. This is the reliable way to learn that a session ended — including the
cases where nothing tells you otherwise, like a viewer backgrounding the tab,
closing their laptop, or dropping off Wi-Fi.

<Note>
  Polling `GET /v1/streams/{id}/status` only works while a stream is live. Once
  the pipeline is torn down the gateway returns `state: OFFLINE` with no
  timestamp, so there is nothing left to read after the fact. The `stream.ended`
  webhook carries that timestamp to you instead.
</Note>

## Quickstart

<Steps>
  <Step title="Register your endpoint">
    ```bash theme={null}
    curl -X POST https://api.daydream.live/v1/webhooks \
      -H "Authorization: Bearer $DAYDREAM_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "url": "https://yourapp.com/webhooks/daydream",
        "events": ["stream.started", "stream.ended"],
        "name": "production"
      }'
    ```

    The response contains your signing secret **in full, once**:

    ```json theme={null}
    {
      "id": "whk_7Fq2mKp9xRtWvN3c",
      "url": "https://yourapp.com/webhooks/daydream",
      "events": ["stream.started", "stream.ended"],
      "secret": "whsec_9dK2mQx7...",
      "is_active": true,
      "consecutive_failures": 0,
      "created_at": "2026-08-17T09:14:22.104Z"
    }
    ```

    Store `secret` now. Later reads return only a `secret_preview`; if you lose it,
    your only option is `POST /v1/webhooks/{id}/rotate-secret`.
  </Step>

  <Step title="Verify it reaches you">
    ```bash theme={null}
    curl -X POST https://api.daydream.live/v1/webhooks/whk_7Fq2mKp9xRtWvN3c/test \
      -H "Authorization: Bearer $DAYDREAM_API_KEY"
    ```

    This sends a synthetic, fully-signed `stream.ended` and returns exactly what
    your endpoint responded with — status, body, and round-trip time. Use it to
    check both connectivity and your signature verification before real traffic
    arrives.
  </Step>

  <Step title="Handle the event">
    Respond `2xx` quickly. Anything else — or no response within 10 seconds — counts
    as a failure and gets retried.
  </Step>
</Steps>

## Events

| Event            | Fires when                                                                                                                                                                                 |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `stream.started` | Ingest connects and the pipeline comes up. This is later than stream *creation*: a client can create a stream via `POST /v1/streams` and take a while to connect, or never connect at all. |
| `stream.ended`   | The session stops, for any reason. See `data.reason`.                                                                                                                                      |

A stream that reconnects emits a fresh `stream.started`, so a single stream id
can cycle `started` → `ended` → `started` over its lifetime.

### Envelope

Every event has the same shape:

```json theme={null}
{
  "id": "evt_3xKp9mQ2vRtWnF7c",
  "type": "stream.ended",
  "created_at": "2026-08-17T09:20:31.882Z",
  "data": { }
}
```

### `stream.started`

```json theme={null}
{
  "id": "evt_8jNw4pLm2xVqBt6d",
  "type": "stream.started",
  "created_at": "2026-08-17T09:14:47.201Z",
  "data": {
    "stream_id": "str_UB3Z53STZ7b9wvN8",
    "pipeline_id": "pip_SD15-v2v",
    "started_at": "2026-08-17T09:14:47.180Z",
    "created_at": "2026-08-17T09:14:31.004Z",
    "gateway_host": "gateway-fra-1.daydream.live"
  }
}
```

### `stream.ended`

```json theme={null}
{
  "id": "evt_3xKp9mQ2vRtWnF7c",
  "type": "stream.ended",
  "created_at": "2026-08-17T09:20:31.882Z",
  "data": {
    "stream_id": "str_UB3Z53STZ7b9wvN8",
    "pipeline_id": "pip_SD15-v2v",
    "started_at": "2026-08-17T09:14:47.180Z",
    "ended_at": "2026-08-17T09:19:58.442Z",
    "duration_seconds": 311,
    "reason": "timeout",
    "gateway_host": "gateway-fra-1.daydream.live"
  }
}
```

`ended_at` is **the last moment the stream was confirmed live**, not the moment
we noticed it had stopped. Those are different: detection takes up to 45 seconds
after the last signal, and `ended_at` is backdated past that window. It is the
timestamp to bill and report against.

`reason` is one of:

| Reason              | Meaning                                                                                                                                                                      |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `timeout`           | The stream stopped being reported as live. This covers the ordinary cases: the client disconnected, the tab was backgrounded, the laptop went to sleep, the network dropped. |
| `deleted`           | `DELETE /v1/streams` was called.                                                                                                                                             |
| `credits_exhausted` | The session was terminated because the account ran out of credits.                                                                                                           |

## Verifying signatures

Every request carries these headers:

| Header                       | Contents                                |
| ---------------------------- | --------------------------------------- |
| `Daydream-Webhook-Signature` | `t=<unix_seconds>,v1=<hmac_sha256_hex>` |
| `Daydream-Webhook-Id`        | The event id — same as `id` in the body |
| `Daydream-Webhook-Event`     | The event type                          |
| `Daydream-Webhook-Timestamp` | Unix seconds, same as `t` above         |

The signature is an HMAC-SHA256 over `` `${timestamp}.${rawBody}` `` keyed with
your secret. Sign the **raw request body**, before any JSON parsing —
re-serializing changes the bytes and the signature will not match.

<CodeGroup>
  ```typescript Node.js theme={null}
  import crypto from "node:crypto";
  import express from "express";

  const app = express();
  const SECRET = process.env.DAYDREAM_WEBHOOK_SECRET!;
  const TOLERANCE_SECONDS = 300;

  function verify(rawBody: Buffer, header: string): boolean {
    const parts = Object.fromEntries(
      header.split(",").map(kv => kv.split("=") as [string, string]),
    );
    const timestamp = Number(parts.t);
    if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) {
      return false;
    }

    const expected = crypto
      .createHmac("sha256", SECRET)
      .update(`${timestamp}.${rawBody.toString("utf8")}`)
      .digest("hex");

    const a = Buffer.from(expected, "hex");
    const b = Buffer.from(parts.v1 ?? "", "hex");
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  }

  // express.raw, not express.json — the signature covers the raw bytes.
  app.post(
    "/webhooks/daydream",
    express.raw({ type: "application/json" }),
    (req, res) => {
      if (!verify(req.body, req.header("Daydream-Webhook-Signature") ?? "")) {
        return res.status(401).send("bad signature");
      }

      const event = JSON.parse(req.body.toString("utf8"));
      if (event.type === "stream.ended") {
        console.log(event.data.stream_id, "stopped at", event.data.ended_at);
      }

      // Acknowledge first, work later.
      res.sendStatus(200);
    },
  );
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import os
  import time

  from flask import Flask, request

  app = Flask(__name__)
  SECRET = os.environ["DAYDREAM_WEBHOOK_SECRET"]
  TOLERANCE_SECONDS = 300


  def verify(raw_body: bytes, header: str) -> bool:
      parts = dict(kv.split("=", 1) for kv in header.split(","))
      try:
          timestamp = int(parts["t"])
      except (KeyError, ValueError):
          return False

      if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
          return False

      expected = hmac.new(
          SECRET.encode(),
          f"{timestamp}.".encode() + raw_body,
          hashlib.sha256,
      ).hexdigest()

      return hmac.compare_digest(expected, parts.get("v1", ""))


  @app.post("/webhooks/daydream")
  def handle():
      # request.data is the raw body; do not use request.json here.
      if not verify(request.data, request.headers.get("Daydream-Webhook-Signature", "")):
          return "bad signature", 401

      event = request.get_json()
      if event["type"] == "stream.ended":
          print(event["data"]["stream_id"], "stopped at", event["data"]["ended_at"])

      return "", 200
  ```
</CodeGroup>

<Warning>
  Compare signatures with a constant-time function (`timingSafeEqual`,
  `hmac.compare_digest`), never `==`. Reject events whose timestamp is more than
  a few minutes old, which is what stops a captured request from being replayed.
</Warning>

## Delivery, retries, and idempotency

A delivery is one POST to one endpoint. Non-2xx, a timeout past 10 seconds, or a
connection error all count as failures and are retried with exponential backoff:

```
15s → 30s → 1m → 2m → 4m → 8m → 16m → 32m
```

Eight attempts spanning roughly an hour. Redirects are **not** followed — a 3xx
is a failure.

**Deduplicate on `id`.** Retries reuse the same event id, and a delivery that
timed out on your side may well have been processed. Treat handlers as
idempotent.

If an endpoint fails 20 deliveries in a row it is automatically disabled and
stops receiving events. Any successful delivery resets that counter. Re-enable
with:

```bash theme={null}
curl -X PATCH https://api.daydream.live/v1/webhooks/whk_7Fq2mKp9xRtWvN3c \
  -H "Authorization: Bearer $DAYDREAM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"is_active": true}'
```

which also clears the failure count.

## Debugging deliveries

Each delivery is recorded for 14 days: the exact payload sent, how many
attempts it took, and the status and body your endpoint returned. The record is
updated in place as retries happen, so `attempts` is a running count while
`response_status`, `response_body` and `error` describe the **most recent**
attempt rather than each one individually.

```bash theme={null}
curl "https://api.daydream.live/v1/webhooks/whk_7Fq2mKp9xRtWvN3c/deliveries?limit=10" \
  -H "Authorization: Bearer $DAYDREAM_API_KEY"
```

Filter by `status` (`pending`, `succeeded`, `failed`), by `event_type`, or by
`stream_id` to answer "did the `stream.ended` for this specific stream reach
me?":

```bash theme={null}
curl "https://api.daydream.live/v1/webhooks/whk_7Fq2mKp9xRtWvN3c/deliveries?stream_id=str_UB3Z53STZ7b9wvN8" \
  -H "Authorization: Bearer $DAYDREAM_API_KEY"
```

`pending` means retries are still in flight; `failed` means every attempt was
used up.

## Endpoint requirements

* **HTTPS only.** Plaintext `http://` is rejected.
* **Publicly routable.** Loopback, private (RFC1918), link-local and
  carrier-grade NAT addresses are rejected at registration. For local
  development, put a tunnel (ngrok, Cloudflare Tunnel) in front of your server.
* **Respond within 10 seconds.** Acknowledge with a `2xx` first and do the real
  work asynchronously; a slow handler turns into a retry storm.
* **Up to 10 endpoints per account.** Useful for pointing production, staging
  and a test receiver at the same events.

## Managing endpoints

| Method   | Path                              | Purpose                                        |
| -------- | --------------------------------- | ---------------------------------------------- |
| `POST`   | `/v1/webhooks`                    | Register an endpoint. Returns the secret once. |
| `GET`    | `/v1/webhooks`                    | List endpoints and their delivery health.      |
| `GET`    | `/v1/webhooks/{id}`               | Fetch one endpoint.                            |
| `PATCH`  | `/v1/webhooks/{id}`               | Change URL, events, name, or active state.     |
| `DELETE` | `/v1/webhooks/{id}`               | Delete the endpoint and its delivery log.      |
| `POST`   | `/v1/webhooks/{id}/rotate-secret` | Issue a new signing secret.                    |
| `POST`   | `/v1/webhooks/{id}/test`          | Send a synthetic signed event.                 |
| `GET`    | `/v1/webhooks/{id}/deliveries`    | Delivery history for debugging.                |

<Warning>
  Rotating a secret takes effect immediately — the old one stops signing anything
  the moment the call returns. If you verify signatures strictly, deploy code that
  accepts the new secret before you rotate.
</Warning>
