# Webhooks

> Signed production events pushed to your URL, with retries, secret rolling and redelivery.

Webhooks push production events to you: a pitch is ready, the production was greenlit, the video wrapped, and the other moments listed below. Each event is sent to every endpoint subscribed to it, signed with that endpoint's secret, retried with backoff, and logged per attempt, so you can see what Raylight sent and what your server answered. The quiet stages in between (Queued, Writing pitch) send nothing; the opt-in `production.progress` event covers the steps while the video is made.

## Register an endpoint

- **In the app:** Raylight API, then Webhooks. Paste the URL, pick events, copy the secret.
- **REST:** `POST /v1/webhooks` with `url`, an optional `description` and an optional `events` list.
- **MCP:** ask your agent; the `create_webhook` tool makes the same call and shows you the secret.
- **CLI:** `raylight webhooks create --url https://hooks.acme.dev/raylight`.

```bash
curl -X POST https://api.raylight.app/v1/webhooks \
  -H "Authorization: Bearer $RAYLIGHT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.acme.dev/raylight",
    "description": "Slack relay",
    "events": ["production.pitch_ready", "production.delivered", "production.failed"]
  }'
```

201:

```json
{
  "endpoint": {
    "id": "2e7a9c41-8b3d-4f6e-a5c2-7d1b0e9f8a63",
    "workspaceId": "0b9d8c7a-6e5f-4d3c-ab1a-09f8e7d6c5b4",
    "url": "https://hooks.acme.dev/raylight",
    "description": "Slack relay",
    "events": ["production.pitch_ready", "production.delivered", "production.failed"],
    "enabled": true,
    "disabledReason": null,
    "consecutiveFailures": 0,
    "lastAttemptAt": null,
    "lastSuccessAt": null,
    "previousSecretExpiresAt": null,
    "createdAt": "2026-10-17T17:55:02.000Z",
    "updatedAt": "2026-10-17T17:55:02.000Z",
    "secret": "whsec_EXAMPLE..."
  }
}
```

The `secret` (`whsec_...`) is returned once, on create and on roll. URLs must be `https` and resolve to a public address; private and loopback ranges are refused. Ten endpoints per workspace.

Errors on `/v1/webhooks` carry a `code` like the rest of the API: 404 `not_found` for an endpoint or workspace you cannot see, 403 `forbidden` with view-only access, and 503 `disabled` on every route when webhooks are switched off on the server.

## Event types

| Type | Fires when | Default |
| --- | --- | --- |
| `production.pitch_ready` | A pitch version landed and is waiting for an answer (Pitch ready). Carries signed Greenlight, Send comments and Decline links. | Yes |
| `production.skipped` | Raylight judged the brief not worth a video. Nothing charged. | Yes |
| `production.greenlit` | Someone greenlit the pitch. Credits charged, project queued. | Yes |
| `production.progress` | While the video is made: when work starts, each time it moves to a new phase (Planning the video, Building scenes, Checking the cut and so on), and when a failed try is retried. Chatty. | No |
| `production.delivered` | The production wrapped: the video is ready. Carries the share link and the edit link. | Yes |
| `production.failed` | Production failed after its retries. The charge is refunded. | Yes |
| `production.declined` | Someone declined the pitch. | Yes |
| `production.cancelled` | The production was cancelled. | Yes |
| `ping` | You sent a test event. Never sent by the pipeline; cannot be subscribed or unsubscribed. | Always |

An endpoint created without `events` gets every default. Add `production.progress` only if you show a live status somewhere.

## The payload

Every production event has the same shape: `id` (stable across retries, also in the `Raylight-Event-Id` header), `type`, `createdAt`, `workspaceId`, and `data` with the full production record (`data.production`), the current pitch as markdown (`pitchMarkdown`), a one-sentence `status` when the stage has one, and `links`. Code files arrive with `source` empty, `patch` null and `sourceOmitted: true`; paths, digests and scores stay. A redelivered event also carries `redeliveryOf`.

production.pitch_ready:

