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
POSTwithX-Layout-Event: endpoint.verificationand"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, withPOST /v1/cli/apps/:app/webhooks/:id/verifyand 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.
| Check | When |
|---|---|
| 1 | About 1 minute after the failed verification |
| 2 | About 10 minutes later |
| 3 | About 1 hour later |
| 4 | About 6 hours later |
| Then | Daily, 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 becomesexpiredwhen 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
v1fails 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%:
| Attempt | Sent |
|---|---|
| 1 | A few seconds after the change |
| 2 | About 30 seconds later |
| 3 | About 2 minutes later |
| 4 | About 10 minutes later |
| 5 | About 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
heldorexpireddelivery 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 sameevent_id, so skip anevent_idyou have already handled. The order id and the event together name one event for every type exceptorder.updated, which can come more than once per order: de-duplicate onevent_id. - No ordering guarantee. Events can arrive out of order. Read
order.state, orGET /v1/orders/:id, rather than inferring state from arrival order. - Every request carries
X-Layout-Event,X-Layout-Delivery, andX-Layout-Environmentalongside the signature (User-AgentLayout-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.