Skip to content

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:

FormatURL must start withBody
slackhttps://hooks.slack.com/{ "text": ..., "unfurl_links": false, "unfurl_media": false }
discordhttps://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 ….

EventMessage 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.readyA recording of `cam/live.hang` is ready
recording.failedA recording of `cam/live.hang` failed (bucket_refusal)
webhook.testTest 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 publishes each payload schema.

EventdataMeaning
broadcast.startedpath, at, optional backfill: trueA path became live, or was discovered in a live-path snapshot (backfill: true).
broadcast.endedpath, at, reasonThe last reporting edge lost the path. reason is unannounced or connection_lost.
recording.startedpath, at, recordingId, generationIdOne physical recording generation started.
recording.readypath, at, recordingId, generationIdThat generation's portable export finished successfully.
recording.failedpath, at, recordingId, generationId, reason, mediaWrittenThe generation could not record or its export failed permanently.
webhook.testendpointA 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.

FieldMeaning
bucketBYOB bucket name, snapshotted when the generation starts.
objectPrefixBYOB generation's object-key prefix, including its trailing /.
masterPlaylistMaster playlist URI, currently index.m3u8.
renditionsArray of { name, kind, playlist, width?, height? }. kind is video or audio; known dimensions are in pixels.
durationMsOn recording.ready, the recorded media duration in milliseconds, independent of elapsed wall time.
regressionOn 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:

FieldMeaning
reasonClosed 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.
mediaWrittenWhether 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.

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;
}

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. It accepts an already verified JSON webhook and returns these events in order:

WebhookIVS detail-typeDetail
broadcast.startedIVS Stream State Changeevent_name: Session Created, then Stream Start
broadcast.endedIVS Stream State Changeevent_name: Stream End, then Session Ended
recording.startedIVS Recording State Changerecording_status: Recording Start
recording.readyIVS Recording State Changerecording_status: Recording End
recording.failedIVS Recording State Changerecording_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.

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 });
  }
}

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.

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,
  };
}

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:

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() };
}

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.

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 attemptNormal delay after the previous attempt
215–30 seconds
330–60 seconds
41–2 minutes
52–4 minutes
64–8 minutes
78–16 minutes
816–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 for endpoint and delivery-log operations, and Settings → Webhooks to inspect failures and re-enable delivery.