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 an endpoint
Section titled “Create an endpoint”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 } }}urlmust usehttpsand 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
ValidationErrorwith codeINVALID_ARGUMENT. If the DNS lookup fails in a way that can clear, such as a timeout, theValidationErrorhas codeFAILED_PRECONDITIONand you can retry the request. Updating an endpoint’surlruns the same checks. eventTypeslists exact type names from the catalog, or"*"for every type. ThewebhookEventTypesquery returns the valid names. Wildcards such as"stream.*"are rejected.descriptionis free text of at most 500 characters.apiVersiondefaults 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.
The request
Section titled “The request”Every delivery is a POST with these headers:
| Header | Value |
|---|---|
content-type | application/json |
user-agent | FrameWorks-Webhooks/1.0 |
webhook-id | The event ID. It is the same for every attempt, every replay, and every endpoint. |
webhook-timestamp | Unix time in seconds when this attempt was signed. |
webhook-signature | One 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-5f8c1d2e4a67webhook-timestamp: 1789826608webhook-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).
| Field | Meaning |
|---|---|
id | The event ID, equal to webhook-id. Deduplicate on it. |
type | The event type from the catalog. |
api_version | The payload version the endpoint pins, "v1". |
created_at | When the change committed, RFC 3339 in UTC with nine fractional digits. Parse it to order events; do not compare the strings. |
data | The 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 withBigInt,int(), orstrconv.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 isnull, for exampleexpiresAton anapi_token.createdevent for a token that never expires.
Within a version, fields are only added. Parse bodies so that unknown fields are ignored.
Verify the signature
Section titled “Verify the signature”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 expressimport 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);// go get github.com/standard-webhooks/standard-webhooks/librariespackage main
import ( "encoding/json" "io" "log" "net/http" "os"
standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go")
type event struct { ID string `json:"id"` Type string `json:"type"` APIVersion string `json:"api_version"` CreatedAt string `json:"created_at"` Data json.RawMessage `json:"data"`}
func main() { wh, err := standardwebhooks.NewWebhook(os.Getenv("FRAMEWORKS_WEBHOOK_SECRET")) if err != nil { log.Fatal(err) } http.HandleFunc("/webhooks/frameworks", func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { http.Error(w, "read failed", http.StatusBadRequest) return } if err := wh.Verify(body, r.Header); err != nil { http.Error(w, "invalid signature", http.StatusUnauthorized) return } var ev event if err := json.Unmarshal(body, &ev); err != nil { http.Error(w, "invalid body", http.StatusBadRequest) return } if alreadyProcessed(ev.ID) { w.WriteHeader(http.StatusNoContent) return } switch ev.Type { case "clip.ready": var data struct { Artifact struct { ArtifactID string `json:"artifactId"` } `json:"artifact"` SizeBytes string `json:"sizeBytes"` // int64 values arrive as strings } if err := json.Unmarshal(ev.Data, &data); err != nil { http.Error(w, "invalid data", http.StatusBadRequest) return } enqueueClipReady(data.Artifact.ArtifactID, data.SizeBytes) } markProcessed(ev.ID) w.WriteHeader(http.StatusNoContent) }) log.Fatal(http.ListenAndServe(":8080", nil))}Inside the FrameWorks monorepo, pkg/webhooksig implements the same scheme (ParseSecret, then
Key.Verify) and is what the delivery service signs with.
# pip install standardwebhooks flaskimport os
from flask import Flask, requestfrom standardwebhooks.webhooks import Webhook, WebhookVerificationError
app = Flask(__name__)wh = Webhook(os.environ["FRAMEWORKS_WEBHOOK_SECRET"])
@app.post("/webhooks/frameworks")def frameworks_webhook(): try: # get_data() returns the raw bytes that were signed. event = wh.verify(request.get_data(), dict(request.headers)) except WebhookVerificationError: return "", 401
if already_processed(event["id"]): return "", 204 if event["type"] == "clip.ready": artifact = event["data"]["artifact"] size_bytes = int(event["data"]["sizeBytes"]) # int64 values arrive as strings publish_clip(artifact["artifactId"], size_bytes) mark_processed(event["id"]) return "", 204alreadyProcessed, markProcessed, and the handlers stand for your own storage and logic.
Responding
Section titled “Responding”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.
Delivery guarantees
Section titled “Delivery guarantees”- 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.idlecan arrive before thestream.liveof the same session. Usecreated_atand 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-idand body. Onlywebhook-timestampand the signature change per attempt.
Retries
Section titled “Retries”A failed delivery is retried on this schedule, each delay varied by up to 10 percent either way:
| After attempt | Next attempt in | After attempt | Next attempt in |
|---|---|---|---|
| 1 | 30 seconds | 7 | 4 hours |
| 2 | 2 minutes | 8 | 8 hours |
| 3 | 10 minutes | 9 | 12 hours |
| 4 | 30 minutes | 10 | 24 hours |
| 5 | 1 hour | 11 | 24 hours |
| 6 | 2 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.
Auto-disable
Section titled “Auto-disable”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.
Delivery log and replay
Section titled “Delivery log and replay”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.
Test an endpoint
Section titled “Test an endpoint”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.
Rotate the secret
Section titled “Rotate the secret”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.
Dashboard and agents
Section titled “Dashboard and agents”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.