# Webhooks (/docs/api/operational/webhooks)



Every long-running operation + every partner-observable state change can be pushed to a URL you control. Webhooks are an alternative to polling `GET /v1/jobs/:jobId` - they ship the same terminal result as soon as it lands.

Five things the delivery system guarantees:

1. **Signed.** Every request carries `X-Layers-Signature: t=<unix>,v1=<hex-hmac>` where the MAC is `HMAC-SHA256(secret, ${t}.${body})`. Verify or drop.
2. **Retried.** Non-2xx responses retry with backoff before the delivery is marked failed.
3. **Dedupable.** `X-Layers-Event-Id: evt_&lt;26>` is stable across retries. Same event never gets two different ids. Dedupe your handler on it.
4. **Replayable.** Failed or accidentally-dropped deliveries can be replayed via `POST /v1/webhook-deliveries/:id/replay`. The replay fires a fresh request with a new event id and a top-level `replayOf` field (a sibling of `id`/`type`/`data`) pointing at the original delivery id.
5. **Auto-paused.** After 20 consecutive delivery failures, we auto-pause the endpoint (`status: "auto_paused"`). Fix your handler, then `PATCH` it back to `active` to resume.

## Event catalog [#event-catalog]

| Event                         | Fires when                                                  | Key payload fields                                            |
| ----------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------- |
| `job.completed`               | Any async job transitions to `completed`.                   | `jobId`, `kind`, `result`                                     |
| `job.failed`                  | Any async job transitions to `failed`.                      | `jobId`, `kind`, `error`                                      |
| `job.canceled`                | Any async job transitions to `canceled`.                    | `jobId`, `kind`                                               |
| `content.generated`           | Container reaches `completed`.                              | `id` (container), `projectId`, `assets`                       |
| `content.approved`            | Container flips from `pending` → `approved`.                | `id`, `projectId`, `approvedAt`, `approvedBy`                 |
| `content.rejected`            | Container flips from `pending` → `rejected`.                | `id`, `projectId`, `rejectedAt`, `rejectedBy`, `reason`       |
| `post.scheduled`              | Scheduled post created.                                     | `scheduledPostId`, `containerId`, `scheduledFor`              |
| `post.published`              | Scheduled post reaches `published`.                         | `scheduledPostId`, `containerId`, `externalId`, `externalUrl` |
| `post.failed`                 | Scheduled post reaches `failed`.                            | `scheduledPostId`, `containerId`, `error`                     |
| `post.canceled`               | Scheduled post is canceled.                                 | `scheduledPostId`, `reason`                                   |
| `social_account.connected`    | OAuth completed.                                            | `socialAccountId`, `platform`, `displayName`                  |
| `social_account.needs_reauth` | Token refresh failed; account flipped to `reauth_required`. | `socialAccountId`, `platform`, `reason`                       |
| `social_account.revoked`      | Partner (or token expiry) revoked the account.              | `socialAccountId`, `platform`                                 |
| `lease_request.assigned`      | Layers fulfilled a lease request.                           | `requestId`, `assignedSocialAccountIds`                       |
| `lease_request.rejected`      | Layers rejected a lease request.                            | `requestId`, `reason`                                         |
| `test.ping`                   | Emitted only by `POST /v1/webhook-endpoints/:id/test`.      | `message`, `endpointId`, `organizationId`                     |

