# Webhooks

Receive signed JSON callbacks, or Slack and Discord messages, when a project's broadcasts or recording generations change.

Create an endpoint in your project's **Settings → Webhooks**, or with
`POST /v1/projects/{pid}/webhooks`. Choose event types, a path pattern, and
a `format`: `json` (the default), `slack`, or `discord`.
Production URLs must use HTTPS and a public hostname, without an IP literal
or user/password. A project supports up to 10 endpoints. Delivery is free.

## Slack and Discord

A `slack` or `discord` endpoint posts a readable message straight to that
platform's incoming webhook, with no relay service in between. Paste the
webhook URL the platform gives you:

| Format | URL must start with | Body |
| --- | --- | --- |
| `slack` | `https://hooks.slack.com/` | `{ "text": ..., "unfurl_links": false, "unfurl_media": false }` |
| `discord` | `https://discord.com/api/webhooks/` | `{ "content": ..., "allowed_mentions": { "parse": [] } }` |

These bodies are unsigned, since neither platform verifies a signature; the
URL rule keeps a preset from posting unsigned bodies anywhere else. The
format is fixed when the endpoint is created: to change it, create a new
endpoint. A new `url` must still match the endpoint's format.

Each message starts with a link to the project's dashboard page and the
project's current name (its pid if unnamed), followed by the event, such as
`` <https://moq.pro/project/abc123> my-project: `cam/live.hang` went live ``.
Link previews are suppressed. Discord limits `content` to 2,000 characters,
so a long path or failure reason is shortened with `…`.

| Event | Message after the project prefix |
| --- | --- |
| `broadcast.started` | `` `cam/live.hang` went live ``, or `` `cam/live.hang` is live (backfill, it may have started earlier) `` |
| `broadcast.ended` | `` `cam/live.hang` went offline ``, with `(connection lost)` for `reason: connection_lost` |
| `recording.started` | `` `cam/live.hang` started recording `` |
| `recording.ready` | ``A recording of `cam/live.hang` is ready`` |
| `recording.failed` | ``A recording of `cam/live.hang` failed (bucket_refusal)`` |
| `webhook.test` | `Test message from moq.pro: this endpoint is connected.` |

Filtering, retries, disabling, and the delivery log work as for JSON
endpoints. Both platforms answer a rate limit with `429` and a
`Retry-After`, which the retry schedule honors.

## Envelope and events

Every `json` delivery is a `POST` with `Content-Type: application/json`,
`User-Agent: moq.pro-webhooks`, and `Moq-Signature`. The envelope is:

```json
{
  "id": "evt_example",
  "type": "broadcast.started",
  "created_at": "2026-10-25T12:00:00.000Z",
  "project": "project-pid",
  "data": {
    "path": "cam/live.hang",
    "at": "2026-10-25T11:59:59.000Z"
  }
}
```

`id` identifies the event and stays the same across HTTP delivery retries
and endpoints.
`project` is the immutable project pid. `created_at` is when the API emitted
the event; `data.at` is when its source observed the change, so a backfill's
observation can be much older. Both timestamps use UTC ISO 8601 strings.
Allow additional fields and ignore event types you do not handle.
The [OpenAPI webhooks section](/api.json) publishes each payload schema.

| Event | `data` | Meaning |
| --- | --- | --- |
| `broadcast.started` | `path`, `at`, optional `backfill: true` | A path became live, or was discovered in a live-path snapshot (`backfill: true`). |
| `broadcast.ended` | `path`, `at`, `reason` | The last reporting edge lost the path. `reason` is `unannounced` or `connection_lost`. |
| `recording.started` | `path`, `at`, `recordingId`, `generationId` | One physical recording generation started. |
| `recording.ready` | `path`, `at`, `recordingId`, `generationId` | That generation's portable export finished successfully. |
| `recording.failed` | `path`, `at`, `recordingId`, `generationId`, `reason`, `mediaWritten` | The generation could not record or its export failed permanently. |
| `webhook.test` | `endpoint` | A test requested for this endpoint, regardless of its path pattern and subscribed event types. |