```json
{
  "id": "c1d2e3f4-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
  "type": "production.pitch_ready",
  "createdAt": "2026-10-17T18:04:12.000Z",
  "workspaceId": "0b9d8c7a-6e5f-4d3c-ab1a-09f8e7d6c5b4",
  "data": {
    "production": {
      "id": "6f1c2b7e-3d4a-4c8e-9a2b-1f0e5d6c7b8a",
      "workspaceId": "0b9d8c7a-6e5f-4d3c-ab1a-09f8e7d6c5b4",
      "stage": "pitch_ready",
      "source": {
        "kind": "api",
        "externalId": "acme/web#412",
        "origin": {
          "provider": "github",
          "label": "acme/web #412",
          "url": "https://github.com/acme/web/pull/412",
          "ref": "feat/dark-mode",
          "requestedBy": "jane"
        }
      },
      "brief": {
        "intent": "Announce dark mode in the Acme dashboard to existing customers.",
        "context": "PR #412 adds a theme toggle to Settings. Charts, tables and the sidebar all pick up the dark palette. Ships to everyone on Thursday.",
        "media": [
          {
            "id": "dashboard-dark",
            "kind": "image",
            "url": "https://cdn.acme.dev/press/dashboard-dark.png",
            "name": "Dashboard in dark mode",
            "description": "The analytics overview with the new dark theme."
          }
        ],
        "code": [
          {
            "path": "src/components/settings/theme-toggle.tsx",
            "source": "",
            "status": "modified",
            "additions": 48,
            "deletions": 9,
            "patch": null,
            "sourceOmitted": true,
            "digest": "ThemeToggle: a pill switch with sun and moon icons, label \"Appearance\", brand purple when on.",
            "score": 0.91
          }
        ],
        "format": {
          "template": "feature",
          "durationMs": 10000,
          "aspect": "16:9",
          "sound": { "voiceover": true, "music": true, "sfx": true }
        },
        "brand": {
          "name": "Acme",
          "logoUrl": null,
          "primaryColor": "#6C5CE7",
          "backgroundColor": null,
          "fontFamily": null,
          "tone": "confident, plain"
        }
      },
      "pitch": {
        "version": 1,
        "verdict": "video",
        "verdictReason": null,
        "title": "Dark mode, finally",
        "summary": "A 10 second feature spot: the toggle flips, the dashboard follows, and an end card says it ships Thursday.",
        "shots": [
          {
            "index": 0,
            "title": "The flip",
            "durationMs": 3000,
            "beat": "The theme toggle flips and the whole settings page goes dark.",
            "visual": "The ThemeToggle component rebuilt as layers at hero scale, centred, a slow push-in. On the flip the background sweeps from white to the brand dark.",
            "text": "Dark mode",
            "voiceover": "Dark mode is here.",
            "mediaIds": [],
            "codePaths": ["src/components/settings/theme-toggle.tsx"]
          },
          {
            "index": 1,
            "title": "The dashboard",
            "durationMs": 4500,
            "beat": "The analytics overview in dark, tilted, drifting left.",
            "visual": "dashboard-dark on a device frame, 12 degree tilt, a lateral drift with a soft depth of field.",
            "text": "Every chart. Every table.",
            "voiceover": "Charts, tables and the sidebar all follow.",
            "mediaIds": ["dashboard-dark"],
            "codePaths": []
          },
          {
            "index": 2,
            "title": "Sign-off",
            "durationMs": 2500,
            "beat": "The Acme logo on the dark background with the ship date.",
            "visual": "Logo centred on the brand dark, the date types on underneath, a short hold before the cut.",
            "text": "Thursday, for everyone",
            "voiceover": "Turn it on in Settings, Thursday.",
            "mediaIds": [],
            "codePaths": []
          }
        ],
        "music": "Low, steady, electronic. No drop.",
        "estimatedCredits": 600,
        "createdAt": "2026-10-17T18:04:11.000Z"
      },
      "greenlight": null,
      "greenlightMode": "manual",
      "delivery": null,
      "projectId": null,
      "creditsCharged": 0,
      "error": null,
      "createdAt": "2026-10-17T18:02:40.000Z",
      "updatedAt": "2026-10-17T18:04:11.000Z",
      "terminal": false,
      "pageUrl": "https://www.raylight.app/api/productions/6f1c2b7e-3d4a-4c8e-9a2b-1f0e5d6c7b8a"
    },
    "pitchMarkdown": "## Dark mode, finally\n\nA 10 second feature spot ...",
    "status": null,
    "links": {
      "page": "https://www.raylight.app/api/productions/6f1c2b7e-3d4a-4c8e-9a2b-1f0e5d6c7b8a",
      "greenlight": "https://www.raylight.app/go/v1.eyJqIjoiNmYxYzJiN2UiLCJ2IjoxfQ.q8Zt3VbY1mK0?action=greenlight",
      "comment": "https://www.raylight.app/go/v1.eyJqIjoiNmYxYzJiN2UiLCJ2IjoxfQ.q8Zt3VbY1mK0?action=comment",
      "decline": "https://www.raylight.app/go/v1.eyJqIjoiNmYxYzJiN2UiLCJ2IjoxfQ.q8Zt3VbY1mK0?action=decline",
      "share": null,
      "edit": null
    }
  }
}
```

