Webhooks


Layout POSTs a signed event when an order you initiated changes state. Delivery is fire-and-forget with retry, and never delays or alters an order.

Subscribe

Add a subscription in the console with a notification URL and the events you care about. HTTPS only, never a private or internal host and never a layout.link host. An application holds up to 10 subscriptions; another is refused with limit_reached. Choose what it Receives: sandbox, live or both, which is the default. A subscription set to sandbox never sees a production order, so a development tunnel cannot receive one. Over the API it is the optional receives on POST /v1/console/apps/:appId/webhooks and PATCH on the subscription; anything else is 400 invalid_receives. Your signing secrets are on the Webhooks page: sandbox and production sign with different secrets, so hold both and verify each delivery with the one for its environment.

A subscription lists the events it receives, and a new event type reaches none you already have until you add it. order.refunded is one of them: see order.refunded.

Switching a subscription off, or deleting it, stops new events at once. A delivery already queued, including one waiting on a retry, keeps its schedule and still goes to the URL it was written for, until it is delivered or dead-lettered.

Events cover orders built for users you provision and orders people place through an OAuth client you created. See which orders send events. An OAuth client's orders are sandbox only for a sign-in test account you create in the console; every other person, your own Layout account included, places real orders and can connect through your client only once your application is approved.

Endpoint verification

When you save a new subscription, or change its URL, Layout sends one signed endpoint.verification event to that URL. A new subscription stays off until your endpoint answers that event with a 2xx, then turns on by itself. Nothing in the response body is read, so there is no challenge to echo: return 2xx.

{
  "event": "endpoint.verification",
  "delivery_id": "dl_7f0c2a91b3e4",
  "subscription": { "id": "9b1e6c2a-4f7d-4e1a-8c3b-2d5f6a7b8c9d" },
  "sent_at": "2026-09-30T10:14:03Z",
  "test": true
}
  • It is a signed POST with X-Layout-Event: endpoint.verification and "test": true, and names no order. Any 2xx verifies the endpoint.
  • It is signed first with the secret of the environment selected in the console. A 4xx or 5xx gets it once more, signed with the other environment’s secret. A redirect or a timeout is not sent again.
  • A changed URL keeps delivering to the old one until the new one verifies. Saving the old URL again cancels the change.
  • Press Verify on the subscription to send it again. 50 verifications a day per developer account. The CLI’s layout webhooks verify <id> does the same, with POST /v1/cli/apps/:app/webhooks/:id/verify and a body of { "environment": "sandbox" } or "live", which picks the signing secret tried first.

Events held while an endpoint is unverified

While a subscription’s endpoint has not verified, its events are not lost. They are held, in the delivery log and in the event feed, and Layout tries the endpoint again on its own. The subscription’s verification.nextCheckAt says when, and is null when no check is waiting.

CheckWhen
1About 1 minute after the failed verification
2About 10 minutes later
3About 1 hour later
4About 6 hours later
ThenDaily, for 7 days
  • These checks do not count against the 50 manual verifications a day. Each manual verify restarts the schedule.
  • On the first 2xx the subscription turns on and the held events are delivered in order.
  • A held delivery has the status held. It becomes expired when it has been held for more than 72 hours, when more than 1,000 are held for the endpoint, or when the endpoint was removed, or verified while the subscription was switched off. Neither status can be replayed, and an expired event is not delivered.

Verify the signature

The X-Layout-Signature header is t=<unix seconds>,v1=<hex>, where v1 is HMAC_SHA256(secret, "${t}.${rawBody}") as a hex digest over the raw request bytes. While a rotated secret is still valid the header carries two signatures, t=<unix>,v1=<new>,v1=<old>. Accept the request if any v1 matches, not only the first one.

Pick the secret by the delivery’s environment: the X-Layout-Environment header is sandbox or live, each signed with that environment’s own secret. A sandbox secret can never produce a valid signature for a live delivery.

Enforce the timestamp yourself. Layout does not refuse a late delivery on your behalf. Reject any t more than 5 minutes from your clock, which bounds how long a captured request can be replayed.

import crypto from "node:crypto";

const TOLERANCE_SECONDS = 300;

