> 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.

# Webhook Events & Verification

> Receive real-time delivery status updates via webhooks. Events, payload schemas, signature verification, idempotency, and retry behavior.

# Webhook Events & Verification

Register a webhook endpoint to receive real-time delivery status updates instead of polling.

## Setup

```bash
curl -X POST https://api.goautolane.com/dd/v1/webhooks \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "x-retailer-id: a1b2c3d4-e5f6-7890-1234-567890abcdef" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-server.com/webhooks/autolane",
    "events": ["pickup_status.changed", "tracking_link.created"]
  }'
```

The response includes a signing secret (prefixed `alwh_`). **Store it securely. It is only shown once.** Subsequent `GET /dd/v1/webhooks` responses return the secret masked as `alwh_****...`. If you need to rotate your secret later, call `PUT /dd/v1/webhooks` with `{ "rotate_secret": true }` and the new secret is returned in full.

### One webhook URL per organization

Each Autolane organization has a single webhook subscription. Re-registering with `POST /dd/v1/webhooks` while one already exists returns `409 WEBHOOK_EXISTS`; use `PUT /dd/v1/webhooks` to update the URL or subscribed events instead. If you operate multiple sites under one organization, all of their delivery events arrive at the same URL. Note that `external_delivery_id` is unique per `(retailer, external_delivery_id)` in the API, so two sites under the same org could legitimately use the same `external_delivery_id` value for different deliveries; ensure your IDs are org-unique (for example, prefix them with your site identifier) before routing on that field alone. DSP aggregators that bridge multiple retailer organizations should register one webhook per partner organization's API key.

## Events

