> ## Documentation Index
> Fetch the complete documentation index at: https://apidocs.scripe.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

# 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:

```json theme={null}
{
  "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 */ }
}
```

* `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`](./workspaces.md), which is always the org id.
  Key your own records off the endpoint you registered rather than
  joining on this field.

Delivery request headers:

```http theme={null}
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>`.

```ts theme={null}
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 `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 `302`s 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` |

```bash theme={null}
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: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](./auth.md#12-scopes)) — so this call needs an
  [OAuth token](./oauth.md). 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.
