Skip to content

Webhooks

A webhook endpoint is an HTTPS URL that FrameWorks sends your account’s public events to. Every event in the event catalog can be delivered: streams going live, clips and uploads becoming ready, invoices, payments, and the rest. The payloads are the same ones the tenantEvents subscription returns; webhooks deliver them to a server that is not connected.

Each request is signed with the Standard Webhooks scheme, so the official verification libraries for TypeScript, Go, Python, and other languages work without FrameWorks-specific code.

Create endpoints in the dashboard under Developer → Webhooks, or with the API:

mutation CreateEndpoint {
createWebhookEndpoint(
input: {
url: "https://hooks.example.com/frameworks"
description: "Clip pipeline"
eventTypes: ["stream.live", "stream.idle", "clip.ready", "clip.failed"]
}
) {
__typename
... on WebhookEndpointSecret {
secret
endpoint {
id
status
createdAt
}
}
... on ValidationError {
message
field
}
}
}
  • url must use https and name a public host. URLs with credentials or a fragment are rejected, as are hosts that are, or resolve to, private, loopback, link-local, shared (100.64.0.0/10), reserved, documentation, or cloud metadata addresses. A NAT64 (64:ff9b::/96) or 6to4 (2002::/16) address is judged by the IPv4 address it embeds, and IPv4-compatible, site-local, Teredo, and local-use NAT64 addresses are rejected. The same check runs again on every connection, so a DNS answer that later changes to a private address is refused at send time.
  • The host must resolve. A host that does not exist returns a ValidationError with code INVALID_ARGUMENT. If the DNS lookup fails in a way that can clear, such as a timeout, the ValidationError has code FAILED_PRECONDITION and you can retry the request. Updating an endpoint’s url runs the same checks.
  • eventTypes lists exact type names from the catalog, or "*" for every type. The webhookEventTypes query returns the valid names. Wildcards such as "stream.*" are rejected.
  • description is free text of at most 500 characters.
  • apiVersion defaults to "v1", the only payload version today. An endpoint keeps the version it was created with; a breaking payload change ships as a new version you opt into.

An account can have at most 10 endpoints. Creating an 11th returns a ValidationError.

The response carries the signing secret, whsec_ followed by the base64 encoding of 32 random bytes. Store it now: no query ever returns it again. If you lose it, rotate it.

updateWebhookEndpoint changes the URL, description, or event types; omitted fields keep their value. deleteWebhookEndpoint removes the endpoint together with its secrets and delivery log.

Every delivery is a POST with these headers:

HeaderValue
content-typeapplication/json
user-agentFrameWorks-Webhooks/1.0
webhook-idThe event ID. It is the same for every attempt, every replay, and every endpoint.
webhook-timestampUnix time in seconds when this attempt was signed.
webhook-signatureOne or more space-separated v1,<base64 HMAC-SHA256> signatures, one per live secret.

The body of a clip.ready event:

{
"id": "0199a3c2-7e41-7d2a-9b3e-5f8c1d2e4a67",
"type": "clip.ready",
"api_version": "v1",
"created_at": "2026-09-19T14:03:27.412000000Z",
"data": {
"artifact": {
"artifactId": "c7f3a9e2b41d4f6a8e0b5d2c9a1f7e34",
"kind": "ARTIFACT_KIND_CLIP",
"streamId": "5b0e6a2c-3f1d-4c8e-9a7b-2d4f6e8a0c13",
"playbackId": ""
},
"durationMs": "30000",
"sizeBytes": "7340032"
}
}

It is sent compact, on one line; it is indented here for reading. The signed headers of that body, sent with the example secret whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw, were:

webhook-id: 0199a3c2-7e41-7d2a-9b3e-5f8c1d2e4a67
webhook-timestamp: 1789826608
webhook-signature: v1,He6+qzzEV3oT+kg/zD4Sd/hJNCLcjDehxhA4waNbaqw=

artifact.playbackId is always empty on clip, recording, and upload events. Look the playback ID up by artifactId (see Events).

FieldMeaning
idThe event ID, equal to webhook-id. Deduplicate on it.
typeThe event type from the catalog.
api_versionThe payload version the endpoint pins, "v1".
created_atWhen the change committed, RFC 3339 in UTC with nine fractional digits. Parse it to order events; do not compare the strings.
dataThe payload of the type, with the fields listed for it in the catalog.

