Layers
Partner APIOperational

Webhooks

Subscribe to partner-visible events with HMAC-signed HTTP callbacks, exponential retry, and replay.

View as Markdown

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

EventFires whenKey payload fields
job.completedAny async job transitions to completed.jobId, kind, result
job.failedAny async job transitions to failed.jobId, kind, error
job.canceledAny async job transitions to canceled.jobId, kind
content.generatedContainer reaches completed.id (container), projectId, assets
content.approvedContainer flips from pendingapproved.id, projectId, approvedAt, approvedBy
content.rejectedContainer flips from pendingrejected.id, projectId, rejectedAt, rejectedBy, reason
post.scheduledScheduled post created.scheduledPostId, containerId, scheduledFor
post.publishedScheduled post reaches published.scheduledPostId, containerId, externalId, externalUrl
post.failedScheduled post reaches failed.scheduledPostId, containerId, error
post.canceledScheduled post is canceled.scheduledPostId, reason
social_account.connectedOAuth completed.socialAccountId, platform, displayName
social_account.needs_reauthToken refresh failed; account flipped to reauth_required.socialAccountId, platform, reason
social_account.revokedPartner (or token expiry) revoked the account.socialAccountId, platform
lease_request.assignedLayers fulfilled a lease request.requestId, assignedSocialAccountIds
lease_request.rejectedLayers rejected a lease request.requestId, reason
test.pingEmitted 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.

Delivery shape

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

By default an endpoint receives only its own org's events (scope: "own"). If you run customers as sub-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 or update 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:

{
  "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://..." } ]
  }
}

Opening or re-targeting an endpoint to all_children requires a parent 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.

Verifying the signature

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

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);
  });
}
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)

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

Webhook deliveries from 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. 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.).

{
  "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.

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

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 so nothing breaks at deploy time.
  • Partner-tier and internal-tier keys: bypass the scope check on read-side endpoints (list / get / deliveries) but 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:

{
  "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

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

On this page