For recordings, `recordingId` is the logical `<path>/<instance>` and
`generationId` is `<path>/<instance>/<n>`. A relay handoff can start another
generation of the same recording. So can a publisher that returns under the
same route instance, such as one a library publisher names, at any later time, even
after the earlier generations' retention ran out: generation numbers are never
reused. A publisher that sends conflicting media or moves the media clock backwards
under a reused instance ends that generation with a `regression` diagnostic. A `ready` event describes only its named
generation: a later generation may still be live. Each generation emits at most
one outcome, `recording.ready` or `recording.failed`. Temporary export errors stay
pending and send neither. A permanent failure sends `failed`; maintenance can
later repair the stored export, but sends no new outcome for that generation.
An archive recording has one generation per instance, so its `ready`
means the whole recording is flushed.

Recording events also carry these optional fields. Older in-flight sources can
omit them; receivers must accept their absence.

| Field | Meaning |
| --- | --- |
| `bucket` | BYOB bucket name, snapshotted when the generation starts. |
| `objectPrefix` | BYOB generation's object-key prefix, including its trailing `/`. |
| `masterPlaylist` | Master playlist URI, currently `index.m3u8`. |
| `renditions` | Array of `{ name, kind, playlist, width?, height? }`. `kind` is `video` or `audio`; known dimensions are in pixels. |
| `durationMs` | On `recording.ready`, the recorded media duration in milliseconds, independent of elapsed wall time. |
| `regression` | On `recording.ready` and `recording.failed`, the source-instance regression diagnostic for this generation, or `null` when none was reported. Older queued outcomes may omit it. Absent on `recording.started`. |

Treat `regression` as diagnostic text, not an enum. It describes the event's
generation; `/v1` recording reads carry the latest non-null diagnostic by
generation number among the logical recording's retained generations, as a
required `string` or `null`. A later reconnect can be live while an earlier
generation's diagnostic remains visible. Retiring that generation exposes the
next retained diagnostic, or `null` when none remain. It is independent of
export status: media committed before the regression can still play and produce
`recording.ready`. If export
fails, `recording.failed` carries both `regression` and the export's `reason`.

`recording.failed` adds two required fields:

| Field | Meaning |
| --- | --- |
| `reason` | Closed enum: `bucket_refusal` for a customer's bucket refusing an upload; `export_failure` for an unrenderable export, including no playable rendition; `platform_error` for platform upload or export failures. |
| `mediaWritten` | Whether the generation acknowledged any uploaded media before failing, including init objects. Sticky even if retention or a later counter correction removes those bytes. Inherited duration from earlier generations does not count. |

Failure events use the same generation identity and BYOB coordinates as the
start event. Their rendition list describes acknowledged media, which may lack
exported playlists. Neither `masterPlaylist` nor `mediaWritten: true` promises
a playable export. The bucket's free-text refusal stays on the recording rule
for the dashboard and is never included in the webhook.

A BYOB generation uses
`<destination>/<broadcast path>/@<instance>/<generation>/` inside the configured
bucket. An empty destination starts at the bucket root. The event's
`objectPrefix` is authoritative; use it instead of reconstructing the layout.
Playlist URIs are relative to that prefix. They percent-encode path components,
so decode those components when building an S3 object key. For example, a
rendition named `audio/main +` has URI `audio/main%20%2B/media.m3u8` and object
key `<objectPrefix>audio/main +/media.m3u8`. Read with your own bucket
credentials; these fields grant no access and are present even with platform
delivery disabled.

Managed recordings omit both `bucket` and `objectPrefix`. Resolve playlist URIs
against the directory of the authorized recording playback URL returned by the
API. Playback still requires its token; the event grants no bucket access.

`recording.started` carries an empty `renditions` array: admission happens before
the recorder reports its init objects. It does not promise that playlists are
playable yet. `recording.ready` lists the
final exported renditions, including audio, and omits init-only renditions that
have no media segments. Dimensions can be absent for audio or unknown video
resolution. Each rendition's `playlist` uses the same base as `masterPlaylist`.