data is the event’s payload message rendered with the standard protobuf JSON mapping:

  • Field names are lowerCamelCase, the names in the catalog tables (streamId, sizeBytes).
  • 64-bit integers are strings: "sizeBytes": "7340032", and money as {"amountMinor": "1250", "currency": "EUR"}. JSON numbers lose precision above 2^53, so parse them with BigInt, int(), or strconv.ParseInt. 32-bit integers are numbers.
  • Enum values are their full names, such as "ARTIFACT_KIND_CLIP".
  • Timestamps are RFC 3339 strings.
  • Every field is present. A field without a value carries its zero value ("", "0", false, or the first enum value), an empty list is [], and an unset nested object or timestamp is null, for example expiresAt on an api_token.created event for a token that never expires.

Within a version, fields are only added. Parse bodies so that unknown fields are ignored.

Verify against the raw request bytes, before any JSON parsing: re-serializing the body changes the bytes and breaks the signature. The libraries below also reject a webhook-timestamp more than five minutes away from your clock, which limits replay of a captured request.

The TypeScript, Go, and Python SDKs wrap these libraries and also parse the verified body into a typed event. The examples below use the libraries directly, for receivers that do not use an SDK.

// npm install standardwebhooks express
import express from "express";
import { Webhook, WebhookVerificationError } from "standardwebhooks";
type FrameWorksEvent = {
id: string;
type: string;
api_version: string;
created_at: string;
data: Record<string, unknown>;
};
const wh = new Webhook(process.env.FRAMEWORKS_WEBHOOK_SECRET!);
const app = express();
// express.raw keeps the body as the bytes that were signed.
app.post("/webhooks/frameworks", express.raw({ type: "application/json" }), async (req, res) => {
let event: FrameWorksEvent;
try {
event = wh.verify(req.body.toString("utf8"), {
"webhook-id": req.header("webhook-id") ?? "",
"webhook-timestamp": req.header("webhook-timestamp") ?? "",
"webhook-signature": req.header("webhook-signature") ?? "",
}) as FrameWorksEvent;
} catch (err) {
if (err instanceof WebhookVerificationError) {
res.sendStatus(401);
return;
}
throw err;
}
if (await alreadyProcessed(event.id)) {
res.sendStatus(204);
return;
}
switch (event.type) {
case "stream.live":
await markStreamLive(event.data.streamId as string);
break;
case "clip.ready": {
const artifact = event.data.artifact as { artifactId: string };
await publishClip(artifact.artifactId, BigInt(event.data.sizeBytes as string));
break;
}
}
await markProcessed(event.id);
res.sendStatus(204);
});
app.listen(8080);

alreadyProcessed, markProcessed, and the handlers stand for your own storage and logic.

A delivery succeeds when your endpoint answers with any 2xx status within 10 seconds. Answer first and do slow work afterwards, for example by queueing the event.

Everything else is a failed attempt:

  • any other status, including 3xx: redirects are not followed, so update the URL instead;
  • no response within 10 seconds, including connecting and the TLS handshake;
  • a DNS, connection, or TLS failure (TLS 1.2 or later with a certificate valid for the host);
  • a destination that is not a public address.

The first 1 KiB of your response body is stored with each attempt and shown in the delivery log, which helps when your endpoint returns an error message.

  • At least once. A delivery can arrive more than once, for example when your endpoint took the event but the response was lost. Deduplicate on id.
  • No ordering. Up to 5 requests to one endpoint are in flight at a time and retries interleave with new events, so a stream.idle can arrive before the stream.live of the same session. Use created_at and your own state, or read the current state through the API.
  • Same ID everywhere. An event sent to two endpoints, retried, or replayed carries the same webhook-id and body. Only webhook-timestamp and the signature change per attempt.

A failed delivery is retried on this schedule, each delay varied by up to 10 percent either way:

After attemptNext attempt inAfter attemptNext attempt in
130 seconds74 hours
22 minutes88 hours
310 minutes912 hours
430 minutes1024 hours
51 hour1124 hours
62 hours

That is 12 attempts over about 3 days and 4 hours. When the 12th attempt fails, the delivery becomes FAILED and is not sent again unless you replay it. An attempt refused because your host resolves to an address FrameWorks does not send to (error class blocked_destination) is not retried: the delivery becomes FAILED after that attempt.

An endpoint that keeps failing is disabled automatically once both hold: at least 20 attempts in a row have failed, and the first of them was at least 5 days ago, with no successful delivery since. One success resets the count.

When that happens, the endpoint’s status becomes DISABLED with reason FAILING, its pending deliveries become SKIPPED, and the billing contact of the account receives an email linking to the endpoint in the dashboard. New events are not delivered while it is disabled.