`job.*`, `content.approved`/`content.rejected`, and `social_account.connected` (new connects only — reauth flows don't re-emit, since the partner already has the `socialAccountId`) fire today on every applicable transition. The remaining `social_account.*` and `lease_request.*` events are wired incrementally — subscribe to them today, their delivery hooks land in the same code path.

The table above is the **core** set. The full subscribable enum also includes the marketing-bootstrap fan-out (`project.created`, `project.brand_ingest.completed`, `sdk_app.created`, `layer.created`, `influencer.created`, `marketing_bootstrap.completed`, `marketing_bootstrap.failed`), the ads families (`ads.account.*`, `ads.token.expired`, `ads.optimizer.*`, `ads.write.executed`/`ads.write.denied`, `ads.creative.*`, `ads.policy.violation`, `ads.budget.cap_exceeded`), and the approval families (`approval.dispatched`, `approval.dispatch_failed`, `approval.approved`, `approval.disapproved`). Any of these may be passed in an endpoint's `events` array. The authoritative list is the `events` enum on [`POST /v1/webhook-endpoints`](/docs/api/reference/webhooks/create-endpoint).

## Delivery shape [#delivery-shape]

```http
POST https://your-handler.example.com/layers-webhook HTTP/1.1
Content-Type: application/json
User-Agent: Layers-Webhooks/1.0
X-Layers-Signature: t=1776626880,v1=b4fae8c1...
X-Layers-Event-Id: evt_01KPM7QZEC6NJF4XJTCZRR6S3N
X-Layers-Event-Type: content.generated
X-Layers-Delivery-Id: 3f71a8b2-4c58-4d2e-b1e3-8e0a2ae5c0c1
X-Layers-Api-Version: v1

{
  "id": "evt_01KPM7QZEC6NJF4XJTCZRR6S3N",
  "type": "content.generated",
  "apiVersion": "v1",
  "createdAt": "2026-04-20T18:28:00.000Z",
  "data": {
    "organizationId": "org_2481fa5c-a404-44ed-a561-565392499abc",
    "id": "cnt_7d18b9a1-8b2c-4f3e-a4d5-6e7f8a9b0c1d",
    "projectId": "prj_254a4ce1-f4ca-42b1-9e36-17ca45ef3d39",
    "assets": [ { "id": "med_...", "kind": "video", "url": "https://..." } ]
  }
}
```

Body is always the canonical envelope - `{id, type, apiVersion, createdAt, data}`. Subscribe-time `events` filter gates which deliveries land on your endpoint; the envelope is consistent across all of them. `data.organizationId` is present on every payload — it names the org the event belongs to, which is what makes the firehose (below) attributable.

## Delivery scope: own vs the firehose [#delivery-scope-own-vs-the-firehose]

By default an endpoint receives only its **own** org's events (`scope: "own"`). If you run customers as [sub-organizations](/docs/api/concepts/organizations), you can instead register one parent endpoint as a **firehose** that receives every child's events too — no need to register and rotate an endpoint per customer.

Set `scope: "all_children"` when you [create](/docs/api/reference/webhooks/create-endpoint) or [update](/docs/api/reference/webhooks/update-endpoint) the endpoint. It then delivers the parent org's own events **plus** all of its direct children's. Because `data.organizationId` is on every payload, you attribute each event to the right customer with no extra lookup:

```json
{
  "id": "evt_01KPM7QZEC6NJF4XJTCZRR6S3N",
  "type": "content.generated",
  "apiVersion": "v1",
  "createdAt": "2026-06-03T18:28:00.000Z",
  "data": {
    "organizationId": "org_d4e5f6a7-8b9c-4d0e-9f2a-3b4c5d6e7f80",
    "id": "cnt_7d18b9a1-8b2c-4f3e-a4d5-6e7f8a9b0c1d",
    "projectId": "prj_254a4ce1-f4ca-42b1-9e36-17ca45ef3d39",
    "assets": [ { "id": "med_...", "kind": "video", "url": "https://..." } ]
  }
}
```

<Callout type="warn">
  Opening or re-targeting an endpoint to `all_children&#x60; requires a parent &#x2A;*`org:admin`** key — it's a superset that exposes every child's events. A key without `org:admin` requesting it gets `403 FORBIDDEN_SCOPE`. An `own`-scoped endpoint is unaffected and needs no special scope.
</Callout>

## Verifying the signature [#verifying-the-signature]

Verify against the **raw bytes you received**, not a re-serialized version. Re-serializing mangles whitespace and breaks the HMAC.

<Tabs items="['TypeScript', 'Python']">
  <Tab value="TypeScript">
    ```ts title="verify-layers-signature.ts"
    import { createHmac, timingSafeEqual } from "node:crypto";

    /**
     * @param rawBody - The raw request body bytes exactly as received.
     * @param header - The `X-Layers-Signature` header value.
     * @param secret - The signing secret returned at create/rotate.
     * @param maxSkewSec - Reject timestamps further than this from now.
     */
    export function verifyLayersSignature(
      rawBody: string,
      header: string,
      secret: string,
      maxSkewSec = 300,
    ): boolean {
      const match = /^t=(\d+),v1=([0-9a-f]+)(,v1=[0-9a-f]+)*$/.exec(header);
      if (!match) return false;
      const ts = Number(match[1]);
      if (!Number.isFinite(ts)) return false;
      const nowSec = Math.floor(Date.now() / 1000);
      if (Math.abs(nowSec - ts) > maxSkewSec) return false;

      const macs = header
        .split(",")
        .filter((p) => p.startsWith("v1="))
        .map((p) => p.slice(3));

      const expected = createHmac("sha256", secret)
        .update(`${ts}.${rawBody}`)
        .digest("hex");
      const expectedBuf = Buffer.from(expected, "hex");

      return macs.some((mac) => {
        const got = Buffer.from(mac, "hex");
        return got.length === expectedBuf.length && timingSafeEqual(got, expectedBuf);
      });
    }
    ```
  </Tab>

  <Tab value="Python">
    ```python title="verify_layers_signature.py"
    import hmac, hashlib, time, re

    _SIG_RE = re.compile(r"^t=(\d+),v1=([0-9a-f]+)(,v1=[0-9a-f]+)*$")

    def verify_layers_signature(
        raw_body: bytes,
        header: str,
        secret: str,
        max_skew_sec: int = 300,
    ) -> bool:
        m = _SIG_RE.match(header)
        if not m:
            return False
        ts = int(m.group(1))
        if abs(int(time.time()) - ts) > max_skew_sec:
            return False
        macs = [p[3:] for p in header.split(",") if p.startswith("v1=")]
        expected = hmac.new(
            secret.encode(), f"{ts}.{raw_body.decode()}".encode(), hashlib.sha256
        ).hexdigest()
        return any(hmac.compare_digest(expected, mac) for mac in macs)
    ```
  </Tab>
</Tabs>

Reject timestamps outside your tolerance window - that's your replay-attack guard. Multiple `v1=` entries in the header represent secret rotation; your verifier should accept any one of them.

## Retry + dedupe [#retry--dedupe]

* **Retry ladder:** attempts use backoff. After the final attempt we set `status: "failed"` and stop.
* **Deduping:** same `X-Layers-Event-Id` across retries of the same delivery. Replays (via the replay endpoint) mint a new event id but include a top-level `replayOf` pointing at the original delivery id.
* **Idempotency on your side:** your handler **must** be idempotent. A 2xx response means "received; stop retrying." If your processing is async, return 2xx as soon as you've durably enqueued - the webhook is not your work queue.

## Sandbox payloads [#sandbox-payloads]

Webhook deliveries from [sandbox](/docs/api/concepts/sandbox) traffic carry an envelope-level `meta.sandbox: true` flag and are signed identically to live deliveries — same `X-Layers-Signature` HMAC over the raw body. &#x2A;*The flag lives on the outer envelope, not inside `data`** — per-event payload schemas stay pristine, so your typed `data` parsers continue to work without changes. After verifying the signature, optionally read `meta.sandbox` to route to a different downstream handler (separate test queue, no on-call paging, etc.).

```json
{
  "id": "evt_…",
  "type": "post.published",
  "apiVersion": "v1",
  "createdAt": "2026-05-08T12:00:00.000Z",
  "meta": {
    "sandbox": true
  },
  "data": {
    "scheduledPostId": "sp_b9b66cde...",
    "containerId": "cnt_7d18b9a1...",
    "externalId": "sb_post_<containerId>_0",
    "externalUrl": null
  }
}
```

`meta` is optional and omitted on live deliveries — treat any of `meta` absent, `meta.sandbox` absent, or `meta.sandbox === false` as live. A `meta.sandbox: true` envelope tells you not to expect real platform metrics downstream; sandbox metrics are synthesized on read. See [sandbox metrics behavior](/docs/api/concepts/sandbox#metrics).

## Registering a subscription [#registering-a-subscription]

1. `POST /v1/webhook-endpoints` with `{url, events}`. Response carries `signingSecret` - store it once, we can't show it again.
2. (Optional) `POST /v1/webhook-endpoints/:id/test` to fire a `test.ping` at your URL end-to-end before wiring real events.
3. Handler logic: verify the signature, dedupe on `X-Layers-Event-Id`, do your work, 2xx.
4. If the endpoint misbehaves or auto-pauses, debug from `GET /v1/webhook-endpoints/:id/deliveries` - status + last-response payloads on recent deliveries.
5. Need to rotate the secret (leaked, employee departure, hygiene)? `POST /v1/webhook-endpoints/:id/rotate-secret`. Your handler should accept **both** signatures during the overlap.

## Required scope: `webhooks:write` [#required-scope-webhookswrite]

Every endpoint under `/v1/webhook-endpoints/*` and the `/v1/webhook-deliveries/:id/replay` endpoint requires the `webhooks:write` scope. This includes the read-side endpoints (list, get, deliveries) — they share the write scope until a `:read` half is split out.

* **New keys:** explicitly request the scope when minting via the dashboard or admin API. Keys minted with no `scopes` array (the legacy default) keep full access — empty `scopes` is treated as "unscoped, all access" — so existing integrations are not affected.
* **Existing keys with explicit scope lists:** were auto-granted `webhooks:write` by [migration `20260426020000_backfill_webhooks_write_scope.sql`](https://github.com/layers/layers/tree/main/supabase/migrations) so nothing breaks at deploy time.
* **Partner-tier and internal-tier keys:** bypass the scope check on **read-side** endpoints (list / get / deliveries) but &#x2A;*must hold `webhooks:write`** for the write-side endpoints (create, patch, delete, test, rotate-secret, replay). This is a defense-in-depth measure — a stolen high-tier key without webhook scope cannot redirect events or rotate signing secrets, even though the same key still has implicit access to lower-blast-radius operations elsewhere in the API. Partner-tier keys minted before this enforcement landed have `webhooks:write` auto-granted by the migration above.

If you mint a new explicitly-scoped key after this change and forget to include `webhooks:write`, every webhook route returns:

```json
{
  "error": {
    "code": "FORBIDDEN_SCOPE",
    "message": "API key is missing required scope: webhooks:write.",
    "details": {
      "requiredScope": "webhooks:write",
      "grantedScopes": [/* your key's scopes */]
    },
    "requestId": "req_…"
  }
}
```

Regrant by re-minting the key with the new scope from the dashboard.

## Migrating from polling [#migrating-from-polling]

The jobs envelope doesn't go away. You can keep polling indefinitely - webhooks are an optimization, not a replacement. The recommended migration:

1. Register the subscription.
2. Keep your poll loop running.
3. Watch your webhook handler for a few days. Reconcile against polled state.
4. When you trust it, drop the poll.

Don't cut the poll over on day one.

## See also [#see-also]

* [API reference: webhooks endpoints](/docs/api/reference/webhooks/list-endpoints)
* [Jobs](/docs/api/concepts/jobs)
* [Errors](/docs/api/operational/errors)