Broadcasts are aggregated by path across reporting edges. A publisher leaving
normally produces `unannounced`; API deployments and edge/config-connection
loss can produce `connection_lost`, followed by another start when the path
is reported live again. A publisher that restarts on the same path without
leaving it, such as a reconnecting encoder whose new session replaces the old
one, produces `unannounced` followed by a new start. Starting the first subscription to
`broadcast.started`, including re-enabling it after all such subscriptions
were off, backfills currently live paths with `backfill: true`. Reconnect snapshots
can also discover a path this way; a backfill does not promise that it just
started publishing.

## Verify before processing

Save the endpoint's signing secret from its create response or its idempotent
replay. It is omitted from later reads. Treat the complete `whsec_...` string as the
UTF-8 HMAC key; do not hex-decode its suffix.

`Moq-Signature` contains `t=<Unix seconds>,v1=<lowercase hex>`. The MAC is
HMAC-SHA256 of the timestamp text, a literal `.`, and the **raw request body**.
Do not parse and reserialize JSON before checking it. Reject timestamps more
than five minutes in the past or future, using your server's clock. The
signature timestamp is refreshed on every attempt; it is independent of the
envelope's `created_at`.

This JavaScript verifier uses Web Crypto, available in Node 22+ and Workers.
Pass the raw body from `await request.text()`, the header (or `""` if absent),
and `Math.floor(Date.now() / 1000)` from your own server as `nowSeconds`.
Only parse JSON and process the event after it returns `true`.

<!-- webhook-verifier -->
```js
export async function verifyWebhook(secret, header, body, nowSeconds) {
  const fields = header.split(",").map((part) => part.trim().split("="));
  const timestamp = fields.find(([name]) => name === "t")?.[1];
  if (!timestamp || !/^\d+$/.test(timestamp)) return false;
  const t = Number(timestamp);
  if (!Number.isSafeInteger(t) || !Number.isSafeInteger(nowSeconds) || Math.abs(nowSeconds - t) > 300) return false;
  const encoder = new TextEncoder();
  const key = await crypto.subtle.importKey(
    "raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"],
  );
  for (const [name, mac] of fields) {
    if (name !== "v1" || !mac || !/^[a-f0-9]{64}$/.test(mac)) continue;
    const signature = Uint8Array.from(mac.match(/../g), (byte) => Number.parseInt(byte, 16));
    if (await crypto.subtle.verify("HMAC", key, signature, encoder.encode(`${timestamp}.${body}`))) return true;
  }
  return false;
}
```
<!-- /webhook-verifier -->

Persist the verified event, deduplicated on `id`, before replying with a
`2xx`. Process it asynchronously if needed. A successful response whose
acknowledgement is lost can lead to another delivery, so a five-minute
signature check alone does not prevent duplicate processing.

## IVS event adapter

