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

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 row 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 (by revoke / partner / ops).scheduledPostId, reason
social_account.connectedOAuth completed, row written.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.* 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.

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

  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

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