`data.links.page` is always there (the production's page in Raylight). `greenlight`, `comment` and `decline` appear only while the pitch is waiting for an answer. They are signed, login-free, bound to this payload's pitch version, and minted for this endpoint (anything done with them is recorded as `webhook:<endpointId>`). `share` and `edit` fill in when the production wraps.

## Headers and signature

```http
Raylight-Signature: t=1792277052,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Raylight-Event-Id: c1d2e3f4-5a6b-4c7d-8e9f-0a1b2c3d4e5f
Raylight-Event-Type: production.pitch_ready
Content-Type: application/json
User-Agent: Raylight-Webhooks/1.0 (+https://www.raylight.app/docs)
```

`Raylight-Signature` is `t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">` under the endpoint's secret. For 24 hours after a secret roll it carries two `v1` values, one per live secret; accept the delivery if any matches. Reject timestamps more than five minutes off your clock to stop replays. Sign the raw body bytes as received, never a re-serialised object.

### Verify with the SDK

`constructWebhookEvent` checks the signature and timestamp, then returns the typed event or throws. A bad signature is a 400; a correctly signed event the SDK cannot read is still answered 2xx, so the endpoint is never switched off over an SDK that is behind. Details on [SDK](https://www.raylight.app/docs/sdk.md).

route.ts:

```ts
import {
  constructWebhookEvent,
  RaylightWebhookPayloadError,
  RaylightWebhookSignatureError,
} from "@raylight/sdk";

export const POST = async (request: Request) => {
  const rawBody = await request.text();
  const signature = request.headers.get("raylight-signature") ?? "";
  const secret = process.env.RAYLIGHT_WEBHOOK_SECRET ?? "";

  let event;

  try {
    event = constructWebhookEvent(rawBody, signature, secret);
  } catch (error) {
    if (error instanceof RaylightWebhookSignatureError) {
      return new Response("bad signature", { status: 400 });
    }

    if (error instanceof RaylightWebhookPayloadError) {
      console.warn("Unreadable Raylight event", error.eventType, error.reason);

      return new Response(null, { status: 204 });
    }

    throw error;
  }

  if (event.type === "production.pitch_ready") {
    await postToSlack(event.data.links);
  }

  return new Response(null, { status: 204 });
};
```

### Verify by hand (Node)

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

export const verifyRaylightSignature = (
  rawBody: string,
  header: string,
  secret: string
): boolean => {
  const parts = header.split(",").map((part) => part.trim());
  const timestamp = Number(parts.find((part) => part.startsWith("t="))?.slice(2));
  const signatures = parts
    .filter((part) => part.startsWith("v1="))
    .map((part) => part.slice(3));

  if (!Number.isFinite(timestamp) || signatures.length === 0) {
    return false;
  }

  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) {
    return false;
  }

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

  return signatures.some(
    (candidate) =>
      candidate.length === expected.length &&
      timingSafeEqual(Buffer.from(candidate), Buffer.from(expected))
  );
};
```

### Verify by hand (Python)

```python
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 5 * 60


def verify_raylight_signature(raw_body: bytes, header: str, secret: str) -> bool:
    parts = [part.strip() for part in header.split(",")]
    timestamps = [part[2:] for part in parts if part.startswith("t=")]
    signatures = [part[3:] for part in parts if part.startswith("v1=")]

    if not timestamps or not signatures:
        return False

    timestamp = int(timestamps[0])

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

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

    return any(hmac.compare_digest(candidate, expected) for candidate in signatures)
```

## Retries and auto-disable

- Any 2xx within 10 seconds is a success; anything else is a failure. Answer fast and do the work after. Answer 2xx to every correctly signed delivery, including event types you do not handle yet; only a bad signature deserves a 4xx.
- Failures retry after 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours: six attempts over about seven hours, then the delivery is `exhausted`.
- Retries reuse the event id. Track handled ids if a duplicate is expensive.
- After 20 exhausted deliveries in a row the endpoint is switched off, `disabledReason` is set, and the workspace owner gets an email. Any success resets the count. Turn it back on with `PATCH /v1/webhooks/{id}` and `{ "enabled": true }` for a clean run.

## Rolling the secret

`POST /v1/webhooks/{id}/roll-secret` mints a new secret and returns it once. The old secret keeps verifying for 24 hours (`previousSecretExpiresAt` on the endpoint), and deliveries in that window carry two `v1` signatures, so you can deploy the new secret without dropping anything. If another roll of the same endpoint lands at the same moment, yours can answer 409 with code `conflict`: nothing changed from your call, so roll again.

## Testing

`POST /v1/webhooks/{id}/test` queues a signed `ping`. Use it to check your receiver and verification before a real production fires.

ping:

```json
{
  "id": "e5f6a7b8-c9d0-4e1f-a2b3-c4d5e6f7a8b9",
  "type": "ping",
  "createdAt": "2026-10-17T17:58:30.000Z",
  "workspaceId": "0b9d8c7a-6e5f-4d3c-ab1a-09f8e7d6c5b4",
  "data": {
    "endpointId": "2e7a9c41-8b3d-4f6e-a5c2-7d1b0e9f8a63",
    "message": "Raylight can reach this endpoint. Real events have a production.* type and carry the production in data.production."
  }
}
```

## Delivery log and redelivery

`GET /v1/webhooks/{id}/deliveries` lists the newest deliveries with status (`pending`, `succeeded`, `exhausted`), attempt count, next attempt time, and your server's last HTTP status and first 2 KB of its response body, or the transport error. `POST /v1/webhooks/{id}/deliveries/{deliveryId}/redeliver` sends a fresh copy as a new delivery: the payload gets a new `id` plus a `redeliveryOf` field naming the original, whose attempt history stays intact. Deliveries are kept for 30 days, and the whole log is deleted with the endpoint.

## Forward the links anywhere

The signed links are the whole credential, so your receiver never calls Raylight back. A Slack message built from `production.pitch_ready` with three URL buttons is a complete greenlight flow: whoever clicks Greenlight lands on a public page, sees the pitch and price, and confirms. Same for a CI job summary, a Linear comment or your own email.

Slack Block Kit:

```json
{
  "text": "Pitch ready: Dark mode, finally",
  "blocks": [
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": "*Dark mode, finally*\n20 s, 16:9, 600 credits\nacme/web #412"
      }
    },
    {
      "type": "actions",
      "elements": [
        { "type": "button", "style": "primary", "text": { "type": "plain_text", "text": "Greenlight" }, "url": "<data.links.greenlight>" },
        { "type": "button", "text": { "type": "plain_text", "text": "Send comments" }, "url": "<data.links.comment>" },
        { "type": "button", "style": "danger", "text": { "type": "plain_text", "text": "Decline" }, "url": "<data.links.decline>" }
      ]
    }
  ]
}
```

What the link page does and refuses: [Greenlight links](https://www.raylight.app/docs/greenlight-links.md). Ready-made relays: [Recipes](https://www.raylight.app/docs/recipes.md).

## More

- Previous: [Productions](https://www.raylight.app/docs/productions.md)
- Next: [Authentication and keys](https://www.raylight.app/docs/authentication.md)
- All docs: https://www.raylight.app/llms.txt
- Everything in one file: https://www.raylight.app/llms-full.txt
- OpenAPI: https://api.raylight.app/v1/openapi.json
