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 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.
- 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 anddata.replayOfpointing at the original. - 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 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
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
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 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 ladder: attempts fire at
0s, 30s, 1m, 5m, 15m, 1h, 6h, 24h. After the final attempt we setstatus: "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 includedata.replayOfpointing 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
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 each delivery for the past 7 days. - 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
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.