# 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 on an exponential ladder (0s, 30s, 1m, 5m, 15m, 1h, 6h, 24h → 8 attempts over \~32h). Then we give up and mark the delivery 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 `data.replayOf` pointing at the original.
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 row 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 (by revoke / partner / ops).     | `scheduledPostId`, `reason`                                   |
| `social_account.connected`    | OAuth completed, row written.                               | `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.*` and `content.approved`/`content.rejected` fire today on every applicable transition. The remaining events are wired incrementally — subscribe to them today, their delivery hooks land in the same code path.

## 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": {
    "id": "cnt_01HX9Y6K7EJ4T2ABCDEF",
    "projectId": "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.

## 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 further than 5 minutes from your clock — that's your replay-attack guard. Multiple `v1=` entries in the header represent the 24-hour secret-rotation overlap window; your verifier should accept any one of them.

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

* **Retry ladder:** attempts fire at `0s, 30s, 1m, 5m, 15m, 1h, 6h, 24h`. 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 `data.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.

## 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 each delivery for the past 7 days.
5. Need to rotate the secret (leaked, employee departure, hygiene)? `POST /v1/webhook-endpoints/:id/rotate-secret`. The old secret stays valid for 24h; your handler should accept **both** signatures during the overlap.

## 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)
* [Changelog](/docs/api/operational/changelog)
