Skip to main content

Webhooks

Webhooks push events to your server so you don’t have to poll. Register an HTTPS endpoint, subscribe it to event names, and Scripe POSTs a signed JSON payload every time a matching event fires in the workspace. The most common use: subscribe to job.completed instead of polling GET /v1/jobs/{jobId}, and to post.created / post.scheduled to mirror content state into your own system. Endpoint management is REST, and all six operations require the webhooks:manage scope — reads included. Full request/response schemas are in the OpenAPI reference tab under Webhooks.

Event catalogue

Job side effects fire both the lifecycle event and the resource-specific one — a finished generation emits post.created and job.completed; subscribe to either. Subscribing to an unknown event name fails with 400 invalid_request, so a typo can’t create an endpoint that never receives anything. The catalogue grows additively — write your receiver to ignore type values it doesn’t recognise.

Payload

Every delivery POSTs one event envelope:
  • id is unique per event. Deliveries are at-least-once — dedupe on id if a double-delivery would hurt you.
  • type is the discriminator for data’s shape.
  • projectId is null for events without a project scope.
  • workspaceId is unprefixed and its shape depends on the caller. It carries whichever id the originating caller was keyed by: for an API-key write, the bare internal workspace id (a 16-character hex string, as above); for an OAuth write, the Clerk org id (org_…). It therefore does not reliably equal the workspace.id from /v1/workspaces/me, which is always the org id. Key your own records off the endpoint you registered rather than joining on this field.
Delivery request headers:

Verifying signatures

Every delivery is signed with the endpoint’s secret (shown once at create and once at rotate): HMAC-SHA-256 over "<t>.<raw body>", hex encoded, in the Webhook-Signature header as t=<unix seconds>,v1=<hex>.
Verify against the raw request body, before any JSON parsing or re-serialisation.

Delivery and retries

  • Timeout: your receiver has 10 seconds to respond. Return a 2xx quickly and process async — anything else counts as a failure.
  • Retry schedule: five attempts per delivery — the initial call plus four retries at 30 s, 5 min, 30 min, and 4 h after the preceding failure. If the fifth attempt fails, the delivery is marked permanently failed roughly 4 h 35 min after the first attempt.
  • Auto-disable: five consecutive permanently-failed deliveries (not one delivery’s retry chain) disable the endpoint. The disabledReason is free-form prose that embeds the streak threshold, today "repeated_failures (>= 5 consecutive deliveries)" — match on the repeated_failures prefix, never on the whole string, since the threshold is interpolated from the retry schedule. Re-enable it from the dashboard or via PATCH once your receiver is healthy. Events routed to a disabled endpoint are recorded as permanently failed with an “Endpoint inactive” reason rather than being retried. The endpoint_disabled error code is reserved for this condition but is not currently returned by any endpoint.
  • Redirects count as failures. The worker sends redirect: "manual" and treats any 3xx as a failed attempt, because the SSRF check pinned only the first hop — following a Location header would re-resolve it unchecked. A receiver that 302s to its canonical URL therefore burns all five attempts and, after five such deliveries, gets auto-disabled. Register the final URL, not one that redirects to it.
  • Ordering is not guaranteed. Use createdAt (and your own state) rather than arrival order.
  • Test-mode keys do not suppress deliveries — yet. A write made with a scripe_sk_test_* key enqueues an ordinary delivery and the worker calls your receiver for real. Suppression (recording the delivery as “would have called” without the HTTP call) is planned but not shipped, so point CI at a receiver you own, not at production.

Managing endpoints

  • webhooks:manage is not grantable to an API key — it is the one REST scope the key wizard does not offer (see auth.md §1.2) — so this call needs an OAuth token. A scripe_sk_* key always gets 403 scope_missing here.
  • The 200 response carries the plaintext signing secret exactly once. Store it immediately; every later read exposes only secretLast4. If you lose it, rotate.
  • projectId scopes the endpoint to one project; null (the default) delivers events for every project in the workspace.
  • URL constraints: HTTPS only, and the hostname must resolve to a public IP — loopback, link-local, private, and CGNAT ranges are rejected with ssrf_blocked. The resolved IP is pinned for ~24 h, and every delivery attempt re-resolves the hostname: a changed but still routable address re-pins and the delivery proceeds (load balancers and CDNs cycle IPs), while a private or non-routable result fails that attempt into the ordinary retry chain. Detection alone does not disable the endpoint — only the five-consecutive-failure streak above does.
  • Rotation (/rotate-secret) mints the new secret and returns its plaintext once. There is no dual-secret grace window: the endpoint stores exactly one secret, and every delivery after the call is signed with the new one only. Sequence the cutover as: teach your receiver to accept either the current secret or a second one from config, call /rotate-secret, deploy the returned secret into that second slot immediately, then drop the old one.

Receiver checklist

  1. Return 2xx within 10 seconds; do the work async.
  2. Verify Webhook-Signature against the raw body, with a timestamp staleness check.
  3. Dedupe on id (at-least-once delivery).
  4. Ignore unknown type values and unknown fields in data.
  5. Alert on your own 4xx/5xx responses — each delivery gives up after ~4.6 hours, and five consecutive give-ups disable the endpoint.