> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.goautolane.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.goautolane.com/_mcp/server.

# Reservations

> API reference for Autolane Reservations. Reserve arrival windows at Autolane stalls for autonomous vehicles and sidewalk robots, and receive signed webhooks as each reservation moves through its lifecycle.

# Reservations API

The Reservations API lets fleet partners reserve an arrival window at an Autolane stall so a runner can load an order into an autonomous vehicle or sidewalk robot. Discover reservable sites, zones, and retailers, create and manage reservations, and register webhook endpoints that receive signed events as a reservation changes status, as the runner reaches each handoff milestone, and when an exception affects your delivery.

## Authentication

All requests require an **Autolane API key** in the `Authorization` header:

```bash
curl https://api-sandbox.goautolane.com/rs/v1/retailers \
  -H "Authorization: Bearer YOUR_API_KEY"
```

Create API keys in the [Autolane Portal](https://portal.goautolane.com) under **Integrations** > **API Keys**. Keys are environment-specific: a sandbox key only works against the sandbox host, and a production key only against the production host. Toggle **dev mode** in the portal to choose which environment a new key targets. See [Authentication](/getting-started/authentication#reservations-api) for the full walk-through.

Your key must also belong to an organization enrolled as an Autolane fleet partner; other keys receive `403 NOT_FLEET_PARTNER` even with the right permission. Contact Autolane to enroll.

### Permissions

Each endpoint checks a permission on your key:

| Permission           | Grants                                                                                      |
| -------------------- | ------------------------------------------------------------------------------------------- |
| `retailers:read`     | `GET /rs/v1/retailers`                                                                      |
| `reservations:read`  | `GET /rs/v1/reservations` and `GET /rs/v1/reservations/{id}`                                |
| `reservations:write` | Create, move, and cancel reservations; report bot status; the sandbox-only advance endpoint |
| `webhooks:manage`    | Every `/rs/v1/webhooks` endpoint                                                            |

Webhook endpoints receive whole reservations, so pointing one at a URL costs the scope that reads them. **Registering an endpoint with `POST /rs/v1/webhooks` needs `reservations:read` as well as `webhooks:manage`.** The same holds for a `PUT /rs/v1/webhooks/{id}` that changes `url` or `subscribed_events`, or sets `is_active` to `true`. What counts is the change, not the field you send: a value you send back unchanged costs nothing. Setting `is_active` to `false` and rotating the secret need `webhooks:manage` alone, so you can always stop a stream or replace a leaked secret. That also means a key holding `webhooks:manage` alone can mint a new secret and read it in full, and that secret is the key that signs reservation events into your endpoint; grant the scope accordingly. A `403` names the fields that asked for more.

## Hosts

| Environment | Base URL                             |
| ----------- | ------------------------------------ |
| Production  | `https://api.goautolane.com`         |
| Sandbox     | `https://api-sandbox.goautolane.com` |

## Sandbox site

The sandbox contains one built-in test site so you can integrate before any real configuration exists. Its ids are stable and safe to reference in tests:

| Entity   | Name               | Id                                     |
| -------- | ------------------ | -------------------------------------- |
| Site     | Sandbox Mall       | `00000000-0000-4000-8000-000000000001` |
| Zone     | Sandbox AV Zone A  | `00000000-0000-4000-8000-000000000002` |
| Retailer | Sandbox Coffee Co. | `00000000-0000-4000-8000-000000000003` |

Real sites and retailers configured for sandbox testing appear alongside it in the same response.

## Reservation lifecycle

A reservation moves through `requested`, `confirmed`, `arrived`, `completed`, or `canceled`. Beside it sit two smaller machines you read on every response and every webhook: `bot`, your last status report for the bot serving this reservation, and `handoff`, the runner's progress carrying the order to it.

| Status      | Meaning and trigger                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `requested` | Accepted, not yet confirmed. In this version a create confirms synchronously, so you will not observe it.                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `confirmed` | The window is booked, a stall is assigned, and a runner task is opened (`handoff.status` starts at `QUEUED`). The create response already carries this status.                                                                                                                                                                                                                                                                                                                                                                            |
| `arrived`   | Your bot is at the stall. Set by your first bot report at `ARRIVED` or any later status (see [Reporting bot status](#reporting-bot-status)); a report that skips `ARRIVED` still marks arrival.                                                                                                                                                                                                                                                                                                                                           |
| `completed` | The order is loaded and the bot has left. Set by your `DEPARTED` report, or by us at window expiry when the runner had already loaded the order (`handoff.status` `LOADED`). Terminal.                                                                                                                                                                                                                                                                                                                                                    |
| `canceled`  | Terminal. `canceled_by` says who canceled: `partner`, `runner`, `ops`, or `system`, and `cancel_reason` carries the reason text (`window_expired` when we closed it at window end plus grace). A bot that reports `SECURED` before the runner had opened the compartment, and never `DEPARTED`, ends `canceled` this way once its window has expired; one that secured after the open ends `completed` instead, because the load is implied. Report `DEPARTED` either way so the reservation closes on your report rather than at expiry. |

Rules that follow from the lifecycle:

* The arrival window can change (`PATCH /rs/v1/reservations/{id}`) only while the reservation is `requested` or `confirmed`. Once it is `arrived`, `completed`, or `canceled`, the window is fixed. Moving a window keeps the assigned stall and changes nothing else, and the new window is checked against the same rules as create.
* Cancel (`DELETE /rs/v1/reservations/{id}`) works from any status except `completed` and is safe to retry: canceling an already canceled reservation returns `200` with the same reservation. A cancel through the API records `canceled_by: partner`, and the optional `reason` query parameter (up to 500 characters) is stored on the reservation and echoed back as `cancel_reason`. Canceling also ends the runner's task; a runner already holding the order takes it back to the counter.
* **Window expiry.** A reservation still `confirmed` or `arrived` once its `arrival_window_end` has passed by the grace period (15 minutes by default) is closed by us: `completed` when the runner had loaded the order, otherwise `canceled` with `canceled_by: system` and `cancel_reason: window_expired`. Either way you receive a `reservation.status_changed` webhook. This is new behavior: before the runner rollout such reservations sat `confirmed` indefinitely. Report `DEPARTED` when your bot leaves so a loaded order completes on your report rather than on ours, and cancel a reservation you will not use so the stall is released sooner.
* Every status change fires a `reservation.status_changed` webhook. Moving a window fires nothing, because the window is not a status and you already know about the change. A cancel retry that finds the reservation already canceled returns `200` and fires nothing: treat the `200` as your confirmation and never wait on the webhook.

### The `bot` and `handoff` blocks

Both are `null` until populated and appear on every reservation you read, on every webhook payload, and on every write response.

* `bot`: `status` (your last accepted report), `label` (the display label you gave so the runner can pick your bot out at the pad), `eta` (your latest arrival estimate), `asserted_at` (when that status was reached).
* `handoff`: `status` (the runner task: `QUEUED`, `ASSIGNED`, `AT_STORE`, `HELD`, `RETRIEVED`, `AT_UNIT`, `HANDOFF_READY`, `LOADED`, `CANCELED`, `FAILED`), `task_started_at`, `order_in_hand_at`, `ready_estimate` (only while the runner is holding at the counter), `runner_at_unit_at`, `handoff_completed_at`, `load_photo_url` (a signed, time-limited link to the load photo; see [Load photos](#load-photos)), `load_photo_skipped`, and `runner_delay_seconds` (how late the runner is against the staging time while still waiting or dispatched; `null` when not late).

## Creating a reservation

`POST /rs/v1/reservations` books an arrival window in a zone for one retailer order:

```bash
curl -X POST https://api-sandbox.goautolane.com/rs/v1/reservations \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-8471" \
  -d '{
    "zone_id": "00000000-0000-4000-8000-000000000002",
    "retailer_id": "00000000-0000-4000-8000-000000000003",
    "order_number": "8471",
    "end_user_name": "Jordan Lee",
    "end_user_phone": "+15125550123",
    "arrival_window_start": "2026-09-01T17:00:00Z",
    "arrival_window_end": "2026-09-01T17:30:00Z",
    "open_link_url": "https://fleet.example.com/vehicles/veh-42/open",
    "retailer_pickup_ref": "PICKUP-8471",
    "payload_limit": { "max_weight_kg": 5, "max_dimensions_cm": { "length": 40, "width": 30, "height": 30 } }
  }'
```

Create-time rules:

* **Zone and retailer.** `zone_id` and `retailer_id` come from `GET /rs/v1/retailers`, and the retailer must be actively supported in the zone. Poll discovery to keep your picker current; zones with no supported retailers are omitted from its response.
* **Arrival window.** The window must start in the future, start no more than 24 hours ahead, and be between 5 and 120 minutes wide. Those three limits are deployment defaults and can be tuned per environment.
* **Phone.** `end_user_phone` must be E.164, for example `+15125550123`.
* **Open link.** `open_link_url` is the HTTPS endpoint our backend calls to open the vehicle door or trunk. It must use `https://` and resolve to a public address; we check that at create and reject anything else with `422 INVALID_OPEN_LINK`.
* **Stall assignment.** A stall is assigned by soft hold: the active stall holding the fewest reservations that overlap your window and are not yet completed or canceled. Capacity never rejects a booking, so a busy zone stacks reservations on its least loaded stall.
* **Pickup reference.** Optional `retailer_pickup_ref` (1 to 64 characters) is what the runner says at the retailer counter to collect the order. When absent, the runner uses `order_number`.
* **Payload limit.** Optional `payload_limit` (`max_weight_kg`, `max_dimensions_cm` with `length`, `width`, `height`; at least one of the two, every measure positive and at most 1000) turns on a size and weight check before the runner collects. An order that does not fit ends the runner task and you receive `reservation.exception` with `reason_code: payload_rejected`.

In this version a reservation is confirmed synchronously, so the `201` response already carries `status: "confirmed"`. That confirm is a real status change, so **a successful create also fires a `reservation.status_changed` webhook carrying `confirmed` for the reservation you just got a `201` for**. Expect it; it is not a duplicate.

### Idempotent retries

Send the optional `Idempotency-Key` header (1 to 255 characters) to make retries safe. Use one key per reservation you intend to create, and resend the same value when you retry. A retry with a key you already used returns the stored reservation with `200` instead of creating a second one. A retry whose zone, retailer, order number, end user name, end user phone, open link, `retailer_pickup_ref`, or `payload_limit` differs from the stored reservation is rejected with `422 IDEMPOTENCY_KEY_REUSED`; the arrival window is excluded from that comparison because it can be changed later with `PATCH`.

If create answers `503 URL_VALIDATION_UNAVAILABLE`, our capacity to validate `open_link_url` is exhausted; that is not a problem with your URL. Retry the same request, reusing your `Idempotency-Key` if you sent one.

## Reporting bot status

`POST /rs/v1/reservations/{id}/bot` tells us where your bot is for this reservation. It needs `reservations:write`.

```bash
curl -X POST https://api-sandbox.goautolane.com/rs/v1/reservations/RESERVATION_ID/bot \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "status": "ARRIVED",
    "label": "Serve 42",
    "eta": "2026-09-01T17:05:00Z",
    "occurred_at": "2026-09-01T17:04:12Z"
  }'
```

`status` is required. `label` (up to 64 characters) and `eta` are optional and are updated on every accepted report, repeats included; `occurred_at` defaults to now and may be late but not more than five minutes in the future; `eta` must be within 24 hours of now either way; `reason` (up to 256 characters) is for `ABORTED`.

| Status              | Meaning                                 | What happens                                                                                                                                                                                                                                                                                                                                                                                     |
| ------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `INBOUND`           | On the way                              | Records the status; an `eta` re-anchors the runner's staging time while no runner has been dispatched yet.                                                                                                                                                                                                                                                                                       |
| `ARRIVED`           | At the stall                            | A `confirmed` reservation becomes `arrived` (`reservation.status_changed`).                                                                                                                                                                                                                                                                                                                      |
| `READY_FOR_HANDOFF` | Compartment may be opened               | If the runner is already waiting at your bot, the handoff opens (`handoff.status` `HANDOFF_READY`). Also marks arrival if you skipped `ARRIVED`.                                                                                                                                                                                                                                                 |
| `SECURED`           | The bot holds the order                 | Ends the runner's task. A runner who had opened the compartment is recorded as loaded and you receive `reservation.handoff_updated` with `milestone: handoff_complete`; a runner who had not yet handed over takes the order back to the counter. If the runner's open attempt has no outcome yet, the task stays open and closes at the runner's load, at your `DEPARTED`, or at window expiry. |
| `DEPARTED`          | Leaving                                 | Runs the `SECURED` close if it has not happened yet, then completes the reservation (`reservation.status_changed` with `completed`; a reservation still `confirmed` is marked `arrived` first, so you may receive two events).                                                                                                                                                                   |
| `ABORTED`           | The bot will not serve this reservation | Flags a runner already carrying the order and fails a task that has not collected it yet. The reservation stays open until you cancel it or its window expires. Final for the bot: only a repeat `ABORTED` is accepted afterwards.                                                                                                                                                               |

The forward-only rule: statuses move in the order `INBOUND`, `ARRIVED`, `READY_FOR_HANDOFF`, `SECURED`, `DEPARTED`, and you may skip any of them. `ABORTED` is accepted from any status before `SECURED`. Repeating the current status is an idempotent `200` (it still updates `label` and `eta`). A status behind the current one is recorded but not applied and answers `409 BOT_STATUS_REGRESSION`. Once the reservation is `completed` or `canceled`, only a repeat of its current bot status is accepted; anything else is `409 INVALID_STATE`.

Side effects run in the same transaction as your report, so the response and every webhook it triggers already carry the updated `bot` and `handoff` blocks.

### Load photos

When the runner records the load, `handoff.load_photo_url` becomes a signed link to the photo, valid for 24 hours by default from when it was signed. It is set on `GET /rs/v1/reservations/{id}` (signed fresh on every read), on the `reservation.handoff_updated` webhook with `milestone: handoff_complete` that records the photo (see [Events](#events) for the case where that milestone arrives twice), and on the sandbox advance endpoint's response for its load step; it is `null` in every other webhook and response, so fetch the reservation when you need a link. If the link could not be signed when the webhook was queued, that webhook carries `load_photo_url: null` with `load_photo_skipped: false`; fetch the reservation for a link. `load_photo_skipped` is `true` when the load was recorded without a photo, for example because your bot reported `SECURED` before the runner's photo landed. Photos are kept for a limited period set per environment (90 days by default); after that the link answers `404` and fetching again does not help.

### Your open link

When the runner is at your bot and it has reported `READY_FOR_HANDOFF`, our backend calls your `open_link_url` with a `POST` carrying:

```json
{
  "reservation_id": "7f3e9c2b-1a5d-4e8f-b6c4-2d9a8e7f1c35",
  "occurred_at": "2026-09-01T17:09:30.412Z",
  "attempt_id": "3b7e1f2a-9c4d-4e6f-8a1b-2c3d4e5f6a7b"
}
```

`attempt_id` is the idempotency key. A retried attempt repeats the same `attempt_id` and the original `occurred_at`, so the body is byte-identical: treat a repeated `attempt_id` as a retry of the same open and return the original `2xx` rather than a conflict. Any `2xx` within 5 seconds is success; anything else, or a timeout, counts as a failed attempt. After three failed attempts (the default) the runner's task is flagged and you receive `reservation.exception` with `reason_code: access_failed`.

## Errors

Every error response has one shape:

```json
{
  "success": false,
  "error": "Arrival window must start in the future",
  "code": "WINDOW_IN_PAST"
}
```

`code` is stable and machine-readable; branch on it, never on the `error` text. The codes:

| Code                             | Status | Meaning                                                                                                                                                                                                                                                                                                        |
| -------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VALIDATION_ERROR`               | 400    | Malformed request: invalid JSON, a field that fails validation, an id that is not a UUID, an empty update body, a bot `eta` more than 24 hours from now or an `occurred_at` more than five minutes in the future, or an unknown `simulate_exception` code, or an `Idempotency-Key` outside 1 to 255 characters |
| `INVALID_API_KEY`                | 401    | Missing or invalid API key                                                                                                                                                                                                                                                                                     |
| `WRONG_ENV_KEY`                  | 403    | A sandbox key on the production host, or a production key on the sandbox host                                                                                                                                                                                                                                  |
| `PERMISSION_DENIED`              | 403    | The key lacks a permission the call needs; the message names what is missing                                                                                                                                                                                                                                   |
| `NOT_FLEET_PARTNER`              | 403    | The organization is not enrolled as an Autolane fleet partner                                                                                                                                                                                                                                                  |
| `ORG_INACTIVE`                   | 403    | Your organization is deactivated, or its setup has not yet reached this environment; every endpoint answers this until it has, whatever permissions the key carries                                                                                                                                            |
| `RESERVATION_NOT_FOUND`          | 404    | No such reservation for your organization. Another organization's reservation returns this same `404`, never a `403`, so ids cannot be probed                                                                                                                                                                  |
| `WEBHOOK_NOT_FOUND`              | 404    | No such webhook endpoint for your organization                                                                                                                                                                                                                                                                 |
| `NOT_FOUND`                      | 404    | The advance endpoint on production, which answers `404` before your key is even checked                                                                                                                                                                                                                        |
| `INVALID_STATE`                  | 409    | The reservation's status does not allow the action: moving a window once the reservation is `arrived`, `completed`, or `canceled`, canceling a `completed` reservation, reporting a new bot status on a `completed` or `canceled` reservation, or advancing a sandbox reservation that has no next step        |
| `BOT_STATUS_REGRESSION`          | 409    | The bot status you reported is behind the current one; it was recorded but not applied                                                                                                                                                                                                                         |
| `ZONE_NOT_FOUND`                 | 422    | `zone_id` does not name a reservable zone                                                                                                                                                                                                                                                                      |
| `RETAILER_NOT_SUPPORTED_IN_ZONE` | 422    | The retailer is not currently supported in that zone                                                                                                                                                                                                                                                           |
| `ZONE_HAS_NO_STALLS`             | 422    | The zone has no active stalls to hold                                                                                                                                                                                                                                                                          |
| `WINDOW_IN_PAST`                 | 422    | The arrival window starts in the past                                                                                                                                                                                                                                                                          |
| `WINDOW_TOO_FAR_OUT`             | 422    | The window starts more than 24 hours ahead                                                                                                                                                                                                                                                                     |
| `WINDOW_TOO_SHORT`               | 422    | The window is under 5 minutes wide                                                                                                                                                                                                                                                                             |
| `WINDOW_TOO_LONG`                | 422    | The window is over 120 minutes wide                                                                                                                                                                                                                                                                            |
| `INVALID_OPEN_LINK`              | 422    | `open_link_url` is not a public HTTPS endpoint                                                                                                                                                                                                                                                                 |
| `IDEMPOTENCY_KEY_REUSED`         | 422    | The `Idempotency-Key` was already used with a different body; the arrival window is excluded from the comparison                                                                                                                                                                                               |
| `INVALID_WEBHOOK_URL`            | 422    | The webhook URL is not a public HTTPS endpoint                                                                                                                                                                                                                                                                 |
| `WEBHOOK_LIMIT_REACHED`          | 422    | The organization already holds 10 webhook endpoints                                                                                                                                                                                                                                                            |
| `INTERNAL_ERROR`                 | 500    | Something failed on our side                                                                                                                                                                                                                                                                                   |
| `URL_VALIDATION_UNAVAILABLE`     | 503    | Our capacity to validate your URL is exhausted, not a problem with your URL. Retry the same request                                                                                                                                                                                                            |

## Webhooks

Register HTTPS endpoints that receive reservation events. Autolane signs every delivery with the secret belonging to the endpoint it is sent to.

### Registering an endpoint

Registration needs `reservations:read` as well as `webhooks:manage` (see [Permissions](#permissions)):

```bash
curl -X POST https://api-sandbox.goautolane.com/rs/v1/webhooks \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-server.com/webhooks/autolane",
    "subscribed_events": ["reservation.status_changed", "reservation.handoff_updated", "reservation.exception"]
  }'
```

The URL must resolve to a public address; we check that at registration. The response carries the endpoint's signing secret (prefixed `whsec_`) in full **for the only time**. Store it before you move on: every later read masks it as `whsec_…cdef`, showing only the last four characters, and the only way to get a usable secret again is to rotate it.

An organization may hold at most 10 endpoints, and deactivated endpoints still count against that limit because endpoints cannot be deleted. To move traffic to a new URL once you are at the limit, point an existing endpoint at it with `PUT /rs/v1/webhooks/{id}`.

Unlike creating a reservation, this endpoint ignores the `Idempotency-Key` header. A create you retry after a lost response can register a second endpoint that then receives every event alongside the first, turning each event into two deliveries. Duplicates show up in `GET /rs/v1/webhooks` and can be switched off with `PUT`.

### Events

Three events can be subscribed. Every one carries the whole reservation, `bot` and `handoff` blocks included, under `data.reservation`, so subscribing to any of them costs `reservations:read` at registration.

| Event                         | Fires                                                                                                                                                                                                                                                                                          | Extras in `data`                                                                                                         |
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `reservation.status_changed`  | Every status change: `confirmed`, `arrived`, `completed`, `canceled`.                                                                                                                                                                                                                          | none                                                                                                                     |
| `reservation.handoff_updated` | The runner milestones: `task_started` (the runner is at the store), `order_in_hand` (the order is collected), `ready_estimate` (the counter gave a ready time; a wait with no estimate sends nothing), `runner_at_unit` (the runner is at your bot), `handoff_complete` (the order is loaded). | `milestone`                                                                                                              |
| `reservation.exception`       | Exceptions that affect your delivery: `order_not_ready_fail`, `order_not_found`, `retailer_closed`, `payload_rejected`, `arrival_unverified`, `bot_no_show`, `stall_blocked`, `access_failed`.                                                                                                 | `reason_code`, `message` (a fixed sentence per code), `occurred_at` (when the condition happened, millisecond precision) |

`handoff_complete` can arrive twice for one reservation. The first time is when your bot reports `SECURED` before the runner has recorded the load: the load is implied, `handoff.load_photo_skipped` is `true` and `handoff.load_photo_url` is `null`. The second time is when the runner's load photo lands and upgrades that record, again as `reservation.handoff_updated` with `milestone: handoff_complete`, this time with the signed photo URL and `load_photo_skipped: false`. Loading alone never completes the reservation: completion and cancellation arrive as `reservation.status_changed`.

Several events can come out of one change. Your `DEPARTED` report on a reservation whose runner has opened the compartment but not yet recorded the load can send `handoff_complete` (the load is implied), then `arrived`, then `completed`. [Ordering](#ordering) says how to put them in order.

What is emitted today: `reservation.status_changed` is live. `reservation.handoff_updated` and `reservation.exception` begin when the runner app goes live; the milestones, the reason codes, and the signed `load_photo_url` are part of this contract so you can subscribe and build against them now, and the [sandbox walkthrough](#sandbox-walkthrough) fires all three today.

### Event payload

Each delivery is a `POST` to your URL. The envelope is `{ event_id, event_type, occurred_at, data: { reservation, ...extras } }`, with the extras flat in `data`:

```json
{
  "event_id": "0d9f2b1a-6c3e-4f7a-9b2d-8e1c5a4f3b21",
  "event_type": "reservation.handoff_updated",
  "occurred_at": "2026-09-01T17:09:41.284913Z",
  "data": {
    "milestone": "handoff_complete",
    "reservation": {
      "reservation_id": "7f3e9c2b-1a5d-4e8f-b6c4-2d9a8e7f1c35",
      "status": "arrived",
      "zone_id": "00000000-0000-4000-8000-000000000002",
      "stall_id": "b4d1f8a2-3c6e-4b9d-8f2a-5e7c1d9b3a64",
      "stall_label": "Blue 3",
      "retailer_id": "00000000-0000-4000-8000-000000000003",
      "order_number": "8471",
      "end_user_name": "Jordan Lee",
      "end_user_phone": "+15125550123",
      "arrival_window_start": "2026-09-01T17:00:00Z",
      "arrival_window_end": "2026-09-01T17:30:00Z",
      "open_link_url": "https://fleet.example.com/vehicles/veh-42/open",
      "canceled_by": null,
      "cancel_reason": null,
      "retailer_pickup_ref": "PICKUP-8471",
      "payload_limit": { "max_weight_kg": 5 },
      "bot": {
        "status": "READY_FOR_HANDOFF",
        "label": "Serve 42",
        "eta": "2026-09-01T17:05:00Z",
        "asserted_at": "2026-09-01T17:08:02Z"
      },
      "handoff": {
        "status": "LOADED",
        "task_started_at": "2026-09-01T16:52:10Z",
        "order_in_hand_at": "2026-09-01T16:58:44Z",
        "ready_estimate": null,
        "runner_at_unit_at": "2026-09-01T17:06:31Z",
        "handoff_completed_at": "2026-09-01T17:09:41Z",
        "load_photo_url": "https://storage.googleapis.com/...signed...",
        "load_photo_skipped": false,
        "runner_delay_seconds": null
      },
      "created_at": "2026-09-01T14:22:05Z",
      "updated_at": "2026-09-01T17:09:41Z"
    }
  }
}
```

The reservation object is the same shape the reservation endpoints return, as it stood at the moment the event occurred. On `reservation.exception` the extras are `data.reason_code`, `data.message`, and `data.occurred_at`; the latter can sit before the envelope `occurred_at` when we raise the exception on a stored deadline, such as a wait that ran out.

Each request carries these headers:

| Header                   | Description                                                                    |
| ------------------------ | ------------------------------------------------------------------------------ |
| `X-Autolane-Signature`   | `t=<unix-seconds>,v1=<hex>`; see [Verifying signatures](#verifying-signatures) |
| `X-Autolane-Delivery-Id` | Names one delivery; the same value arrives again when that delivery retries    |
| `Content-Type`           | `application/json`                                                             |

### Verifying signatures

Every delivery carries `X-Autolane-Signature: t=<unix-seconds>,v1=<hex>`. The hex is an HMAC-SHA256 over the exact string `"<t>.<raw request body>"`. The key is the endpoint secret as a whole string, `whsec_` prefix included, taken as UTF-8 bytes: do not strip the prefix, and do not hex-decode the 48 characters after it. Verify by recomputing that HMAC over the raw bytes you received, before any JSON parsing, and comparing constant-time.

**A matching hex is not enough.** Check that `t` is close to your own clock and reject the delivery when it is not. Five minutes either way is a sound tolerance and leaves room for clock skew. Skip this step and a captured delivery stays valid forever. The timestamp is signed along with the body, so once the hex matches you know `t` came from us and not from whoever replayed it.

```javascript
import { createHmac, timingSafeEqual } from 'crypto';
import express from 'express';

const app = express();

// Verification needs the raw bytes exactly as received, before any JSON parsing
app.use(
  express.json({
    verify: (req, _res, buf) => {
      req.rawBody = buf;
    },
  }),
);

const TOLERANCE_SECONDS = 5 * 60;

function verifySignature(rawBody, header, secret) {
  if (!header) return false;

  // Header format: t=<unix-seconds>,v1=<hex>
  const parts = Object.fromEntries(
    header.split(',').map((pair) => pair.split('=', 2)),
  );
  const t = Number(parts.t);
  const provided = parts.v1;
  if (!Number.isInteger(t) || !provided) return false;

  // Reject stale timestamps: a matching HMAC alone would let a
  // captured delivery verify forever.
  const nowSeconds = Math.floor(Date.now() / 1000);
  if (Math.abs(nowSeconds - t) > TOLERANCE_SECONDS) return false;

  // The key is the whole secret string, whsec_ prefix included, as UTF-8
  const expected = createHmac('sha256', secret)
    .update(`${t}.`)
    .update(rawBody)
    .digest('hex');

  const providedBuffer = Buffer.from(provided, 'utf8');
  const expectedBuffer = Buffer.from(expected, 'utf8');
  if (providedBuffer.length !== expectedBuffer.length) return false;
  return timingSafeEqual(providedBuffer, expectedBuffer);
}

app.post('/webhooks/autolane', (req, res) => {
  const isValid = verifySignature(
    req.rawBody,
    req.get('x-autolane-signature'),
    process.env.AUTOLANE_WEBHOOK_SECRET, // the whsec_... secret from registration
  );
  if (!isValid) {
    return res.status(401).send('Invalid signature');
  }

  const { event_type, data } = req.body;
  const { reservation } = data;
  console.log(`${event_type}: reservation ${reservation.reservation_id} is ${reservation.status}, handoff ${reservation.handoff?.status ?? 'none'}`);

  res.status(200).send('OK');
});

app.listen(3000);
```

A rejection spends a retry attempt, and the retry schedule below runs out: a delivery your clock rejected 8 times is dropped for good. There is no replay endpoint, so if you have rejected deliveries or suspect your clock is off, read current state from `GET /rs/v1/reservations` instead of waiting for us to send it again.

### Delivery is at-least-once: dedup on two keys

Every delivery carries `X-Autolane-Delivery-Id`, and every payload carries `event_id`. They are different keys and you need both:

* **The delivery id names one delivery.** Delivery is at-least-once, so the same delivery id can arrive more than once, and a repeat is not a new event. Dedup on the delivery id to drop retries.
* **The event id names one event (a status change, a milestone, or an exception).** Two of your endpoints subscribed to the same event (a retried create can leave two of them on one URL) turn a single change into two deliveries carrying two delivery ids and one shared event id. Dedup on the event id to collapse one event that reached you through more than one endpoint.

### Ordering

Deliveries are not ordered. Each one retries on its own schedule and several are sent at once, so a later event can land before an earlier one. Order by `occurred_at`, never by arrival. Every envelope `occurred_at` comes from one clock, our database's, stamped with microsecond precision (six fractional digits, as in the example above) as the event is recorded, so ordering by it reproduces the order the changes happened in, whichever of our instances recorded them. That clock reads the host time, so it is not a counter: two stamps can tie at microsecond precision, and a clock correction on our side can put a later event marginally earlier. When you need one total order, sort by `occurred_at` and then `event_id`; that makes the order deterministic, the same on every reader, though not causal for two events stamped within the same microsecond. Do not read `occurred_at` as a sequence number. Every payload is a full snapshot, the reservation as it stood at that moment rather than a diff, so applying deliveries in the order they turn up can walk a reservation backwards. Keep the `occurred_at` and `event_id` of the last snapshot you applied to each reservation and drop any delivery that sorts earlier, so the reservation you hold only moves forward; a `completed` or `canceled` snapshot is final, and nothing stamped later reopens it. When two deliveries you cannot order disagree, `GET /rs/v1/reservations/{id}` returns the current state.

### Retries

Up to 8 attempts per delivery. Any non-2xx response fails an attempt, and redirects are not followed, so a 3xx fails too. Each attempt is given 10 seconds. Backoff doubles between attempts (2, 4, 8 and so on, in minutes) and is capped at 60 minutes; after the eighth attempt the delivery is dropped. Acknowledge with a `2xx` quickly and do heavy processing after you respond.

### Managing endpoints

`PUT /rs/v1/webhooks/{id}` changes the URL, the subscribed events, or the active flag, and rotates the secret. Every field is optional, but the body must carry at least one of them. Concurrent `PUT`s are last write wins: every field you send is written whether or not it changed, so a body built from a stale `GET` puts back what it read.

* **Pausing.** Set `is_active` to `false` to stop deliveries without giving up the slot. While an endpoint is inactive no new event is queued for it, but a delivery already queued gets no promise either way: it may be discarded, or let through if you switch back on quickly. Do not build on either outcome.
* **Repointing.** Point an existing endpoint at a new URL to reuse a slot, but repoint only to a host you control: the URL is read when a delivery is sent, not when it is queued, so every pending delivery built up under the old URL goes to the new host, and a backlog spanning about three hours of the retry schedule can arrive as a burst.
* **Rotating the secret.** Send `rotate_secret: true` to mint a new secret. The response returns it in full exactly once, and new deliveries are signed with it at once, but a delivery already picked up for sending keeps the old secret for up to 90 seconds. Accept both signatures for a few minutes after a rotation rather than cutting the old one off. Rotation is not idempotent, and this endpoint ignores `Idempotency-Key`: a `PUT` you retry after a lost response rotates a second time, and the secret from the first rotation is gone for good. If a rotate response goes missing, rotate again and use the secret you get back.

## Sandbox walkthrough

`POST /rs/v1/reservations/{id}/advance` exists only on the sandbox host. Each call takes the next step of the runner happy path for you, so you can drive a reservation end to end without a runner or a bot, and every step fires the same webhooks the real event would, with the same payloads. On production the route always answers `404 NOT_FOUND`, before your key is even checked.

The steps, in order: a runner is assigned (`handoff.status` `ASSIGNED`); the runner is at the store (`handoff_updated` `task_started`); the order is in hand (`order_in_hand`); the bot reports `ARRIVED` (`status_changed` `arrived`); the runner is at the bot (`runner_at_unit`); the bot reports `READY_FOR_HANDOFF`; the runner loads (`handoff_complete`, with a signed `load_photo_url` to a placeholder photo); the bot reports `SECURED`; the bot reports `DEPARTED` (`status_changed` `completed`). Nine calls from `confirmed` to `completed`.

The walk skips whatever you already did. Report bot statuses yourself through `POST /rs/v1/reservations/{id}/bot` at any point and the walk will not report them again; if your `SECURED` already ended the runner's task, the walk leaves it as it is. A task that was canceled or failed (a simulated exception, say) is skipped too, and the bot steps still complete the reservation. This endpoint never calls your `open_link_url`.

Two things a real runner would also face apply here. Calls are not idempotent: a retry after a lost response takes the following step, so read the reservation before retrying. And the runner sweeper's clocks keep running: a task left at the store past the arrival window end fails with `reservation.exception` (`order_not_ready_fail`), and one left at your bot past the wait timeout (10 minutes by default) is flagged `bot_no_show`. A task that has failed is skipped by later steps while the bot steps still complete the reservation, so walk a fresh reservation briskly, or move its window with `PATCH` first.

Drive a reservation end to end against the sandbox:

**1. Register a webhook endpoint** subscribed to all three events (see [Registering an endpoint](#registering-an-endpoint)). Register it first: an endpoint registered after a change receives nothing for it.

**2. Create a reservation** using the sandbox zone and retailer (see [Creating a reservation](#creating-a-reservation)). The `201` response carries `status: "confirmed"` and `handoff.status: "QUEUED"`, and your endpoint receives `reservation.status_changed` carrying `confirmed`.

**3. Advance, nine times:**

```bash
curl -X POST https://api-sandbox.goautolane.com/rs/v1/reservations/RESERVATION_ID/advance \
  -H "Authorization: Bearer YOUR_API_KEY"
```

Each `200` carries the reservation after the step. Watch `bot.status` and `handoff.status` move, and match the deliveries at your endpoint against the list above. After the load step, `GET /rs/v1/reservations/RESERVATION_ID` carries a `handoff.load_photo_url` that answers `200`.

**4. Advance past the end:** a tenth call answers `409 INVALID_STATE`, because a completed reservation has no next step. The same `409` comes back for a canceled reservation and for one whose bot reported `ABORTED`.

**5. Simulate an exception:** on another reservation, after a step or two, send a body:

```bash
curl -X POST https://api-sandbox.goautolane.com/rs/v1/reservations/RESERVATION_ID/advance \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "simulate_exception": "stall_blocked" }'
```

Instead of taking a step, this raises the exception on the runner's task and your endpoint receives `reservation.exception` with `reason_code: stall_blocked` and its fixed `message`. Any of the eight reason codes works. `order_not_ready_fail` and `payload_rejected` end the task (`handoff.status` `FAILED`); the other six set a flag that the next step clears, and repeating the flag already set is a `200` that sends nothing. A task that has already ended answers `409 INVALID_STATE`.

To exercise the cancel path, stop anywhere and call `DELETE /rs/v1/reservations/RESERVATION_ID`; the event that follows carries `canceled` with `canceled_by: "partner"`, and the `handoff` block shows the task closed.