# 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](https://developer.layout.link/console/webhooks/subscriptions) 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](https://developer.layout.link/reference/events#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](https://developer.layout.link/reference/authentication#oauth-clients). See [which orders send events](https://developer.layout.link/reference/events#which-orders-send-events). An OAuth client's orders are sandbox only for a [sign-in test account](https://developer.layout.link/reference/authentication#sign-in-test-accounts) 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.

```json
{
  "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](https://developer.layout.link/reference/api#get-v1-events), 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 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.

```js
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:

```js
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%:

| 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 `held` or `expired` delivery cannot be replayed.
- The delivery log and the [event feed](https://developer.layout.link/reference/api#get-v1-events) 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](https://developer.layout.link/reference/sandbox#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](https://developer.layout.link/reference/events#example-payload) for the fields.