disableWebhookEndpoint disables an endpoint yourself (reason USER) and also skips its pending deliveries. enableWebhookEndpoint turns delivery back on and resets the failure count. Enabling never resends what was skipped: replay the gap with a range replay once your endpoint works.

Every delivery and every attempt is kept for 30 days. List them with webhookDeliveriesConnection, newest first, filtered by endpoint, status (PENDING, SUCCEEDED, FAILED, SKIPPED), event type, event ID, and creation time:

query FailedClipDeliveries($endpointId: ID!, $since: Time!) {
webhookDeliveriesConnection(
endpointId: $endpointId
statuses: [FAILED, SKIPPED]
eventType: "clip.ready"
createdAfter: $since
page: { first: 50 }
) {
nodes {
id
eventId
status
attempts
lastStatusCode
lastErrorClass
createdAt
}
pageInfo {
hasNextPage
endCursor
}
}
}

lastErrorClass is one of http_status, redirect, timeout, connection, tls, dns, blocked_destination, or internal. internal means FrameWorks could not send the delivery for a reason of its own: no request was made, no attempt is recorded, and it does not count toward disabling the endpoint. A delivery that cannot be sent at all is failed at once and can be replayed once the cause is fixed; one that can succeed later is retried after a delay.

blocked_destination means your host resolved to an address FrameWorks does not send to, such as a private, loopback, or platform address (see the url rules above). Every retry would be refused the same way, so the delivery is failed at once instead of retried. The attempt is recorded, its responseExcerpt gives the reason, and it counts toward disabling the endpoint. Point the host at a public address, then replay the delivery.

webhookDelivery(id) returns one delivery with attemptHistory: status code, error class, latency, and the response excerpt of each attempt.

replayWebhookDelivery(id) sends a finished delivery (succeeded, failed, or skipped) again. The receiver gets the same webhook-id and body, the attempt count starts over, and the full retry schedule applies again. A pending delivery or a test delivery cannot be replayed.

To recover from an outage, replay a time range:

mutation ReplayOutage($endpointId: ID!) {
replayWebhookDeliveries(
endpointId: $endpointId
createdAfter: "2026-09-18T22:00:00Z"
createdBefore: "2026-09-19T06:00:00Z"
) {
__typename
... on WebhookReplayResult {
replayedCount
hasMore
}
... on ValidationError {
message
}
}
}

It returns the endpoint’s failed and skipped deliveries created in [createdAfter, createdBefore) to pending, oldest first, at most 1,000 per call. Call it again while hasMore is true. Both replays need the endpoint to be enabled. Events older than 30 days are gone and cannot be replayed.

testWebhookEndpoint(id) sends a signed webhook.test event and waits for the result, which includes the attempt’s status code, latency, and response excerpt. Its body has the same shape, with data naming the endpoint:

{
"id": "0199a3d0-12c4-7b8e-a1f3-6e2d9c4b7a05",
"type": "webhook.test",
"api_version": "v1",
"created_at": "2026-09-19T14:10:02.118000000Z",
"data": { "endpointId": "0199a3b8-5c2e-7f41-8d6a-3b9e0f1c2d74" }
}

A test is sent once, without retries, and works on a disabled endpoint too. Each endpoint can be tested once every 10 seconds; a sooner call returns a RateLimitError. Test deliveries appear in the log with kind TEST.

rotateWebhookEndpointSecret(id) creates a new secret and returns it once. For the next 24 hours, every request carries two signatures in webhook-signature, one per secret:

webhook-signature: v1,<signature with the new secret> v1,<signature with the previous secret>

The verification libraries accept a request when any signature matches, so a receiver that still holds the previous secret keeps working while you deploy the new one. previousSecretExpiresAt on the endpoint shows when the previous secret stops signing. Pass revokePrevious: true to stop using the previous secret immediately, for example after it leaked.

The dashboard lists endpoints at Developer → Webhooks. An endpoint’s page shows its settings and failure state, and a delivery log filtered by status, event type, and time, with the attempt history of each delivery, single and range replay, testing, and secret rotation. Secrets appear once, after creation and rotation, with a copy button.

Agents connected over MCP can read endpoints and deliveries (list_webhook_endpoints, get_webhook_endpoint, list_webhook_deliveries, get_webhook_delivery, list_webhook_event_types). The tools that change an endpoint or send requests (create_webhook_endpoint, update_webhook_endpoint, delete_webhook_endpoint, enable_webhook_endpoint, disable_webhook_endpoint, rotate_webhook_secret, test_webhook_endpoint, replay_webhook_delivery, replay_webhook_deliveries) require a confirmation string in the call.