| Event                             | Description                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pickup_status.changed`           | Fires on each pickup status transition: `ASSIGNED`, `EN_ROUTE_TO_STORE`, `ARRIVED_AT_STORE`, `WAITING_FOR_LOAD`, `LOADED`, `EN_ROUTE_TO_CUSTOMER`, `ARRIVED_AT_CUSTOMER`, `COMPLETED`, `FAILED`, `CANCELLED`, and on sites with returns enabled `RETURN_IN_PROGRESS`, `RETURN_ARRIVED`, `RETURNED`. For sites with stall reporting enabled, store-side statuses also carry `stall_id`; return statuses carry the [return fields](#return-fields). |
| `tracking_link.created`           | Fires when a customer-facing tracking link is generated for a delivery (at the moment the pickup is scheduled).                                                                                                                                                                                                                                                                                                                                   |
| `pickup.vehicle_location.changed` | Fires when the assigned autonomous vehicle's location updates during the delivery. Subscribe to this event to drive live map UIs on your side.                                                                                                                                                                                                                                                                                                    |

Subscribe to additional events at any time by calling `PUT /dd/v1/webhooks` with an updated `events` array.

## Payloads

When an event fires, Autolane sends a `POST` request to your registered URL with a JSON body.

### `pickup_status.changed`

```json
{
  "event": "pickup_status.changed",
  "timestamp": "2026-04-10T12:00:00Z",
  "data": {
    "external_delivery_id": "ACME-12345",
    "status": "EN_ROUTE_TO_CUSTOMER",
    "previous_status": "ASSIGNED",
    "estimated_arrival": "2026-04-10T12:30:00Z",
    "customer": {
      "phone": "+15551234567"
    },
    "delivery_address": {
      "street": "123 Main St",
      "unit": "4B",
      "city": "Austin",
      "state": "TX",
      "zip": "78701"
    }
  }
}
```

`estimated_arrival` is the estimated arrival time **at the customer** — the delivery ETA locked when the quote was created. It is `null` only when no delivery ETA is available for the delivery. On `RETURN_IN_PROGRESS` it is the estimated arrival back **at the store** instead; see [Return fields](#return-fields).

If you are also subscribed to `pickup.vehicle_location.changed`, this event additionally carries a `vehicle_location` field with the vehicle's position at the moment the status changed. The field is `null` when no fresh location fix (within the last 5 minutes) is available, and is omitted entirely when you are not subscribed to the location event.

#### `stall_id` (sites with stall reporting)

Some stores identify the stall the vehicle is parked in so staff know where to bring the order. Ask your Autolane contact to enable **stall reporting** for a site. When it is enabled:

* `data.stall_id` is present on `ARRIVED_AT_STORE`, `WAITING_FOR_LOAD`, and `LOADED` events. It is a short uppercase string such as `A4` or `B-12`.
* It is `null` on those statuses when no stall was recorded for the arrival.
* It is omitted entirely on every other status, and omitted for sites without stall reporting.
* If the stall changes while the delivery is at `ARRIVED_AT_STORE` or `WAITING_FOR_LOAD`, you receive another `pickup_status.changed` event with `previous_status` equal to `status` and the new `stall_id`. Treat the newest event by `timestamp` as authoritative. If you de-duplicate on `(external_delivery_id, status)`, exempt events whose `previous_status` equals `status`. A delivery that is already `LOADED` gets no correction event; the GET endpoint still returns its current stall.

```json
{
  "event": "pickup_status.changed",
  "timestamp": "2026-09-10T18:02:11Z",
  "data": {
    "external_delivery_id": "ACME-12345",
    "status": "ARRIVED_AT_STORE",
    "previous_status": "EN_ROUTE_TO_STORE",
    "estimated_arrival": "2026-09-10T18:30:00Z",
    "customer": { "phone": "+15551234567" },
    "delivery_address": { "street": "123 Main St", "city": "Austin", "state": "TX", "zip": "78701" },
    "stall_id": "A4"
  }
}
```

`GET /dd/v1/deliveries/{external_delivery_id}` returns the same `stall_id` under the same rule, so polling integrations see it too.

#### Return fields (sites with returns enabled)

When a site has returns enabled and a cancel lands at `LOADED` or later, the delivery reports `RETURN_IN_PROGRESS`, then `RETURN_ARRIVED`, then `RETURNED`, always in that order and never followed by `EN_ROUTE_TO_CUSTOMER`, `ARRIVED_AT_CUSTOMER`, or `COMPLETED`. Return events for one delivery are sent in order: `RETURN_ARRIVED` is not sent until `RETURN_IN_PROGRESS` was delivered or given up on, and the same holds for `RETURNED`. See [Returns](/direct-delivery/overview#returns) for the cancel matrix.

On the three return statuses `data` carries:

* `return_reason`: `CUSTOMER_CANCELLED`, `COLD_CHAIN_EXPIRED`, or `OTHER`, normalized from the cancel's `cancellation_reason` (any unrecognized or non-string value becomes `OTHER`), or `null` when the cancel carried none.
* `return_stall_id` (sites with stall reporting only): `null` until a return stall is recorded, then a short uppercase string such as `B21`. Omitted for sites without stall reporting. `stall_id` keeps the value from the pickup and never changes during a return.
* `return_fee_cents` on `RETURNED`: the flat per-site fee in cents, or `null` when no fee is set.
* `estimated_arrival`: the estimated arrival back at the store on `RETURN_IN_PROGRESS`; `null` on `RETURN_ARRIVED` and `RETURNED`.
* `vehicle_location` under the same rules as every other status, through `RETURNED`.

If the return stall changes while the delivery is `RETURN_ARRIVED`, you receive another `pickup_status.changed` with `status` and `previous_status` both `RETURN_ARRIVED` and the new `return_stall_id`. Treat the newest event by `timestamp` as authoritative, and exempt events whose `previous_status` equals `status` from `(external_delivery_id, status)` de-duplication, as for stall corrections.

Return starts (cancel received at `EN_ROUTE_TO_CUSTOMER`):

```json
{
  "event": "pickup_status.changed",
  "timestamp": "2026-09-24T18:30:12Z",
  "data": {
    "external_delivery_id": "tsk_abc123",
    "status": "RETURN_IN_PROGRESS",
    "previous_status": "EN_ROUTE_TO_CUSTOMER",
    "return_reason": "COLD_CHAIN_EXPIRED",
    "stall_id": "A07",
    "return_stall_id": null,
    "estimated_arrival": "2026-09-24T18:42:00Z",
    "vehicle_location": { "latitude": 30.2672, "longitude": -97.7431, "recorded_at": "2026-09-24T18:30:11Z" }
  }
}
```

Back at the store, with a return stall:

```json
{
  "event": "pickup_status.changed",
  "timestamp": "2026-09-24T18:41:56Z",
  "data": {
    "external_delivery_id": "tsk_abc123",
    "status": "RETURN_ARRIVED",
    "previous_status": "RETURN_IN_PROGRESS",
    "estimated_arrival": null,
    "return_reason": "COLD_CHAIN_EXPIRED",
    "stall_id": "A07",
    "return_stall_id": "B21",
    "vehicle_location": { "latitude": 30.2690, "longitude": -97.7402, "recorded_at": "2026-09-24T18:41:55Z" }
  }
}
```

The store took the order back:

```json
{
  "event": "pickup_status.changed",
  "timestamp": "2026-09-24T18:47:03Z",
  "data": {
    "external_delivery_id": "tsk_abc123",
    "status": "RETURNED",
    "previous_status": "RETURN_ARRIVED",
    "estimated_arrival": null,
    "return_reason": "COLD_CHAIN_EXPIRED",
    "stall_id": "A07",
    "return_stall_id": "B21",
    "vehicle_location": { "latitude": 30.2690, "longitude": -97.7402, "recorded_at": "2026-09-24T18:46:50Z" },
    "return_fee_cents": 450
  }
}
```

`customer` and `delivery_address` are present as on every other status; the examples omit them for brevity. `GET /dd/v1/deliveries/{external_delivery_id}` returns `return_reason`, `return_stall_id`, and (once `RETURNED`) `return_fee_cents` under the same rules.

### `tracking_link.created`

```json
{
  "event": "tracking_link.created",
  "timestamp": "2026-04-14T17:00:00Z",
  "data": {
    "external_delivery_id": "ACME-12345",
    "tracking_url": "https://autola.ne/t/tok_abc123def456",
    "customer": {
      "phone": "+14155551234"
    }
  }
}
```

### `pickup.vehicle_location.changed`

```json
{
  "event": "pickup.vehicle_location.changed",
  "timestamp": "2026-05-11T15:00:30Z",
  "data": {
    "external_delivery_id": "ACME-12345",
    "vehicle_location": {
      "latitude": 30.2672,
      "longitude": -97.7431,
      "recorded_at": "2026-05-11T15:00:28Z"
    }
  }
}
```

`recorded_at` is the timestamp on the underlying telemetry sample. It may lag the outer `timestamp` by a few seconds.

Events are not strictly ordered across event types. A `pickup.vehicle_location.changed` event may arrive shortly after a terminal `pickup_status.changed` (`ARRIVED_AT_CUSTOMER`, `COMPLETED`) for the same delivery. Use the `timestamp` field to reconcile relative ordering on your side.

### Request headers

Each request includes these headers:

| Header                   | Description                                                     |
| ------------------------ | --------------------------------------------------------------- |
| `X-Autolane-Signature`   | HMAC-SHA256 hex digest of the raw body, signed with your secret |
| `X-Autolane-Delivery-Id` | UUID for this delivery attempt. Stable across retries           |
| `Content-Type`           | `application/json`                                              |
| `User-Agent`             | `Autolane-Webhook/1.0`                                          |

Your endpoint should return any `2xx` status code to acknowledge receipt. Non-2xx responses (and request timeouts past 10 seconds) trigger retries.

## Verifying Signatures

Use the signing secret from your webhook registration to verify that each delivery is authentically from Autolane. The `X-Autolane-Signature` header contains an HMAC-SHA256 hex digest of the raw request body, computed using your `alwh_`-prefixed secret.

**Always verify signatures using a timing-safe comparison** to prevent timing attacks:

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

const app = express();

// Capture raw body for webhook signature verification
app.use(
  express.json({
    verify: (req, _res, buf) => {
      req.rawBody = buf;
    },
  }),
);

function verifyWebhookSignature(rawBody, signature, secret) {
  // secret is your alwh_-prefixed signing secret from webhook registration
  const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
  const providedBuffer = Buffer.from(signature ?? '', '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 signature = req.get('x-autolane-signature');
  const isValid = verifyWebhookSignature(
    req.rawBody,
    signature,
    process.env.AUTOLANE_WEBHOOK_SECRET,
  );

  if (!isValid) {
    return res.status(401).send('Invalid signature');
  }

  const event = req.body;
  if (event.event === 'pickup_status.changed') {
    console.log(`Delivery ${event.data.external_delivery_id} is now ${event.data.status}`);
  } else if (event.event === 'tracking_link.created') {
    console.log(`Tracking link created for ${event.data.external_delivery_id}`);
  } else if (event.event === 'pickup.vehicle_location.changed') {
    const { latitude, longitude } = event.data.vehicle_location;
    console.log(`Vehicle at ${latitude}, ${longitude}`);
  }

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

### Replay protection

The signature covers the request body only and does not include a timestamp. To reject replayed events at your endpoint, combine two payload-level signals:

* The top-level `timestamp` field on every event is an ISO 8601 string. Reject events whose `timestamp` is more than a small tolerance (e.g., 5 minutes) outside your server clock.
* The `X-Autolane-Delivery-Id` header is a UUID stable across retries for the same delivery attempt. Treat it as the dedupe key (see the next section).

## Idempotency

Each delivery includes an `X-Autolane-Delivery-Id` header, a UUID that stays the same across retries. Store processed delivery IDs in a shared store (Redis, your database, etc.) and reject duplicates so retried deliveries don't double-fire your business logic.

```javascript
import { createClient } from 'redis';

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

