API v1 · Resources
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
| Event | Fires when |
|---|---|
note.created | A note was created via the API. |
post.created | A post was created — by a sync write or by a finished generation job. |
post.updated | A post's content/status changed via the API. |
post.scheduled | A post got a publish time (API write or MCP schedule_post). |
post.unscheduled | A post's publish time was removed. |
post.deleted | A post was permanently deleted (MCP delete_post). The payload carries the post's identity and last known status, never its body — the row is gone by the time this fires. |
source.created | A source was created via a synchronous write. |
source.deleted | A source was permanently deleted (delete_source / DELETE /v1/sources/:sourceId). Identity and last known status only, never the transcript — the rows are gone by the time this fires. derivedPostsKept counts the posts that deliberately survive their source. |
job.completed | Any async job reached DONE. Fired from the worker after the row settles. |
job.failed | Any async job reached FAILED. |
knowledge.indexed | A knowledge-base ingest finished indexing. |
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": "evt_3f9a1c7e2b5d40a8",
"type": "post.created",
"createdAt": "2026-08-13T09:14:02.000Z",
"workspaceId": "9f2c4b7ae1d06835",
"projectId": "proj_7c1e5a94b2f80d63",
"data": { /* the event's resource payload — see the event catalogue below */ }
}idis unique per event. Deliveries are at-least-once — dedupe onidif a double-delivery would hurt you.typeis the discriminator fordata's shape.projectIdisnullfor events without a project scope.workspaceIdis 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 theworkspace.idfrom/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:
User-Agent: Scripe-Webhooks/1.0
X-Scripe-Event: post.created
X-Scripe-Delivery: 3f9a1c7e2b5d40a8
X-Scripe-Attempt: 1
Webhook-Signature: t=1755075242,v1=5257a869e7…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>.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody: string, header: string, secret: string): boolean {
const m = /^t=(\d+),v1=([0-9a-f]+)$/.exec(header);
if (!m) return false;
const [, t, sig] = m;
// Reject stale timestamps to defeat replay (5 minutes is plenty).
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
return (
sig.length === expected.length &&
timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
);
}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
2xxquickly 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
disabledReasonis free-form prose that embeds the streak threshold, today"repeated_failures (>= 5 consecutive deliveries)"— match on therepeated_failuresprefix, never on the whole string, since the threshold is interpolated from the retry schedule. Re-enable it from the dashboard or viaPATCHonce your receiver is healthy. Events routed to a disabled endpoint are recorded as permanently failed with an "Endpoint inactive" reason rather than being retried. Theendpoint_disablederror 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 any3xxas a failed attempt, because the SSRF check pinned only the first hop — following aLocationheader would re-resolve it unchecked. A receiver that302s 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
| Method | Path | Scope |
|---|---|---|
| GET | /v1/webhook-endpoints | webhooks:manage |
| POST | /v1/webhook-endpoints | webhooks:manage |
| GET | /v1/webhook-endpoints/{endpointId} | webhooks:manage |
| PATCH | /v1/webhook-endpoints/{endpointId} | webhooks:manage |
| DELETE | /v1/webhook-endpoints/{endpointId} | webhooks:manage |
| POST | /v1/webhook-endpoints/{endpointId}/rotate-secret | webhooks:manage |
curl -i https://api.scripe.io/v1/webhook-endpoints \
-X POST \
-H "Authorization: Bearer $SCRIPE_OAUTH_TOKEN" \
-H "Scripe-Api-Version: 2026-08-10" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"name": "Production CRM",
"url": "https://hooks.example.com/scripe",
"events": ["post.created", "job.completed"],
"projectId": null
}'webhooks:manageis 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. Ascripe_sk_*key always gets403 scope_missinghere.- The
200response carries the plaintext signing secret exactly once. Store it immediately; every later read exposes onlysecretLast4. If you lose it, rotate. projectIdscopes 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
- Return
2xxwithin 10 seconds; do the work async. - Verify
Webhook-Signatureagainst the raw body, with a timestamp staleness check. - Dedupe on
id(at-least-once delivery). - Ignore unknown
typevalues and unknown fields indata. - Alert on your own 4xx/5xx responses — each delivery gives up after ~4.6 hours, and five consecutive give-ups disable the endpoint.