export function verifyLayoutSignature(rawBody, header, secret) {
  if (!secret) return false;
  let t = NaN;
  const signatures = [];
  for (const part of String(header || "").split(",")) {
    const at = part.indexOf("=");
    const key = part.slice(0, at);
    const value = part.slice(at + 1);
    if (key === "t") t = Number(value);
    if (key === "v1") signatures.push(value);
  }
  const now = Math.floor(Date.now() / 1000);
  if (!Number.isFinite(t) || Math.abs(now - t) > TOLERANCE_SECONDS) return false;
  const expected = Buffer.from(
    crypto.createHmac("sha256", secret).update(t + "." + rawBody).digest("hex")
  );
  return signatures.some((s) => {
    const got = Buffer.from(s);
    return got.length === expected.length && crypto.timingSafeEqual(got, expected);
  });
}

Wired into Express, reading the raw body and answering before the work starts. An environment whose secret you have not configured answers 400, not 500:

app.post("/webhooks/layout", express.raw({ type: "application/json" }), (req, res) => {
  const secret = req.get("X-Layout-Environment") === "live"
    ? process.env.LAYOUT_WEBHOOK_SECRET_LIVE
    : process.env.LAYOUT_WEBHOOK_SECRET_SANDBOX;
  const raw = req.body.toString("utf8");
  if (!verifyLayoutSignature(raw, req.get("X-Layout-Signature"), secret)) {
    return res.status(400).end();
  }
  res.status(200).end();
  queue.push(JSON.parse(raw));
});

Rotate the signing secret

Rotate on the Webhooks page replaces the signing secret for the environment you have selected. For the next 24 hours every delivery in that environment carries two signatures, the new secret’s first and the previous secret’s second. A verifier that accepts any matching v1 keeps passing whichever secret it holds, so you can deploy the new one any time inside that window. The page shows when the previous secret stops signing.

  • Check your verifier before you rotate. One that reads only the first v1 fails on every delivery from the moment you rotate until you deploy the new secret.
  • While a previous secret is still signing, a second rotation is refused. The console offers to end the overlap instead: the older secret stops signing at once, your current secret signs beside a new one for 24 hours. Use it when a secret has leaked.
  • Rotating a production secret needs a sign-in from the last 15 minutes.
  • Sandbox and production rotate separately.

Delivery and retries

The first attempt usually arrives within seconds. Answer 2xx within 5 seconds, before doing the work; an answer not finished within 10 seconds also fails. Anything outside 2xx is retried, five attempts in total, each wait varying by up to 15%:

AttemptSent
1A few seconds after the change
2About 30 seconds later
3About 2 minutes later
4About 10 minutes later
5About 1 hour later
  • After the last attempt a delivery is dead-lettered. Replay it from the delivery log in the console, up to 200 replays a day for your developer account. A held or expired delivery cannot be replayed.
  • The delivery log and the event feed keep 30 days. An older delivery is deleted and cannot be replayed or read.
  • A 3xx is a failure. Layout never follows a redirect, so point the subscription at the final URL.
  • At least once. The same event can arrive more than once: a retry of one delivery keeps its delivery_id, and a replay arrives under a new one. Every copy carries the same event_id, so skip an event_id you have already handled. The order id and the event together name one event for every type except order.updated, which can come more than once per order: de-duplicate on event_id.
  • No ordering guarantee. Events can arrive out of order. Read order.state, or GET /v1/orders/:id, rather than inferring state from arrival order.
  • Every request carries X-Layout-Event, X-Layout-Delivery, and X-Layout-Environment alongside the signature (User-Agent Layout-Webhooks/1), so you can route, deduplicate, and pick the signing secret without parsing the body.

Test deliveries

Send test event on a subscription fires a real, signed delivery at your endpoint, so you can prove the whole path works before a single order exists. Same signing secret, same retries, same everything. It is always an order.placed, and it goes to that subscription whatever events it lists, even while it is off. 50 a day for your developer account.

To send any event type from a script or CI, POST /v1/sandbox/webhooks/fire with your sk_test_ key. See Fire a test webhook.

A test payload carries "test": true at the top level, and so does an endpoint.verification event. Answer either with a 2xx and skip the work:

if (payload.test) return res.status(200).end();

The ids inside are fixed placeholders that match nothing real: the order is ord_test_000000000000 and the user usr_test_000000, unless you fire the event with the orderId of one of your sandbox orders. If you look one up and get nothing back, that is the right answer, not a bug.

Payload

Every order event has the same shape, and no event carries card data. See Example payload for the fields.