Webhooks
Subscribe to partner-visible events with HMAC-signed HTTP callbacks, exponential retry, and replay.
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:
- Signed. Every request carries
X-Layers-Signature: t=<unix>,v1=<hex-hmac>where the MAC isHMAC-SHA256(secret, ${t}.${body}). Verify or drop. - Retried. Non-2xx responses retry with backoff before the delivery is marked failed.
- Dedupable.
X-Layers-Event-Id: evt_<26>is stable across retries. Same event never gets two different ids. Dedupe your handler on it. - 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-levelreplayOffield (a sibling ofid/type/data) pointing at the original delivery id. - Auto-paused. After 20 consecutive delivery failures, we auto-pause the endpoint (
status: "auto_paused"). Fix your handler, thenPATCHit back toactiveto resume.
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.
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.
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);
});
}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-Idacross retries of the same delivery. Replays (via the replay endpoint) mint a new event id but include a top-levelreplayOfpointing 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
POST /v1/webhook-endpointswith{url, events}. Response carriessigningSecret- store it once, we can't show it again.- (Optional)
POST /v1/webhook-endpoints/:id/testto fire atest.pingat your URL end-to-end before wiring real events. - Handler logic: verify the signature, dedupe on
X-Layers-Event-Id, do your work, 2xx. - If the endpoint misbehaves or auto-pauses, debug from
GET /v1/webhook-endpoints/:id/deliveries- status + last-response payloads on recent deliveries. - 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
scopesarray (the legacy default) keep full access — emptyscopesis treated as "unscoped, all access" — so existing integrations are not affected. - Existing keys with explicit scope lists: were auto-granted
webhooks:writeby migration20260426020000_backfill_webhooks_write_scope.sqlso 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:writefor 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 havewebhooks:writeauto-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:
- Register the subscription.
- Keep your poll loop running.
- Watch your webhook handler for a few days. Reconcile against polled state.
- When you trust it, drop the poll.
Don't cut the poll over on day one.