Skip to navigation

Webhook Events & Verification

Webhook Events & Verification

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

Setup

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

EventDescription
pickup_status.changedFires 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.
tracking_link.createdFires when a customer-facing tracking link is generated for a delivery (at the moment the pickup is scheduled).
pickup.vehicle_location.changedFires 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

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

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

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

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

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

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

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

HeaderDescription
X-Autolane-SignatureHMAC-SHA256 hex digest of the raw body, signed with your secret
X-Autolane-Delivery-IdUUID for this delivery attempt. Stable across retries
Content-Typeapplication/json
User-AgentAutolane-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:

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.

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

AttemptApproximate delay after the previous attempt
1initial delivery
230 seconds
360 seconds
4120 seconds
5240 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.