`@moq/pro` exports `toIvsEvents(webhook, context)`, a pure typed mapper based
on the [AWS IVS EventBridge reference](https://docs.aws.amazon.com/ivs/latest/LowLatencyUserGuide/eventbridge.html).
It accepts an already verified JSON webhook and returns these events in order:

| Webhook | IVS `detail-type` | Detail |
| --- | --- | --- |
| `broadcast.started` | `IVS Stream State Change` | `event_name`: `Session Created`, then `Stream Start` |
| `broadcast.ended` | `IVS Stream State Change` | `event_name`: `Stream End`, then `Session Ended` |
| `recording.started` | `IVS Recording State Change` | `recording_status`: `Recording Start` |
| `recording.ready` | `IVS Recording State Change` | `recording_status`: `Recording End` |
| `recording.failed` | `IVS Recording State Change` | `recording_status`: `Recording End Failure` if `mediaWritten`, otherwise `Recording Start Failure` |

Test deliveries and unknown event types return an empty array. No Stream Health
Change or Starvation Start/End is emitted: an on-demand relay cannot distinguish
a stalled broadcast from an idle one. Session Created approximates a successful
broadcast announcement; a refused publisher has no broadcast event. Stream Start
means the path is announced, not that an HLS segment is ready. A backfilled start
emits the same pair, using the source observation time, and may describe a stream
that started earlier. Stream End/Session Ended describe the last edge's loss of
the path, including `connection_lost`; they do not prove encoder disconnection.
Recording End means the generation's export is ready, which may follow ingest end.

The required `context` has your `source`, `account`, and `region`, plus optional
`channelArn(path, project)` to set `resources: [arn]`. Without that function, or
when it returns `undefined`, resources are empty. No channel ARN is fabricated.
Use a custom source such as `com.example.moq`; `aws.` sources are reserved and
the mapper refuses them. Change your EventBridge rule's `source` from `aws.ivs`
to your custom source, retaining the IVS detail-type and detail filters. Existing
resource filters require your actual path-to-channel mapping.

Broadcast payloads have no route epoch. For each broadcast event, also supply
`context.broadcastEpoch` from your own durable historical publisher-instance
context, matching both start and end to that instance. Missing context is
refused. Do not substitute the path, event timestamp, a random identity, or a
lookup of the currently active publisher for a delayed historical event. If your
controller cannot correlate those events, it cannot use the broadcast mapping.
Recording events derive the epoch from `recordingId`, independent of that
context. `channel_name` is the project-relative path; `stream_id` is
`<project>/<path>/<route epoch>`, since publishers can reuse an epoch on another
path or project. Each generation gets its own `recording_session_id`,
`<project>/<generationId>`, even after a handoff under the same route epoch.

`time` is `data.at`. Local mapped IDs are `<webhook id>:<zero-based output index>`
and remain stable across retries. Recording bucket and prefix are copied from
the generation's `bucket` and authoritative `objectPrefix`; missing older fields
and managed storage coordinates stay absent. No prefix is reconstructed.
Start duration is zero; End duration copies `durationMs` when present. Failure
duration is unknown and omitted, and its status reason is the webhook's `reason`.
`mediaWritten` can include init objects and does not promise playable media.
Optional regression diagnostics do not turn a ready export into a failure.
IVS thumbnail, S3 layout, merged-session stream lists, and failure codes are not
invented. Handlers that depend on those IVS features need to account for these
differences.

### Signed Worker or Lambda receiver

Install `@moq/pro`, `@aws-sdk/client-eventbridge`, and the AWS SDK's
`@smithy/service-error-classification` in an ESM package
(`"type": "module"` in `package.json`). Copy the Web Crypto
`verifyWebhook` above into `verify-webhook.js` and save this shared Request
handler as `forward-ivs.js`. It verifies the raw body **before** parsing,
looking up context, or forwarding. Supply `broadcastEpochFor(webhook)` from
your publisher controller's durable history; it returns that instance's route
epoch and refuses unresolved instances. Only broadcast events need that lookup.
The SDK decides which event types to map, so updating it also updates the receiver.

<!-- ivs-receiver -->
```js
import { toIvsEvents } from "@moq/pro";
import { PutEventsCommand } from "@aws-sdk/client-eventbridge";
import { isThrottlingError, isTransientError } from "@smithy/service-error-classification";
import { verifyWebhook } from "./verify-webhook.js";

export async function forwardIvs(request, { secret, project, context, broadcastEpochFor, bridge, bus }) {
  if (request.method !== "POST") return new Response(null, { status: 405 });
  const body = await request.text();
  if (!secret || !await verifyWebhook(secret, request.headers.get("Moq-Signature") ?? "", body, Math.floor(Date.now() / 1000))) {
    return new Response("Invalid signature", { status: 401 });
  }
  let webhook;
  try { webhook = JSON.parse(body); }
  catch { return new Response("Invalid JSON", { status: 400 }); }
  if (webhook?.project !== project) return new Response("Wrong project", { status: 403 });
  try {
    const resolved = { ...context };
    if (webhook.type === "broadcast.started" || webhook.type === "broadcast.ended") {
      resolved.broadcastEpoch = await broadcastEpochFor(webhook);
    }
    const events = toIvsEvents(webhook, resolved);
    if (!events.length) return new Response(null, { status: 204 });
    const result = await bridge.send(new PutEventsCommand({
      Entries: events.map((event) => ({
        EventBusName: bus,
        Source: event.source,
        DetailType: event["detail-type"],
        Time: new Date(event.time),
        Resources: event.resources,
        Detail: JSON.stringify({ ...event.detail, moq_event_id: event.id }),
      })),
    }));
    if (result.FailedEntryCount !== 0 || result.Entries?.length !== events.length ||
        result.Entries.some((entry) => entry.ErrorCode || !entry.EventId)) {
      const failed = result.Entries?.filter((entry) => entry.ErrorCode || !entry.EventId) ?? [];
      const retryable = failed.length > 0 && failed.every((entry) =>
        ["InternalFailure", "ThrottlingException"].includes(entry.ErrorCode));
      return new Response("EventBridge refused entries", { status: retryable ? 503 : 422 });
    }
    return new Response(null, { status: 204 });
  } catch (error) {
    const retryable = error instanceof Error && (isThrottlingError(error) || isTransientError(error));
    return new Response("Context or forwarding failed", { status: retryable ? 503 : 422 });
  }
}
```
<!-- /ivs-receiver -->

Put the shared server-side configuration in `ivs-config.js`. The imported
`publisher-history.js` is your controller integration: it exports
`broadcastEpochFor` and the `channelArn` mapping described above (export
`channelArn = undefined` if no ARN mapping is needed). Set the environment values for your
expected project, signing secret, custom source, destination account/region,
and existing event bus ARN. Keep credentials and signing secrets server-side.

<!-- ivs-config -->
```js
import { EventBridgeClient } from "@aws-sdk/client-eventbridge";
import { broadcastEpochFor, channelArn } from "./publisher-history.js";

export function ivsConfig(env, credentials) {
  const region = env.AWS_REGION;
  return {
    secret: env.MOQ_WEBHOOK_SECRET,
    project: env.MOQ_PROJECT_ID,
    context: { source: env.EVENT_SOURCE, account: env.AWS_ACCOUNT_ID, region, channelArn },
    broadcastEpochFor,
    bridge: new EventBridgeClient({ region, credentials, maxAttempts: 1 }),
    bus: env.EVENT_BUS_ARN,
  };
}
```
<!-- /ivs-config -->

A Worker's `fetch(request, env)` imports `forwardIvs` and `ivsConfig`, then calls
`forwardIvs(request, ivsConfig(env, credentials))` with its AWS credential
provider. A Lambda execution role supplies credentials through the AWS SDK's
default provider. Grant only `events:PutEvents` on your existing bus.

The mapper returns a complete envelope for direct handlers, including required,
nonempty account/region context. PutEvents generates those envelope fields and
a fresh `id` itself; configure the bus credentials and region to match the
context. The pure mapper cannot verify the AWS destination. The recipe copies
the stable mapped ID into `detail.moq_event_id` for downstream deduplication.

For a Lambda Function URL's payload v2, save this as `index.mjs`. The imports
and config wire it to the two modules above, and a base64 body is preserved
before constructing the Request:

<!-- ivs-lambda -->
```js
import { forwardIvs } from "./forward-ivs.js";
import { ivsConfig } from "./ivs-config.js";

const config = ivsConfig(process.env);

export async function handler(input) {
  if (input.requestContext.http.method !== "POST") return { statusCode: 405, body: "" };
  const body = input.isBase64Encoded ? Buffer.from(input.body ?? "", "base64") : input.body ?? "";
  const response = await forwardIvs(new Request("https://receiver.example/webhook", {
    method: input.requestContext.http.method,
    headers: input.headers,
    body,
  }), config);
  return { statusCode: response.status, body: await response.text() };
}
```
<!-- /ivs-lambda -->

The recipe makes one forwarding attempt. Failures the AWS SDK's own retry policy
classifies as throttling or transient (connection resets, DNS and timeout
failures, `429`, `500`, `502`, `503`, `504`), and entries refused with
`InternalFailure` or `ThrottlingException`, return `503`, leaving retries to
moq.pro's shared delivery policy. Missing context, permanent refusals,
unrecognized errors and malformed acknowledgements return `422` and end delivery. A retry can repeat entries that already succeeded:
handlers must deduplicate `detail.moq_event_id`
and tolerate unordered transitions. The pair's array order does not guarantee
EventBridge delivery order. For stronger receiver-side deduplication, persist
verified events and forwarding progress in your own durable inbox before
acknowledging. EventBridge can accept events for a nonexistent bus without
reporting failure, so verify your bus and rule configuration separately. See
[PutEvents handling](https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-putevents.html).

## Delivery, retries, and disabling

Delivery attempts are at least once while an event remains queued, and events
are unordered. Retrying an edge report after its acknowledgement was lost can
rarely emit the same broadcast transition with a new `id`. Treat those events
as idempotent state observations as well as deduplicating HTTP retries.
Retry and retention limits mean this is not an event archive.
Keep your receiver idempotent and reconcile important state with the API.

Each request has a 10-second timeout. Any `2xx` acknowledges it. Network
failures, timeouts, `408`, `425`, `429`, and `5xx` except `501` and `505`
retry; all other responses are final. Redirects are not followed.
A delivery gets at most eight attempts, including the first:

| Retry attempt | Normal delay after the previous attempt |
| --- | --- |
| 2 | 15–30 seconds |
| 3 | 30–60 seconds |
| 4 | 1–2 minutes |
| 5 | 2–4 minutes |
| 6 | 4–8 minutes |
| 7 | 8–16 minutes |
| 8 | 16–32 minutes |

A numeric `Retry-After` is a floor on that delay, up to one hour; it can
extend the overall retry window. HTTP-date values are ignored. A requested
delay above one hour ends the delivery as failed rather than retrying early.

After 20 consecutive **failed deliveries**, not individual attempts, the
endpoint disables. A successful delivery resets that count. Disabling fails
its remaining pending deliveries. Re-enabling resets the failure count and
resumes new events; it does not replay old failed deliveries. Editing an
endpoint never rotates its secret. To re-enable an endpoint, request
`enabled: true`.

Each source outbox and the project's pending delivery queue hold at most
1,000 rows. Overflow drops new work and increments the `dropped` counter
shown in webhook settings and the endpoint-list response. A broadcast that
starts and ends during an edge restart, or beyond its outage buffer, can be
missed. Backfill reports currently live paths, not a history of everything
that happened while reporting was unavailable.

## Path patterns

The pattern is relative to the project. `cam/*` matches one segment after
`cam/`; `cam/**` matches `cam` itself and every descendant; the default
`**` matches ordinary paths. A partial wildcard such as `draft-*` matches
part of one segment. Use at most one `**` in a pattern. A segment beginning with `.` is hidden and matches only
where the pattern names that segment literally: `**` skips `.private/live.hang`,
while `.private/**` can match it. Platform `.pro` paths never produce customer
webhooks. Tests bypass the path pattern.

## Rotate a signing secret

There is no in-place secret rotation. Create a replacement endpoint with the
same URL, event types and pattern; save its new secret. Leave a free slot
under the 10-endpoint limit for this overlap. During it,
accept signatures under either secret and deduplicate on the event `id`,
since both endpoints can deliver it. Confirm a test delivery with the new
secret. Drain or deliberately discard the old endpoint's pending deliveries,
then delete the old endpoint and stop accepting its secret after any request
already in flight has finished. Deletion discards its queued deliveries;
there is no automatic replay to the replacement endpoint.

Use the [API reference](/api) for endpoint and delivery-log operations,
and [Settings → Webhooks](https://moq.pro/project) to inspect failures and re-enable delivery.