const DEDUP_TTL_SECONDS = 60 * 60 * 24 * 7; // keep dedupe state for 7 days

app.post('/webhooks/autolane', async (req, res) => {
  const deliveryId = req.headers['x-autolane-delivery-id'];

  // SET key value NX EX <ttl>: atomic "set if not exists with TTL".
  // Returns null when the key already exists, indicating a duplicate.
  const reserved = await redis.set(`autolane:webhook:${deliveryId}`, '1', {
    NX: true,
    EX: DEDUP_TTL_SECONDS,
  });
  if (reserved === null) {
    return res.status(200).send('Already processed');
  }

  // Process the event...

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

Note that `external_delivery_id` is the **create-side** idempotency key (see [Idempotency](/direct-delivery/overview#idempotency) in the Overview). It identifies the underlying delivery and stays the same across all events for that delivery. `X-Autolane-Delivery-Id` identifies one webhook event delivery (stable across the retries for that event) and changes from one event to the next.

## Retries

Failed deliveries (non-2xx responses or request timeouts past 10 seconds) are retried with exponential backoff on a 30-second base:

| Attempt | Approximate delay after the previous attempt |
| ------- | -------------------------------------------- |
| 1       | initial delivery                             |
| 2       | 30 seconds                                   |
| 3       | 60 seconds                                   |
| 4       | 120 seconds                                  |
| 5       | 240 seconds                                  |

After 5 failed attempts the event is given up on. Failed attempts also increment a per-organization `consecutive_failures` counter; a successful delivery resets it to zero.

If the counter reaches **50 consecutive failures** the webhook endpoint is automatically disabled and the notification email on your account receives a heads-up. Call `PUT /dd/v1/webhooks` (with any valid update body, or just `{ "url": "<your-current-url>" }`) to re-enable the endpoint. Re-enabling resets the failure counter to zero.