Documentation
Partner API

Webhooks

Signed HTTPS notifications for every event: endpoints, Standard Webhooks headers, verifiers in Node, Python, Go and PHP, retries, ordering and rotation.

Updated 2026-10-24MarkdownOpenAPI

A webhook endpoint is an HTTPS URL of yours that receives every event — or the types you pick — as a signed POST, soon after it happens. Endpoints are per environment; your plan sets how many you may register (webhook_endpoints_max).

Register an endpoint

curl -X POST "https://engine.layercrew.com/v1/webhook-endpoints" \
  -H "Authorization: Bearer $LAYERCREW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "url": "https://shop.example.com/webhooks/layercrew",
  "description": "Production events",
  "event_types": [
    "order.accepted",
    "order.rejected",
    "order.shipped",
    "order.delivered",
    "order.cancelled"
  ]
}'
  • url is HTTPS on port 443 or 8443, on a public host that resolves, without credentials — 422 ENDPOINT_URL_INVALID otherwise. Private and link-local addresses are refused at creation and again at every dial.
  • event_types is null for every event, or a list of names from the lifecycle page — 422 EVENT_TYPE_INVALID for a name outside it.
  • secret is whsec_ followed by the base64 of 24 random bytes. It is shown once; store it in your secret manager.
  • PATCH changes url, description, event_types and enabled; DELETE removes the endpoint and its pending deliveries.

What a delivery looks like

HTTP
POST /webhooks/layercrew HTTP/1.1
Host: shop.example.com
Content-Type: application/json
User-Agent: LayerCrew-Webhooks/1.0 (+https://layercrew.com/developers/webhooks)
webhook-id: d4e5f6a7-b8c9-4d0e-1f2a-3b4c5d6e7f80
webhook-timestamp: 1793196312
webhook-signature: v1,Yk9tTk5xZ3pRQ3pFQlJ6R1Nnd0RkWlZ4N2Fqb1VqeHNuV3N0Y3F0VmVHaz0=
X-LayerCrew-Event: order.shipped
X-LayerCrew-Sequence: 1234
X-Request-ID: 01J9Z8Y7X6W5V4U3T2S1R0Q9P8

{"id":"9c8b7a6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d","type":"order.shipped","api_version":"2026-10-24","created_at":"2026-10-27T14:05:12Z","seq":1234,"environment":"live","data":{"order":{…}}}
HeaderMeaning
webhook-idThe delivery id — the same across every retry of this delivery. Deduplicate on it.
webhook-timestampUnix seconds of this attempt. Refuse it when more than five minutes from your clock, either way.
webhook-signaturev1,<base64> — one entry, or two separated by a space during a secret rotation.
X-LayerCrew-EventThe event type, so you can route before parsing.
X-LayerCrew-SequenceThe event's seq.
X-Request-IDOur id for this attempt; it appears in the delivery log.

Verifying the signature

The scheme is Standard Webhooks, so any of its libraries verifies a LayerCrew delivery. By hand: take the characters after whsec_ and base64-decode them — that is the HMAC key. Build the signed content <webhook-id>.<webhook-timestamp>.<raw body> from the exact bytes you received, before any parsing. Compute HMAC-SHA256 over it, base64-encode the result, and compare it in constant time against each v1, entry of the header. Refuse a timestamp more than five minutes from your clock. Pass both secrets during a rotation.

import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300;

export function verifyLayerCrewWebhook(headers, rawBody, secrets) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signature = headers["webhook-signature"];
  if (!id || !timestamp || !signature) return false;
  const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!Number.isFinite(skew) || skew > TOLERANCE_SECONDS) return false;

  const signed = Buffer.concat([Buffer.from(`${id}.${timestamp}.`), rawBody]);
  const candidates = signature.split(" ").filter((s) => s.startsWith("v1,")).map((s) => s.slice(3));
  for (const secret of secrets) {
    const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
    const expected = Buffer.from(createHmac("sha256", key).update(signed).digest("base64"));
    for (const candidate of candidates) {
      const given = Buffer.from(candidate);
      if (given.length === expected.length && timingSafeEqual(given, expected)) return true;
    }
  }
  return false;
}

Read the raw body (express.raw(), not express.json()); secrets is [current] or [current, previous]

  • Read the raw body. A re-serialised JSON body has different bytes and the signature fails.
  • Answer `2xx` within 10 seconds, then do the work. Queue it if it takes longer; a slow handler is retried as a failure.
  • Treat every refusal the same. A missing header, a stale timestamp and a wrong signature all mean the request is not from LayerCrew.
  • Deduplicate on `webhook-id` and order by seq, never by arrival time.

Retries

A delivery is tried until your endpoint answers 2xx, up to webhook_max_attempts — 12 on the default plan; it can differ per plan — on this ladder, each wait with ±20 percent jitter:

AttemptWait before it
1at once
21 minute
35 minutes
430 minutes
52 hours
6 and later6 hours

Twelve attempts span about two days. A Retry-After header on your 429 or 503 is honoured, up to 6 hours. How we read your answer:

Your answerWhat happens
2xxDelivered. last_success_at and the attempt are recorded.
410 GoneThe endpoint is disabled at once; you get webhook_endpoint.disabled and an email.
429, 408, 425, any 5xx, a timeout or a connection failureRetried on the ladder, honouring Retry-After; nothing is counted against the endpoint.
3xx and every other 4xxRetried on the ladder and counted as a refusal. Redirects are never followed.

Ordering

Deliveries to one endpoint go out in seq order, one at a time: a delivery that keeps failing holds the ones behind it until it succeeds, runs out of attempts or you redeliver it. Across endpoints there is no ordering. Your handler should be idempotent on webhook-id and read seq to order events; the feed GET /v1/events?after= is the authoritative order when in doubt.

Automatic disabling

An endpoint is disabled when 20 consecutive deliveries were refused, when it has had no success for 72 hours and a delivery ran out of attempts, or at once on a 410. You get webhook_endpoint.disabled on the feed and an email; its pending deliveries are marked skipped. Re-enable with PATCH /v1/webhook-endpoints/{id} and {"enabled": true}: the failure count resets and the deliveries skipped in the last 7 days are queued again, in order. A disabled endpoint answers 409 ENDPOINT_DISABLED to ping and redeliver.

Rotating the secret

POST /v1/webhook-endpoints/{id}/rotate-secret returns a new secret once. For 24 hours every delivery carries two signatures — v1,<new> v1,<old> — so you deploy the new secret at your own pace; after that only the new one signs. webhook_endpoint.secret_rotated marks the moment.

Testing and the delivery log

POST /v1/webhook-endpoints/{id}/ping queues a test.ping with an empty data so you can verify your signature check before going live. The delivery log — GET /v1/webhook-deliveries with endpoint_id, status and event_id filters, and GET /v1/webhook-deliveries/{id} — shows every attempt with status code, duration, our request id and the first kilobyte of your response. POST /v1/webhook-deliveries/{id}/redeliver sends the exact original bytes again with a fresh signature and timestamp; a delivery in flight answers 409 DELIVERY_IN_FLIGHT. The same log is under Integrations → Webhooks in your dashboard.

Every endpoint is in the reference.

We value your privacy

We use cookies for analytics and advertising to improve your experience and measure our marketing. See our Privacy Policy.