Skip to navigation

Reservations

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:

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

Create API keys in the Autolane Portal 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 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:

PermissionGrants
retailers:readGET /rs/v1/retailers
reservations:readGET /rs/v1/reservations and GET /rs/v1/reservations/{id}
reservations:writeCreate, move, and cancel reservations; report bot status; the sandbox-only advance endpoint
webhooks:manageEvery /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

EnvironmentBase URL
Productionhttps://api.goautolane.com
Sandboxhttps://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:

EntityNameId
SiteSandbox Mall00000000-0000-4000-8000-000000000001
ZoneSandbox AV Zone A00000000-0000-4000-8000-000000000002
RetailerSandbox 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.

StatusMeaning and trigger
requestedAccepted, not yet confirmed. In this version a create confirms synchronously, so you will not observe it.
confirmedThe 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.
arrivedYour bot is at the stall. Set by your first bot report at ARRIVED or any later status (see Reporting bot status); a report that skips ARRIVED still marks arrival.
completedThe 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.
canceledTerminal. 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_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:

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.

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.

StatusMeaningWhat happens
INBOUNDOn the wayRecords the status; an eta re-anchors the runner’s staging time while no runner has been dispatched yet.
ARRIVEDAt the stallA confirmed reservation becomes arrived (reservation.status_changed).
READY_FOR_HANDOFFCompartment may be openedIf the runner is already waiting at your bot, the handoff opens (handoff.status HANDOFF_READY). Also marks arrival if you skipped ARRIVED.
SECUREDThe bot holds the orderEnds 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.
DEPARTEDLeavingRuns 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).
ABORTEDThe bot will not serve this reservationFlags 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 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.

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:

{
"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:

{
"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:

CodeStatusMeaning
VALIDATION_ERROR400Malformed 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_KEY401Missing or invalid API key
WRONG_ENV_KEY403A sandbox key on the production host, or a production key on the sandbox host
PERMISSION_DENIED403The key lacks a permission the call needs; the message names what is missing
NOT_FLEET_PARTNER403The organization is not enrolled as an Autolane fleet partner
ORG_INACTIVE403Your 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_FOUND404No such reservation for your organization. Another organization’s reservation returns this same 404, never a 403, so ids cannot be probed
WEBHOOK_NOT_FOUND404No such webhook endpoint for your organization
NOT_FOUND404The advance endpoint on production, which answers 404 before your key is even checked
INVALID_STATE409The 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_REGRESSION409The bot status you reported is behind the current one; it was recorded but not applied
ZONE_NOT_FOUND422zone_id does not name a reservable zone
RETAILER_NOT_SUPPORTED_IN_ZONE422The retailer is not currently supported in that zone
ZONE_HAS_NO_STALLS422The zone has no active stalls to hold
WINDOW_IN_PAST422The arrival window starts in the past
WINDOW_TOO_FAR_OUT422The window starts more than 24 hours ahead
WINDOW_TOO_SHORT422The window is under 5 minutes wide
WINDOW_TOO_LONG422The window is over 120 minutes wide
INVALID_OPEN_LINK422open_link_url is not a public HTTPS endpoint
IDEMPOTENCY_KEY_REUSED422The Idempotency-Key was already used with a different body; the arrival window is excluded from the comparison
INVALID_WEBHOOK_URL422The webhook URL is not a public HTTPS endpoint
WEBHOOK_LIMIT_REACHED422The organization already holds 10 webhook endpoints
INTERNAL_ERROR500Something failed on our side
URL_VALIDATION_UNAVAILABLE503Our 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):

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.

EventFiresExtras in data
reservation.status_changedEvery status change: confirmed, arrived, completed, canceled.none
reservation.handoff_updatedThe 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.exceptionExceptions 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 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 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:

{
"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:

HeaderDescription
X-Autolane-Signaturet=<unix-seconds>,v1=<hex>; see Verifying signatures
X-Autolane-Delivery-IdNames one delivery; the same value arrives again when that delivery retries
Content-Typeapplication/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.

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 PUTs 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). 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). The 201 response carries status: "confirmed" and handoff.status: "QUEUED", and your endpoint receives reservation.status_changed carrying confirmed.

3. Advance, nine times:

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:

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.