# Event types

The full catalog of webhook events. The vocabulary carries the truth: `order.unconfirmed` is first-class, and must never be collapsed into `order.failed`.

| Event | Meaning |
| --- | --- |
| `order.building` | The order was accepted and the cart is being built. |
| `order.carted` | The cart is priced and ready. Carries the store's name and address, how many items it holds, and the store's total, without Layout's service fee. A cart you canceled while it was building is never sent as `order.carted`: it ends with one `order.failed` carrying `USER_CANCELED`. |
| `order.placing` | The person confirmed; Layout is submitting to the merchant. |
| `order.placed` | The order reached the merchant, with evidence. Fires only on a confirmed placement. |
| `order.failed` | Not placed. `order.failure_code` says why. A ready cart nobody confirmed before `expiresAt` is `CART_EXPIRED`. A failed order can still show a pending hold: see [Holds on a failed order](https://developer.layout.link/reference/errors#holds-on-a-failed-order). |
| `order.unconfirmed` | Placement is genuinely uncertain. Do not retry it, automatically or by hand, and do not treat it as failed. Wait for `order.placed` or read `GET /v1/orders/:id`. That is the double-charge trap. Layout keeps following the order, and `order.placed` can follow once it learns the outcome, for instance when the restaurant's receipt arrives. |
| `order.updated` | More pickup details arrived after placement (name, time, order number, instructions). It can come several times, each with its own `event_id`: see [More than one order.updated](#more-than-one-order-updated). Read the order again on each one. `order.state` stays `placed`, and it is sent only for an order whose `order.placed` was sent to you. |
| `order.refunded` | Money went back to the person. `order.refunded_minor` is this one refund and `order.method` says where it went. `order.state` does not change. See [order.refunded](#order-refunded). Add it to a subscription's events to receive it. |
| `order.challenge` | Layout texted the person a link to approve this purchase, and it is waiting on them. A pause, not a new state: `order.state` is where the order was when it paused, which is `building`, `carted` or `placing`. Sent once per order, and only while the order is still in flight. |
| `order.needs_approval` | Reserved. Not sent. |

## Example payload

Every order event carries the same payload, whatever its type, on a webhook and in `GET /v1/events`. It never carries item names, modifiers or card data: read `GET /v1/carts/:id` for a cart's items and `GET /v1/orders/:id` for the order.

```json
{
  "event": "order.carted",
  "event_id": "evt_7c21e4b9a0d3_carted",
  "delivery_id": "dl_351eacf74201",
  "order": {
    "id": "ord_7c21e4b9a0d3",
    "state": "carted",
    "store": "Layout test kitchen",
    "store_address": "1 Sandbox Way, San Francisco, CA 94107",
    "items": 2,
    "total_minor": 2140,
    "currency": "usd"
  },
  "user": { "id": "usr_4b8e9a01c2d3" },
  "occurred_at": "2026-09-10T17:01:05Z",
  "sent_at": "2026-09-10T17:01:07Z"
}
```

| Field | Notes |
| --- | --- |
| `event` | The event type (not `type`). |
| `event_id` | The event: this order reaching this state, or one wave of `order.updated`. Every delivery of it, to every endpoint, on every retry and replay, carries the same id. Deduplicate on it and treat it as opaque. |
| `delivery_id` | This delivery (not `id`). A retry keeps it; a replay gets a new one. |
| `order.id` | The same `ord_` id `GET /v1/orders/:id` returns. The order is nested under `order` (not `data`). |
| `order.store`, `order.store_address` | The store's name and address. `store_address` is null when Layout does not know it, and always null when `store` is. |
| `order.items` | How many items the order holds. |
| `order.total_minor` | The store's total in minor units, without Layout's service fee, the same as on `GET /v1/orders/:id`. Null until the cart is priced. The cart's `totalMinor` includes the fee, and the order's `charged_minor` is what the card was charged. |
| `order.failure_code` | On `order.failed` only: the same code `GET /v1/orders/:id` returns as `failure_code`, or null when Layout recorded none. |
| `order.ready_eta`, `order.ready_at`, `order.fulfillment` | On `order.updated` only: the values `GET /v1/orders/:id` returns when the event is written. `ready_at` is the pickup time as an instant, or null when Layout cannot pin the store's words to its clock. Absent when Layout could not read the order then. |
| `order.refunded_minor`, `order.method` | On `order.refunded` only: the amount of this one refund in minor units, and `card` or `credit`. |
| `order.simulated` | `true` on a simulated sandbox order. See [Simulated orders](#simulated-orders). |
| `test` | `true` on a console test delivery, a fired test event and `endpoint.verification`. |
| `user.id` | The `usr_` id you provisioned. For a person who connected through your OAuth client, a `usr_` id that is stable for your application and differs in every other one; `POST /v1/users/:id/grant` does not accept it. |
| `occurred_at`, `sent_at` | When the order changed, and when Layout wrote the delivery (kept on a retry, restamped on a replay). Order events by `occurred_at`, never by arrival. |

`simulated` and `test` are absent, never `false`, when not true. `endpoint.verification` has its own shape with no `order` or `user`: see [Endpoint verification](https://developer.layout.link/reference/webhooks#endpoint-verification).

## order.refunded

Sent for each refund on an order. It changes no state, so an order that was `placed` stays `placed`; the deprecated `refunded` state is never sent. `order.refunded_minor` is the amount of this refund, in minor units, and `order.method` is `card` when it goes back to the person's card, or `credit` when it is Layout credit on their account. `GET /v1/orders/:id` `refunded_minor` is the total refunded so far. A second refund has its own `event_id`, ending `_2`, so de-duplicate on `event_id`.

It is not part of any subscription you already have: edit the subscription and add it to its events.

## More than one order.updated

Every other event is sent at most once per order. `order.updated` is sent once for each wave of pickup details that fills a detail the order did not have yet. A detail already there is never replaced.

| Wave | `event_id` |
| --- | --- |
| First | `evt_7c21e4b9a0d3_updated` |
| Second | `evt_7c21e4b9a0d3_updated_2` |
| Third, and so on | `evt_7c21e4b9a0d3_updated_3` |

- De-duplicating on `event_id` keeps every wave, because each has its own id. A retry or a replay of one wave keeps that wave's id.
- Do not key on the order id and the event type together: that drops the second wave and everything after it.
- `GET /v1/events?after=evt_7c21e4b9a0d3_updated_2` resumes right after that wave.
- On each one, read `GET /v1/orders/:id` and show what it has now. The pickup time is often the last detail to arrive: see [Pickup time](https://developer.layout.link/reference/api#pickup-time).
- Test it with [test_place_pickup_later](https://developer.layout.link/reference/sandbox#the-test-restaurants), which sends two.

## Which orders send events

Events fire for orders your application caused, and only those:

- Orders built for a user you provisioned, through a build session from `POST /v1/users/:id/grant`.
- Orders a person placed through an [OAuth client you created](https://developer.layout.link/reference/authentication#oauth-clients) in the console, while they are still connected to it.

An order a person placed on their own, or through anyone else's app, never reaches you. Layout sends no event when a person is disconnected.

## Simulated orders

A simulated sandbox order carries `"simulated": true` in its `order` object: nothing went to the restaurant and nothing was charged. A [sandbox](https://developer.layout.link/reference/sandbox) order built from the menu Layout holds, or at a test restaurant, carries it on every event, from `order.building` on. A sandbox order built with `"engine": "real"` carries it once its confirm is simulated, and not on its events before that. On every other delivery the key is absent, never `false`, so test for `true`. `GET /v1/orders/:id` follows the same rule.
