This is the abridged developer documentation for Delivr Merchant API
# Overview
> How the Delivr Merchant API fits into your checkout, your stores and your order system.
The Delivr Merchant API books same-day deliveries from your stores to your customers. You use it from your own servers: your web shop’s checkout, a point-of-sale system, or an order management system. Delivr offers two kinds of delivery: | Type | What the shopper gets | How it is fulfilled | | ----------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------ | | `on_demand` | A driver now. An arrival band such as “today 14:20 to 14:50”. | Delivr finds a nearby driver, timed so they arrive when your goods are packed. | | `window` | A delivery window, such as “today 17:00 to 19:00”. | Booked on a Delivr transport partner’s route for that window. | ## How an integration works [Section titled “How an integration works”](#how-an-integration-works) 1. **Stores.** You register each store or warehouse goods are picked up at as a [location](/guides/locations/). 2. **Quote.** At checkout, your server asks for a [quote](/guides/quotes-and-orders/) from a store to the shopper’s address. The answer lists the options with prices and times. 3. **Order.** When the shopper pays, you place an order for the option they chose. Delivr texts the recipient a tracking link. 4. **Fulfil.** Your store tells Delivr when the goods [will be ready](/guides/ready-signals/). A driver picks them up and delivers. 5. **Follow.** [Webhooks](/webhooks/) tell your system about every step, and the order endpoint is always the source of truth. 6. **Pay.** Delivr [bills you](/guides/billing/) weekly for delivered orders. You charge the shopper in your own checkout. ## Environments [Section titled “Environments”](#environments) | Environment | Base URL | Use | | ----------- | -------------------------------------- | -------------------------------------------------------------------------------- | | Sandbox | `https://driver-staging-api.delivr.no` | Build and test. No SMS is sent, and [test helpers](/sandbox/) move orders along. | | Production | Given at onboarding | Real drivers and real deliveries. | Every route lives under `/v1/merchant/`. Your sandbox and production keys are different. ## The essentials [Section titled “The essentials”](#the-essentials) * **JSON over HTTPS**, field names in `camelCase`. Request bodies are strict: an unknown field is an error, so typos never pass silently. * **Money** is an integer in minor units (øre), VAT included. `{"amountMinor": 14900, "currency": "NOK"}` is NOK 149.00. * **Times** are RFC 3339. Answers are in UTC; `timeZone` names the market’s zone for display. * **Writes are idempotent.** Send an `Idempotency-Key` and retry safely. See [Idempotency](/conventions/idempotency/). * **Forward compatible.** Ignore response fields and webhook types you do not know. See [Versioning](/conventions/versioning/). ## Next steps [Section titled “Next steps”](#next-steps) * Follow the [quickstart](/quickstart/) to place your first sandbox order. * Read the [order lifecycle](/guides/order-lifecycle/) before you build status handling. * Browse the [API reference](/api/) for every field.
# Quickstart
> Add a store, quote a delivery, place an order and watch it get delivered in the sandbox.
This guide takes you from an API key to a delivered sandbox order. It takes about ten minutes. You need `curl`, [`jq`](https://jqlang.org) and a Bash or Zsh shell (on Windows, use WSL).
```bash
export DELIVR_API=https://driver-staging-api.delivr.no
export DELIVR_API_KEY=dmk_your_sandbox_key
# Every write needs a new UUID as its Idempotency-Key.
uuid() { uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid; }
```
1. ### Check your key [Section titled “Check your key”](#check-your-key) [List your store locations](/api/operations/listlocations/). An empty list is fine; it means your key works. Request
```bash
curl -s $DELIVR_API/v1/merchant/locations \
-H "Authorization: Bearer $DELIVR_API_KEY"
```
Response · 200
```json
{ "items": [] }
```
A `401 unauthenticated` means the key is wrong or revoked. See [Authentication](/authentication/). 2. ### Add a store [Section titled “Add a store”](#add-a-store) [Register the store](/api/operations/createlocation/) your goods are picked up from. Delivr finds it on the map from its address. Request
```bash
curl -s $DELIVR_API/v1/merchant/locations \
-H "Authorization: Bearer $DELIVR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuid)" \
-d '{
"externalStoreId": "oslo-sentrum",
"name": "Oslo Sentrum",
"street": "Akersgata 45",
"postalCode": "0158",
"city": "Oslo",
"contactName": "Store staff",
"contactPhone": "+4722000000",
"prepMinutes": 10
}' | jq .
```
Response · 201
```json
{
"id": "9b2c4f60-1d7e-4c3a-8f15-6a0e2d9b7c41",
"externalStoreId": "oslo-sentrum",
"name": "Oslo Sentrum",
"street": "Akersgata 45",
"postalCode": "0158",
"city": "Oslo",
"country": "NO",
"latitude": 59.914,
"longitude": 10.7423,
"contactName": "Store staff",
"contactPhone": "+4722000000",
"prepMinutes": 10,
"readyDeadlineMinutes": 60,
"readySignal": "assumed",
"pickupInstructions": ""
}
```
From now on you can name this store by your own `externalStoreId`. Running this step again answers `409 conflict`: you already have a store with that `externalStoreId`, so carry on. Robo drivers park at a new store **within a minute**. Wait a minute before the next step. 3. ### Quote a delivery [Section titled “Quote a delivery”](#quote-a-delivery) [Ask what Delivr can offer](/api/operations/createquote/) from the store to an address, for one 4.5 kg parcel. The answer is saved to `quote.json` and its id to `$QUOTE`. Request
```bash
curl -s $DELIVR_API/v1/merchant/quotes \
-H "Authorization: Bearer $DELIVR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"pickupExternalStoreId": "oslo-sentrum",
"delivery": {
"address": { "street": "Karl Johans gate 22", "postalCode": "0026", "city": "Oslo" }
},
"parcels": [{ "count": 1, "weightKg": 4.5, "lengthCm": 40, "widthCm": 30, "heightCm": 20 }]
}' | tee quote.json | jq .
export QUOTE=$(jq -r .quoteId quote.json)
```
Response · 200
```json
{
"quoteId": "3f0e9a52-8a7e-4f8e-9a51-2c0f3c9d1b7a",
"expiresAt": "2026-11-03T13:05:00Z",
"timeZone": "Europe/Oslo",
"options": [
{
"type": "on_demand",
"key": "on_demand",
"eta": { "from": "2026-11-03T13:25:00Z", "to": "2026-11-03T13:55:00Z" },
"price": { "amountMinor": 14900, "vatMinor": 2980, "exVatMinor": 11920, "currency": "NOK" },
"supply": {
"nearbyDrivers": 2,
"pickupEta": { "from": "2026-11-03T13:02:00Z", "to": "2026-11-03T13:06:00Z" }
}
},
{
"type": "window",
"key": "window:2026-11-03T17:00:00+01:00/2026-11-03T19:00:00+01:00",
"window": { "start": "2026-11-03T16:00:00Z", "end": "2026-11-03T18:00:00Z" },
"cutoffAt": "2026-11-03T14:00:00Z",
"price": { "amountMinor": 9900, "vatMinor": 1980, "exVatMinor": 7920, "currency": "NOK" }
}
]
}
```
Show these options to the shopper (see [Showing options at checkout](/guides/checkout-options/)). Prices are what Delivr bills you, VAT included; what you charge the shopper is up to you. 4. ### Place the order [Section titled “Place the order”](#place-the-order) When the shopper pays, [book the option](/api/operations/createorder/) they chose. Use your own order id as `externalRef` (here made unique with the time, so you can run the step again), and give the recipient’s name and phone so Delivr can text them a tracking link. The order’s id is saved to `$ORDER`. Request
```bash
curl -s $DELIVR_API/v1/merchant/orders \
-H "Authorization: Bearer $DELIVR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuid)" \
-d @- < API keys, scopes, and how to keep them safe.
Every request carries an API key as a bearer token:
```http
Authorization: Bearer dmk_3J9xT2…
```
Delivr issues your keys; see [Get sandbox access](/sandbox/#get-sandbox-access). Each key is shown once, when it is created, so store it in a secret manager straight away. Sandbox and production keys are separate, and a key only ever sees its own merchant’s stores and orders. ## Scopes [Section titled “Scopes”](#scopes) A key has one of two scopes. Give each system the narrowest one it needs. | Scope | Allows | Typical use | | -------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------- | | `quotes` | `GET /availability`, `GET /locations`, `GET /locations/{id}`, `POST /quotes` | The backend of a checkout widget that only prices deliveries. | | `orders` | Everything: quotes, locations, orders, cancellations and webhooks | Your order system or checkout backend that books deliveries. | A call outside the key’s scope answers `403 insufficient_scope`. ## Keep keys server-side [Section titled “Keep keys server-side”](#keep-keys-server-side) Keys are for servers only. Never put one in a browser, a mobile app or a public repository: anyone holding it can book deliveries on your account. If your storefront needs prices, call Delivr from your own backend and pass the result on. ## Rotating and revoking [Section titled “Rotating and revoking”](#rotating-and-revoking) Ask Delivr for a new key, deploy it, then ask for the old one to be revoked. Both work in the meantime, so you can rotate without downtime. A revoked key answers `401 unauthenticated` straight away. ## Errors [Section titled “Errors”](#errors) | Status | Code | Meaning | | ------ | -------------------- | ------------------------------------------------------------------------------------------------- | | 401 | `unauthenticated` | The key is missing, unknown or revoked. | | 403 | `insufficient_scope` | The key’s scope does not cover this call. | | 403 | `merchant_suspended` | Your merchant account is suspended. Contact Delivr. | | 429 | `rate_limited` | Too many requests, or 30 failed sign-ins in 10 minutes from your address. Wait for `Retry-After`. |
# Sandbox and testing
> Test your integration end to end with robo drivers, no real SMS and directives for every failure path.
The sandbox is a separate Delivr environment to build and test against. It behaves like production, with three differences: * **No real SMS.** Recipient messages are recorded, not sent. Use any valid Norwegian phone number, such as `+4791234567`, and open the order’s `trackingUrl` to see what the recipient sees. * **Robo drivers.** Test drivers deliver your on-demand orders in about two minutes. * **Test data only.** Never send real customers’ names, phones or addresses. | | | | -------------- | ----------------------------------------------------------------------------------------------------- | | Base URL | `https://driver-staging-api.delivr.no` | | Tracking pages | `https://tracking-stage.delivr.no` | | Keys | Sandbox keys (`dmk_…`), see [Get sandbox access](#get-sandbox-access). They never work in production. | ## Get sandbox access [Section titled “Get sandbox access”](#get-sandbox-access) Ask your Delivr contact for a sandbox merchant. Send: * your company name and organization number, and the market (Norway), * who should receive the key, * the scope you need: `orders` to book deliveries, or `quotes` for a backend that only prices them (see [Scopes](/authentication/#scopes)), * your webhook URL, if you want webhooks (public HTTPS), * the [fallback policy](/guides/fallbacks/) you want to test, if not the default `recipient_choice`. Delivr creates a sandbox merchant and sends you the API key and, with a webhook URL, its signing secret (`whsec_…`). Each is shown once, so store them in a secret manager. ### Pick a webhook URL that stays the same [Section titled “Pick a webhook URL that stays the same”](#pick-a-webhook-url-that-stays-the-same) Only Delivr can change your webhook URL, so use one that does not change: * a tunnel with a fixed name, such as an ngrok static domain or a Cloudflare named tunnel, to reach your laptop, * or a request inspector such as webhook.site, to look at events before you have a handler. A free ngrok URL changes every time you restart it. Until your endpoint is set up, [test your handler locally](/webhooks/testing/). ## Getting set up [Section titled “Getting set up”](#getting-set-up) 1. Add your stores with [`POST /v1/merchant/locations`](/guides/locations/). Robo drivers park at a new store within a minute. 2. Check your webhook endpoint with [`GET /v1/merchant/webhook`](/api/operations/getwebhook/) (`409 conflict` means none is set up yet). 3. Send a test event with [`POST /v1/merchant/webhooks/test`](/api/operations/sendwebhooktest/), then [`GET /v1/merchant/webhooks/deliveries`](/api/operations/listwebhookdeliveries/) to see what your server answered. ## Robo drivers [Section titled “Robo drivers”](#robo-drivers) Robo drivers park outside sandbox stores, online. Because they are nearby, quotes for your store offer `on_demand`, and when an order’s goods are released one of them accepts it. A robo driver behaves like a real driver using the Delivr driver app: | Step | Takes | Status and webhook | | -------------------------------------- | ------------------------------------- | ----------------------------------------------------- | | Accepts the offer | A few seconds after the search starts | `driver_assigned`, `order.driver_assigned` | | Drives to the store | About 45 seconds | | | Arrives and loads, with a pickup photo | 5 to 20 seconds | `picked_up`, `order.picked_up` | | Drives to the recipient | About 45 seconds | | | Delivers, with a drop-off photo | | `delivered`, `order.delivered` with `proofOfDelivery` | The recipient’s tracking page shows the robo driver moving on the map, just as a shopper would see a real one. ### Where robo drivers park [Section titled “Where robo drivers park”](#where-robo-drivers-park) The sandbox is shared by every integrator, and so are its robo drivers: * Stores are grouped into cells of about 100 m. Stores of different merchants at the same address share a cell. * Two robo drivers park in each of the **30 newest cells**, counted across all integrators. They return there after each delivery. * Robo drivers only take Merchant API orders. So two things can happen: * **Both drivers of your cell are busy**, for example because another integrator uses the same address. A quote then has no `on_demand` option until one is back. Wait a few minutes, or use a store at another address below. * **Your store has dropped out of the 30 newest cells** because others added stores since. Its quotes stop offering `on_demand`. Add a new location (a new `externalStoreId`) at another address, and robo drivers park there within a minute. Locations cannot be removed, so give test stores clear names. ### Test addresses [Section titled “Test addresses”](#test-addresses) These Oslo addresses are in the national address register, so they work without coordinates. Each store address is in its own cell. | Use as a store | Use as a delivery address | | ----------------------------- | ------------------------------------ | | Akersgata 45, 0158 Oslo | Karl Johans gate 22, 0026 Oslo | | Storgata 1, 0155 Oslo | Møllergata 20, 0179 Oslo | | Pilestredet 48, 0167 Oslo | Dronning Eufemias gate 16, 0191 Oslo | | Hegdehaugsveien 31, 0352 Oslo | | | Bogstadveien 50, 0366 Oslo | | | Grønlandsleiret 25, 0190 Oslo | | An address that is not in the register answers `422 invalid_address`. Check the postal code: Karl Johans gate 22 is `0026`, not `0159`. ## Testing failure paths with directives [Section titled “Testing failure paths with directives”](#testing-failure-paths-with-directives) Real drivers read `pickup.note`. Robo drivers do too, and they follow **directives** written there. Put one anywhere in the note of the order you place:
```json
{
"quoteId": "…",
"option": "on_demand",
"externalRef": "test-no-driver-1",
"pickup": { "note": "sandbox:no_driver" },
"delivery": { "…": "…" }
}
```
| Directive | What the robo driver does | What you can test | | ------------------------ | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | *(none)* | Accepts and delivers. | The happy path and every status webhook. | | `sandbox:no_driver` | Every robo driver declines, and the search ends within seconds instead of after 15 minutes. | `failed` with reason `no_driver`, and your [fallback policy](/guides/fallbacks/). | | `sandbox:hand_back` | The first driver accepts, sets off and gives the job back. Another driver then delivers it. | A driver handing a job back. For [assisted checkout](/guides/assisted-checkout/), `sale.driver` shows `reassigning`. | | `sandbox:stop_en_route` | Accepts and stays on the way to the store. | Cancelling at the `driver_en_route` fee. | | `sandbox:stop_at_pickup` | Accepts, arrives at the store and waits there. | Cancelling at the `driver_arrived` fee, and goods that are not ready when the driver arrives. | Start the search A robo driver reads the directive when it gets the offer, that is when the driver search starts. With `readySignal: assumed` that is `prepMinutes` after you place the order. To test straight away, send [`POST /v1/merchant/orders/{id} /ready`](/api/operations/markready/) after placing, or use a store with `prepMinutes: 0`. Directives are not case-sensitive and can sit next to normal text, like `"Ask for Kari. sandbox:stop_at_pickup"`. A store’s `pickupInstructions` work too, which is handy for a store you only use to test one path. In production, directives are ordinary text that drivers ignore. A held driver (`stop_*`) gives the job back after an hour if you do nothing. The order then searches again, and the next robo driver reads the same directive and holds it too. Cancel the order to end the test. ## Timings [Section titled “Timings”](#timings) These are the defaults. A market’s price list or your agreement can set others. | What | Default | | --------------------------------------- | ------------------------------------------------ | | Quote offered (`expiresAt`) | 5 minutes | | Quote grace when you place after expiry | 10 minutes | | On-demand driver search | 15 minutes, then `failed` with `no_driver` | | Ready grace after `readyBy` | 15 minutes, then `failed` with `goods_not_ready` | | Checkout budget (assisted checkout) | 10 minutes from the driver’s accept | | Recipient’s window choice (fallback) | 30 minutes (`choiceDeadline`) | ## More things to test [Section titled “More things to test”](#more-things-to-test) | Scenario | How | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Goods not ready | Set your store’s `readySignal` to `required`, place an order with a `readyBy` 5 minutes ahead, and send no `ready` or `processing`. You get `order.ready_overdue` at `readyBy`, then the order fails with `goods_not_ready` after the ready grace (15 minutes by default). | | Packing time | Send `POST /orders/{id}/processing {readyAt}` a few minutes ahead. The driver search starts so that the driver arrives at about `readyAt`. | | Price changes | Place an order on a quote more than 15 minutes old (validity plus grace). Delivr quotes again: you get the order at no more than the quoted price, or `409 price_changed`. | | Free cancellation | Place an order and cancel it before you send `ready`, while it is still `awaiting_goods`, with `maxFeeMinor: 0`. | | Cancellation fees | Place with `sandbox:stop_en_route` or `sandbox:stop_at_pickup`, send `ready`, wait for `driver_assigned`, then check `GET /orders/{id}/cancellation` and cancel. | | Fallback as recipient | Place with `sandbox:no_driver` and send `ready`. Under `recipient_choice`, open the failed order’s `trackingUrl` and choose a window, as the recipient would. | | Webhook retries | Answer `500` from your endpoint, then look at `GET /v1/merchant/webhooks/deliveries` for the retry schedule. Replay an event with `POST /v1/merchant/webhooks/events/{id}/replay`. | | Assisted checkout | Place with `sale: "pending"`, poll until `sale.driver` is `assigned`, then complete with `POST /orders/{id}/sale`. | ## Window orders [Section titled “Window orders”](#window-orders) Window orders can be quoted, placed, rescheduled and cancelled in the sandbox. Robo drivers do not run window routes, so a window order usually stays `scheduled`. If you need to see `picked_up` and `delivered` on a window order, ask your Delivr contact to run one for you. ## Limits and data [Section titled “Limits and data”](#limits-and-data) * The same [rate limits](/conventions/rate-limits/) apply as in production. * The sandbox is shared by every integrator, and Delivr may reset its data. Do not depend on old orders being there. * Prices come from a sandbox agreement and are not your production prices.
# Store locations
> Register the stores and warehouses your goods are picked up from.
A location is a store or warehouse a driver picks your goods up from. Every quote and order names one. ## Add a store [Section titled “Add a store”](#add-a-store) [`POST /v1/merchant/locations`](/api/operations/createlocation/): Request
```bash
curl https://driver-staging-api.delivr.no/v1/merchant/locations \
-H "Authorization: Bearer $DELIVR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"externalStoreId": "oslo-sentrum",
"name": "Oslo Sentrum",
"street": "Akersgata 45",
"postalCode": "0158",
"city": "Oslo",
"contactName": "Store staff",
"contactPhone": "+4722000000",
"prepMinutes": 10,
"readyDeadlineMinutes": 60,
"readySignal": "assumed",
"pickupInstructions": "Staff entrance in the back yard. Ring the bell marked Delivr."
}'
```
`name`, `street`, `postalCode`, `city`, `contactName` and `contactPhone` are required. The answer is `201` with the location and its `id`. Another store of yours with the same `externalStoreId` answers `409 conflict`. | Field | Default | Meaning | | ----------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------- | | `externalStoreId` | none | Your own id for the store, unique among your stores. Use it instead of `id` in quotes and orders. | | `contactName`, `contactPhone` | required | Who the driver asks for at the store. The phone is stored as E.164. | | `prepMinutes` | `0` | How long packing takes, used when `readySignal` is `assumed`. | | `readyDeadlineMinutes` | `60` | When the goods are due, counted from the order, if the order has no `readyBy`. At least `prepMinutes`. | | `readySignal` | `assumed` | Whether Delivr assumes the goods are ready, or waits for you. See [Goods ready signals](/guides/ready-signals/). | | `defaultParcel` | none | A size (`lengthCm`, `widthCm`, `heightCm`) for parcels you declare by weight only. | | `pickupInstructions` | `""` | Standing instructions for drivers at this store, such as the entrance or counter. | | `latitude`, `longitude` | from the address | Send both or neither. | ### Where the store is [Section titled “Where the store is”](#where-the-store-is) Without coordinates, Delivr places the store from its address in the national address register. If the address is not found, you get `422 invalid_address`; send `latitude` and `longitude` then. ### Who sees the instructions [Section titled “Who sees the instructions”](#who-sees-the-instructions) `pickupInstructions` reach the driver only. You can read them back, but they never appear on the recipient’s tracking page, in SMS or in webhooks. ## Change a store [Section titled “Change a store”](#change-a-store) [`PATCH /v1/merchant/locations/{id}`](/api/operations/updatelocation/) changes only the fields you send. * `""` clears `externalStoreId` or `pickupInstructions`, and `"defaultParcel": null` drops the default size. * A new street, postal code or city places the store again from its address, unless you send coordinates too. * Orders already placed keep their pickup. New quotes and orders use the change. ## Read your stores [Section titled “Read your stores”](#read-your-stores) [`GET /v1/merchant/locations`](/api/operations/listlocations/) lists your stores, and [`GET /v1/merchant/locations/{id}`](/api/operations/getlocation/) reads one. Reading needs scope `quotes`; adding and changing need scope `orders` and an `Idempotency-Key`. Locations cannot be removed yet. Change one to reuse it.
# Quotes and orders
> Price the delivery options for an address, then book the one your shopper picks.
Booking a delivery takes two calls: a quote prices the options, and an order books one of them. ## 1. Show Delivr in the cart (optional) [Section titled “1. Show Delivr in the cart (optional)”](#1-show-delivr-in-the-cart-optional) Before the shopper has entered their whole address, ask [`GET /v1/merchant/availability`](/api/operations/getavailability/) for a cheap hint by postal code: Request
```bash
curl "https://driver-staging-api.delivr.no/v1/merchant/availability?pickupExternalStoreId=oslo-sentrum&postalCode=0026" \
-H "Authorization: Bearer $DELIVR_API_KEY"
```
Response · 200
```json
{
"postalCode": "0026",
"onDemand": { "available": true, "eta": { "fromMinutes": 35, "toMinutes": 60 } },
"windows": { "available": true },
"priceFrom": { "amountMinor": 9900, "vatMinor": 1980, "exVatMinor": 7920, "currency": "NOK" },
"timeZone": "Europe/Oslo",
"checkedAt": "2026-11-03T13:00:00Z"
}
```
Use it for messages like “Delivered today from NOK 99”. It promises nothing and is safe to cache for 60 seconds. ## 2. Quote [Section titled “2. Quote”](#2-quote) [`POST /v1/merchant/quotes`](/api/operations/createquote/) prices every option from a store to an address. | Field | Required | Notes | | --------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pickupLocationId` or `pickupExternalStoreId` | One of them | The store. | | `delivery.address` | Yes | `street`, `postalCode`, `city`; optional `line2` (apartment, such as `H0101`) and coordinates. | | `delivery.contact` | No | The recipient. Required later, on the order. | | `parcels` | Yes | Groups of identical parcels: `count`, `weightKg`, and all three dimensions or none (none uses the store’s `defaultParcel`; without one, `422 invalid_request`). | | `items` | No | Products bought (`title`, `quantity`, optional `imageUrl`). Shown to the recipient and driver, never priced. | | `readyBy` | No | When the goods are ready at the latest, within 24 hours. | The answer lists `options`, with `on_demand` first when there is driver capacity, then windows in time order: * **On demand** has an `eta` band: when the recipient can expect the delivery. * **Windows** have a `window` and a `cutoffAt`. Book and cancel for free before the cutoff. * Every option has a stable `key`, such as `on_demand` or `window:2026-11-03T17:00:00+01:00/2026-11-03T19:00:00+01:00`. The same delivery quoted again gives the same keys, so you can store a key with the shopper’s cart. * `price` is what Delivr bills you, VAT included. You decide what the shopper pays. `options` may be empty, for example far outside Delivr’s area. If nothing at all can be offered you get `422 no_delivery_options`. See [Showing options at checkout](/guides/checkout-options/) for how to present them. ## 3. Order [Section titled “3. Order”](#3-order) When the shopper pays, [`POST /v1/merchant/orders`](/api/operations/createorder/) with: | Field | Notes | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `quoteId` | The quote. | | `option` | The `key` of the option chosen. | | `externalRef` | Your order id, unique among your live orders. | | `delivery` | `address`, **`contact`** (the recipient’s name and phone, required) and optional `dropoff` (`instructions`, `doorCode`, `floor`, `entrance`, `leaveAtDoor`). | | `pickup.note` | Optional note for the driver at the store, for this order only. | | `parcels`, `items` | Optional. Replace the quote’s. | | `readyBy` | Optional, on demand. When the goods are due. | | `onWindowFull` | Optional, windows. `reject` (default) or `reschedule`. See below. | The answer is `201` with the order. Its `id` equals the `quoteId`, and `reference` (`DD-…`) is the short id for people. The placement answer also carries `eta` (on demand) or `rescheduled` (window); these two fields appear only there, so store them if you need them. ### Placing on an older quote [Section titled “Placing on an older quote”](#placing-on-an-older-quote) A quote is valid for **5 minutes** (`expiresAt`), plus the market’s quote grace when you place (10 minutes by default). After that, or when the address, the parcels or the store changed since the quote, Delivr quotes again behind the scenes and books the same key at **no more than the quoted price**. | Answer | What happened | What to do | | ------------------------ | --------------------------------------------------------------------------------------------------------- | ----------------------------------- | | `201` | Booked, at or below the quoted price. | Nothing. | | `409 price_changed` | It would cost more now. `details.option` has the new price. | Show the new price, or quote again. | | `409 option_unavailable` | The option is gone, for example no driver capacity. `details.alternatives` lists what is offered instead. | Offer an alternative. | | `409 window_full` | The window filled up. `details.alternatives` has the nearest free window. | Offer it, or set `onWindowFull`. | With `"onWindowFull": "reschedule"`, a full window books the nearest free one instead (no dearer than quoted). The answer has `rescheduled: true`, and you get an `order.window_changed` webhook. ### Booking without a quote [Section titled “Booking without a quote”](#booking-without-a-quote) If your shopper does not pick between options, you can skip the quote: send the store (`pickupLocationId` or `pickupExternalStoreId`), `parcels` and the option key, with no `quoteId`. The order is quoted and booked in one call. ### Retries [Section titled “Retries”](#retries) Placing is idempotent twice over: by the `Idempotency-Key`, and by `quoteId` (or `externalRef` without a quote). A retry answers the order already placed instead of booking a second delivery. * A repeat on the same quote answers the order already placed, **even if it names another option**. To book another option, quote again. * The same quote placed under another `externalRef` answers `409 quote_already_used`. ## Who sees what [Section titled “Who sees what”](#who-sees-what) | Data | Driver | Recipient (SMS, tracking) | Webhooks | | ----------------------------------------- | ------ | ------------------------- | -------- | | `items` | Yes | Yes | No | | `delivery.dropoff.doorCode` | Yes | No | No | | `pickup.note`, store `pickupInstructions` | Yes | No | No |
# Showing options at checkout
> When to quote, how to label on-demand and window options for shoppers, and what to keep for the order.
A quote lists what Delivr can offer for one address. This page is about turning it into choices a shopper understands. ## When to call [Section titled “When to call”](#when-to-call) | Moment in the checkout | Call | | -------------------------------------------- | --------------------------------------------------------------------------------------------------------- | | Cart or product page, postal code only | [`GET /availability`](/api/operations/getavailability/), cached for 60 seconds per store and postal code. | | The shopper has entered the full address | [`POST /quotes`](/api/operations/createquote/), once. | | The address, the parcels or the store change | `POST /quotes` again. | | The shopper pays | [`POST /orders`](/api/operations/createorder/) with the `quoteId` and the chosen option’s `key`. | Quote when the address is complete, not on every keystroke. Quotes are made from your server; your storefront gets the options from your backend, never the API key. ## What to show [Section titled “What to show”](#what-to-show) Keep the options in the order Delivr gives them: `on_demand` first when there is capacity, then windows in time order. * **On demand.** Show the `eta` band as the arrival time, such as “Today 14:25–14:55”. Do not show `supply`: it is a hint for sellers in [assisted checkout](/guides/assisted-checkout/), not a promise to shoppers. * **Windows.** Show `window` as “Today 17:00–19:00” or “Thursday 5 November 17:00–19:00”, grouped by day if there are many. A window can be booked and cancelled free until its `cutoffAt`. * **Times.** Answers are in UTC. Show them in the quote’s `timeZone`, never the server’s or the browser’s. * **Price.** `price` is what Delivr bills you. What the shopper pays is yours to set: the same, a flat fee, or free over a basket size. * **Nothing to offer.** When `options` is empty or the quote answers `422 no_delivery_options`, hide Delivr for this address. `422 invalid_address` means the address is not found: ask the shopper to check it.
```js
// Labels such as "Today 14:25–14:55" in the market's time zone.
export function optionLabel(option, timeZone, now = new Date()) {
const time = new Intl.DateTimeFormat("en-GB", { timeZone, hour: "2-digit", minute: "2-digit" });
const date = new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" });
const start = new Date(option.type === "on_demand" ? option.eta.from : option.window.start);
const end = new Date(option.type === "on_demand" ? option.eta.to : option.window.end);
const days = (Date.parse(date.format(start)) - Date.parse(date.format(now))) / 86_400_000;
const day =
days === 0
? "Today"
: days === 1
? "Tomorrow"
: new Intl.DateTimeFormat("en-GB", { timeZone, weekday: "long", day: "numeric", month: "long" }).format(start);
return `${day} ${time.format(start)}–${time.format(end)}`;
}
const nok = new Intl.NumberFormat("nb-NO", { style: "currency", currency: "NOK" });
nok.format(option.price.amountMinor / 100); // "149,00 kr"
```
For the [quickstart’s quote](/quickstart/#quote-a-delivery), this gives “Today 14:25–14:55” for `on_demand` and “Today 17:00–19:00” for the window. ## What to keep for the order [Section titled “What to keep for the order”](#what-to-keep-for-the-order) Save the `quoteId` and the chosen option’s `key` with the cart. You do not need the quote to still be valid when the shopper pays: * A quote is offered for 5 minutes (`expiresAt`), plus a grace when you place. Later than that, Delivr quotes again and books the same `key` at **no more than the price you showed**. * If it would cost more now, you get `409 price_changed` with the new price. Show it, or quote again. * If the option is gone, you get `409 option_unavailable` (or `409 window_full`) with `details.alternatives`. Offer one of them, by its `key`. See [Placing on an older quote](/guides/quotes-and-orders/#placing-on-an-older-quote). ## After the order [Section titled “After the order”](#after-the-order) * Show the placement answer’s `eta` (on demand) as the promised arrival. It is only in that answer, so store it. * Put `trackingUrl` in your order confirmation and emails. The recipient also gets it by SMS from Delivr. * Ask for `delivery.dropoff` details at checkout (door code, floor, entrance, leave at the door) if your shoppers live in flats. They reach the driver only.
# Order lifecycle
> Every status, event and reason an order can have, and how to handle them.
An order’s `status` is one of a small, stable set. Each change sends a webhook and increases the order’s `version`. On demand 1. `placed` 2. `awaiting_goods` 3. `searching` 4. `driver_assigned` 5. `picked_up` 6. `delivered` Can end early: `cancelled``failed` Window 1. `placed` 2. `scheduled` 3. `picked_up` 4. `delivered` Can end early: `cancelled``failed` ## Statuses [Section titled “Statuses”](#statuses) | Status | Type | Meaning | Webhook | | ----------------- | --------- | ------------------------------------------------------------------------------------ | ----------------------- | | `placed` | both | Booked. Transport is being arranged. | `order.placed` | | `awaiting_goods` | on demand | Waiting for your goods. The driver search has not started. | `order.placed` | | `searching` | on demand | Goods released; Delivr is finding a driver. | `order.searching` | | `scheduled` | window | Booked on its window’s route. | `order.scheduled` | | `driver_assigned` | on demand | A driver accepted and is on the way to the store. | `order.driver_assigned` | | `picked_up` | both | The driver has the goods. | `order.picked_up` | | `delivered` | both | Handed over. `proofOfDelivery` has `deliveredAt` and, when recorded, `receiverName`. | `order.delivered` | | `cancelled` | both | Cancelled, with a `reason`. | `order.cancelled` | | `failed` | both | Could not be delivered, with a `reason`. | `order.failed` | `placed` and `awaiting_goods` both send `order.placed`: that is the moment to create the delivery in your system. An on-demand order usually shows `awaiting_goods` from the start, while it waits for the goods. You are [billed](/guides/billing/) for delivered orders and for cancellation fees, not for placing. ## Reasons [Section titled “Reasons”](#reasons) `cancelled` and `failed` orders carry a `reason`: | Reason | Ends as | Meaning | | ----------------------- | ----------- | ----------------------------------------------------------------------------------------------------------- | | `no_driver` | `failed` | No driver accepted the on-demand order in time. Your [fallback](/guides/fallbacks/) may follow. | | `goods_not_ready` | `failed` | The goods were never reported ready, and the ready deadline and its grace passed. Your fallback may follow. | | `no_capacity` | `failed` | The transport could not be booked, for example the window filled up. | | `undeliverable` | `failed` | The transport partner could not complete the delivery. | | `cancelled_by_merchant` | `cancelled` | You cancelled. | | `cancelled_by_delivr` | `cancelled` | Delivr’s operators cancelled. | | `other` | either | Anything else. | New reasons may be added. Treat an unknown one like `other`. ## Other events [Section titled “Other events”](#other-events) Some webhooks are not status changes: | Event | When | | -------------------------- | ------------------------------------------------------------------------------------------ | | `order.ready_at_changed` | You reported when the goods will be ready (`readyAt`). | | `order.ready_overdue` | The goods are due (`readyBy`) and not reported ready. | | `order.window_changed` | Booked on another window than quoted (`previousWindow`), after `onWindowFull: reschedule`. | | `order.awaiting_recipient` | A failed order waits for the recipient to choose a window, until `choiceDeadline`. | | `order.successor_created` | A failed order got a successor order (`successorId`). | | `order.fallback_cancelled` | No successor follows (`fallbackReason`: `expired` or `no_window`). | | `order.held` | An assisted checkout order is held for a pending sale. | See [Event types](/webhooks/events/) for the payloads. ## Reading orders [Section titled “Reading orders”](#reading-orders) * [`GET /v1/merchant/orders/{order}`](/api/operations/getorder/) takes Delivr’s `id` or the `DD-` reference. It is the source of truth. * [`GET /v1/merchant/orders`](/api/operations/listorders/) is a change feed. Page through `nextCursor` to see every change once, and filter by `updatedSince`, `status` or `externalRef`. A nightly reconciliation with `updatedSince` catches anything a webhook missed. * `version` increases with every change. Keep the highest you have seen, and ignore older data. ## Reconciling [Section titled “Reconciling”](#reconciling) Webhooks can be missed, for example while your endpoint was down for longer than the retries last. Run a job, nightly or hourly, that reads every order changed since its last run and applies it with the same `version` check as your webhook handler. `updatedSince` counts from when Delivr recorded a change, and `nextCursor` is absent on the last page, so keep the time each run started and overlap the next run a little:
```js
const startedAt = new Date();
const since = new Date(lastRunStartedAt.getTime() - 10 * 60_000); // overlap: applying twice is harmless
let cursor;
do {
const url = new URL("/v1/merchant/orders", process.env.DELIVR_API_URL);
url.searchParams.set("updatedSince", since.toISOString());
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.DELIVR_API_KEY}` } });
if (!res.ok) throw new Error(`Delivr answered ${res.status}`);
const page = await res.json();
for (const order of page.items) await applyIfNewer(order); // update only when order.version is higher
cursor = page.nextCursor;
} while (cursor);
await saveLastRunStartedAt(startedAt);
```
Held assisted checkout orders are not listed until their sale completes; read them by id. ## Times on an order [Section titled “Times on an order”](#times-on-an-order) | Field | Meaning | | ----------------------- | -------------------------------------------------------------------- | | `createdAt`, `placedAt` | When the order was created and placed. | | `readyBy` | On demand. When the goods are due. | | `readyAt` | On demand. When the goods are (or are assumed to be) ready. | | `window` | Window orders. The delivery window. | | `confirmedAt` | A driver accepted (on demand), or the transport was booked (window). | | `updatedAt` | The last change. |
# Goods ready signals
> Tell Delivr when the goods are packed, so the driver arrives when the parcel does.
An on-demand driver should arrive when your goods are packed: not before, wasting the driver’s time, and not after, wasting the shopper’s. Each store’s `readySignal` decides how Delivr knows. ## `assumed` (default) [Section titled “assumed (default)”](#assumed-default) The goods count as ready at the order’s `readyBy` or, without one, `prepMinutes` after the order. Delivr plans the driver search when the order is placed, so a driver arrives about then. You never have to call anything, which suits stores that always pack within a known time. You can still send the signals below to correct the plan, for example when packing is quicker or slower than usual. ## `required` [Section titled “required”](#required) On-demand orders wait in `awaiting_goods` until you signal. Choose this when packing time varies a lot, or when your warehouse system knows exactly when a parcel is packed. ## The signals [Section titled “The signals”](#the-signals)
```bash
# Packing has started; the goods will be ready at readyAt (within 24 hours).
curl -X POST https://driver-staging-api.delivr.no/v1/merchant/orders/$ORDER/processing \
-H "Authorization: Bearer $DELIVR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{ "readyAt": "2026-11-03T13:25:00Z" }'
# Ready now.
curl -X POST https://driver-staging-api.delivr.no/v1/merchant/orders/$ORDER/ready \
-H "Authorization: Bearer $DELIVR_API_KEY" \
-H "Idempotency-Key: $(uuidgen)"
```
| Signal | Effect | | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`processing {readyAt}`](/api/operations/markprocessing/) | The driver search is timed so a driver arrives at about `readyAt`. Repeat it as often as you like until the search starts. A `readyAt` in the past means ready now. The recipient’s tracking page shows the time. Sends `order.ready_at_changed`. | | [`ready`](/api/operations/markready/) | The search starts at once, if it has not already. | On a window order, both signals are only recorded, for tracking. ## Deadlines [Section titled “Deadlines”](#deadlines) The goods are due at `readyBy`: yours, or the store’s `readyDeadlineMinutes` after the order. 1. At `readyBy`, if the goods are not ready, you get `order.ready_overdue`. This mostly matters for `required` stores. 2. After the market’s ready grace (15 minutes by default) the order fails with `goods_not_ready`, and your [fallback policy](/guides/fallbacks/) applies. A `processing` with a `readyAt` past the deadline moves the deadline with it.
# Receiving webhooks
> Get a signed HTTPS call for every change to your orders.
Delivr posts an event to your HTTPS endpoint whenever one of your orders changes. Webhooks are how your order system stays in step without polling. ## Set up [Section titled “Set up”](#set-up) Delivr registers your endpoint URL and gives you its signing secret (`whsec_…`); see [Get sandbox access](/sandbox/#get-sandbox-access). Only Delivr can change the URL, so pick one that stays the same. Then you manage it through the API: | Call | Does | | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | | [`GET /v1/merchant/webhook`](/api/operations/getwebhook/) | Your endpoint, the subscribed events and the secret’s prefix. | | [`PUT /v1/merchant/webhook/events`](/api/operations/setwebhookevents/) | Choose the events you want. An empty list means all. | | [`POST /v1/merchant/webhooks/test`](/api/operations/sendwebhooktest/) | Send a `webhook.test` event. | | [`GET /v1/merchant/webhooks/deliveries`](/api/operations/listwebhookdeliveries/) | The delivery log: every attempt, its status code and timing, for 30 days. | | [`POST /v1/merchant/webhooks/events/{id}/replay`](/api/operations/replaywebhookevent/) | Send an event again, with the same id. | To try your handler before Delivr can reach it, see [Testing webhooks](/webhooks/testing/). ## The event [Section titled “The event”](#the-event)
```http
POST /hooks/delivr HTTP/1.1
Content-Type: application/json
User-Agent: Delivr-Webhooks/1
X-Delivr-Event: order.picked_up
X-Delivr-Event-Id: 6b0f4f1e-2b7a-4d4e-9a6b-1f3e5d2c7a90
X-Delivr-Signature: t=1793712600,v1=5f2b…
Delivr-Api-Version: 2026-11-01
```
```json
{
"id": "6b0f4f1e-2b7a-4d4e-9a6b-1f3e5d2c7a90",
"type": "order.picked_up",
"apiVersion": "2026-11-01",
"createdAt": "2026-11-03T13:30:00Z",
"data": {
"orderId": "3f0e9a52-8a7e-4f8e-9a51-2c0f3c9d1b7a",
"reference": "DD-482913",
"externalRef": "order-1001",
"type": "on_demand",
"status": "picked_up",
"version": 9
}
}
```
`data` is the order’s state when the event was queued. It carries the order id, `reference`, `externalRef`, `type`, `status`, `version` and, when they apply, `reason`, `readyAt`, `window`, `previousWindow`, `predecessorId`, `successorId`, `choiceDeadline`, `fallbackReason`, `proofOfDelivery` and `sale`. Door codes and driver notes are never included. ## Handling events correctly [Section titled “Handling events correctly”](#handling-events-correctly) 1. **Verify the signature** before you trust anything. See [Verifying signatures](/webhooks/signatures/). 2. **Answer `2xx` within 15 seconds.** Queue the work and answer first. Redirects are not followed. 3. **Deduplicate on `id`.** An event keeps its `id` across retries and replays. Process each id once. 4. **Expect any order.** Events for one order can arrive late or out of order, for example `order.driver_assigned` before `order.held`. 5. **Apply `data` only if it is newer.** Store each order’s `version`. Apply `data` as state only when its `version` is higher than yours, but still act on every new event id: some events share a version, such as `order.window_changed` with its `order.placed`. 6. **Create orders on `order.placed` only.** Never on `order.held` or a status event. A status event for an id you do not know, or with `sale.status` `pending` or `abandoned`, is an assisted checkout hold: ignore it or read the order. 7. **Ignore unknown event types, and still answer `2xx`.** New ones may be added. When in doubt, [`GET /v1/merchant/orders/{id}`](/api/operations/getorder/) is the source of truth. The endpoint verifies and queues:
```js
// Express, with the raw body kept for the signature.
app.post("/hooks/delivr", express.raw({ type: "application/json" }), async (req, res) => {
if (!verify(process.env.DELIVR_WEBHOOK_SECRET, req.get("X-Delivr-Signature"), req.body)) {
return res.sendStatus(400);
}
await queue.add("delivr-event", JSON.parse(req.body)); // do the work after answering
res.sendStatus(200);
});
```
The worker deduplicates and applies only newer state, in one transaction (PostgreSQL here):
```js
async function handleDelivrEvent(event) {
if (event.type === "webhook.test") return;
await db.tx(async (tx) => {
// One order's events one at a time, so a status event cannot slip in
// between reading the order and storing it below.
await tx.query("SELECT pg_advisory_xact_lock(hashtext($1))", [event.data.orderId]);
// Each event id once: retries and replays reuse it.
const fresh = await tx.query("INSERT INTO delivr_events (id) VALUES ($1) ON CONFLICT DO NOTHING", [event.id]);
if (fresh.rowCount === 0) return;
// order.placed creates the order from its current state, so status
// events that arrived before it (out of order) are not lost.
const d = event.type === "order.placed" ? await getDelivrOrder(event.data.orderId) : event.data;
if (event.type === "order.placed") {
await tx.query(
`INSERT INTO deliveries (order_id, reference, external_ref, status, reason, version)
VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (order_id) DO NOTHING`,
[event.data.orderId, d.reference, d.externalRef, d.status, d.reason ?? null, d.version],
);
}
// State only when newer; an order not placed yet (a held sale) matches no row.
await tx.query(
"UPDATE deliveries SET status = $2, reason = $3, version = $4 WHERE order_id = $1 AND version < $4",
[event.data.orderId, d.status, d.reason ?? null, d.version],
);
// Then act on the event itself, newer or not, e.g. alert the store on order.ready_overdue.
});
}
// GET /v1/merchant/orders/{id}: the order as it is now.
async function getDelivrOrder(id) {
const res = await fetch(`${process.env.DELIVR_API_URL}/v1/merchant/orders/${id}`, {
headers: { Authorization: `Bearer ${process.env.DELIVR_API_KEY}` },
});
if (!res.ok) throw new Error(`Delivr GET order ${id}: ${res.status}`); // the queue retries the job
return res.json();
}
```
## Retries [Section titled “Retries”](#retries) Any answer other than `2xx`, no answer within 15 seconds, or a redirect is a failure. Delivr retries after 30 s, 2 m, 10 m, 30 m, 1 h, 3 h, 6 h, 6 h and 6 h: 10 attempts over about 23 hours. Then it gives up, and you can still replay the event for 30 days. Events for one order are sent one at a time. While one is failing and being retried, that order’s later events wait behind it. So answer `2xx` to every event you have verified, even one you cannot process (log it and look at it later), rather than letting it block the order for up to a day. Replays send the event as it was first queued, to your endpoint as it is now, whatever your subscription. One replay per event per minute.
# Verifying signatures
> Check that a webhook really comes from Delivr and has not been replayed.
Every webhook carries an `X-Delivr-Signature` header:
```plaintext
X-Delivr-Signature: t=1793712600,v1=5f2b9c…
```
* `t` is the attempt’s Unix time in seconds. * `v1` is the hex HMAC-SHA256 of `"."`, keyed with your signing secret. The key is the **whole secret string, `whsec_` included**, as UTF-8 bytes. Do not strip the prefix or base64-decode it. This is not the Standard Webhooks scheme, so libraries made for it (such as Svix’s) will not verify Delivr’s signatures. To verify: 1. Split the header on `,` and read `t` and every `v1`. 2. Reject the event if `t` is more than 5 minutes from now. This stops replays of captured requests. 3. Compute the HMAC over the **raw** body bytes, exactly as received. Re-serialized JSON will not match. 4. Accept if any `v1` matches, compared in constant time. During a secret rotation, Delivr adds a second `v1` signed with the old secret for 24 hours, so accepting any match lets you rotate without dropping events. * Node.js
```js
import crypto from "node:crypto";
export function verify(secret, header, rawBody, nowSec = Date.now() / 1000) {
const parts = (header ?? "").split(",").map((p) => p.split("="));
const t = parts.find(([k]) => k === "t")?.[1];
if (!t || Math.abs(nowSec - Number(t)) > 300) return false;
const want = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
return parts
.filter(([k]) => k === "v1")
.some(([, v]) => {
const got = Buffer.from(v, "hex");
return got.length === want.length && crypto.timingSafeEqual(got, want);
});
}
```
* Go
```go
func Verify(secret, header string, body []byte, now time.Time) bool {
var t string
var sigs []string
for _, part := range strings.Split(header, ",") {
k, v, _ := strings.Cut(part, "=")
if k == "t" {
t = v
} else if k == "v1" {
sigs = append(sigs, v)
}
}
sec, err := strconv.ParseInt(t, 10, 64)
if err != nil || now.Sub(time.Unix(sec, 0)).Abs() > 5*time.Minute {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(t + "."))
mac.Write(body)
want := []byte(hex.EncodeToString(mac.Sum(nil)))
for _, sig := range sigs {
if hmac.Equal([]byte(sig), want) {
return true
}
}
return false
}
```
* Python
```python
import hashlib, hmac, time
def verify(secret: str, header: str, raw_body: bytes, now: float | None = None) -> bool:
parts = [p.split("=", 1) for p in (header or "").split(",") if "=" in p]
t = next((v for k, v in parts if k == "t"), None)
now = time.time() if now is None else now
if t is None or not t.isdigit() or abs(now - int(t)) > 300:
return False
want = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(v, want) for k, v in parts if k == "v1")
```
* PHP
```php
function delivr_verify(string $secret, string $header, string $rawBody, ?int $now = null): bool {
$now ??= time();
$t = null; $sigs = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = array_pad(explode('=', $part, 2), 2, '');
if ($k === 't') { $t = $v; } elseif ($k === 'v1') { $sigs[] = $v; }
}
if ($t === null || !ctype_digit($t) || abs($now - (int) $t) > 300) {
return false;
}
$want = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
foreach ($sigs as $sig) {
if (hash_equals($want, $sig)) { return true; }
}
return false;
}
```
## Test vector [Section titled “Test vector”](#test-vector) Check your code against this before you wire up the endpoint. The body is exactly these bytes, with no trailing newline. | Input | Value | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | Secret | `whsec_docsTestVectorNotARealSecret00000000000` | | Header | `t=1793712600,v1=a7d3d6de1888ccb64601f9928769ed68ad4f7fc7beb5dc620c51fcecd879f8bc` | | Body | `{"id":"6b0f4f1e-2b7a-4d4e-9a6b-1f3e5d2c7a90","type":"webhook.test","apiVersion":"2026-11-01","createdAt":"2026-11-03T13:30:00Z","data":{"test":true}}` | | Now | `1793712600` (2026-11-03T13:30:00Z) | | Expected result | Valid. With `now` 301 seconds later, or any other secret, it is invalid. | The same check with OpenSSL:
```bash
printf '%s' '{"id":"6b0f4f1e-2b7a-4d4e-9a6b-1f3e5d2c7a90","type":"webhook.test","apiVersion":"2026-11-01","createdAt":"2026-11-03T13:30:00Z","data":{"test":true}}' > body.json
printf '1793712600.' | cat - body.json | openssl dgst -sha256 -hmac 'whsec_docsTestVectorNotARealSecret00000000000' -hex
# … a7d3d6de1888ccb64601f9928769ed68ad4f7fc7beb5dc620c51fcecd879f8bc
```
## Common mistakes [Section titled “Common mistakes”](#common-mistakes) * **Parsing before verifying.** Frameworks that parse JSON first lose the raw bytes. Keep the raw body for the route (for example `express.raw`). * **Comparing with `==`.** Use a constant-time comparison. * **Only checking the first `v1`.** During a rotation the matching signature may be the second. * **Trusting the timestamp in the body.** Use `t` from the header. * **Decoding the secret.** Use it as it is, `whsec_` and all.
# Event types
> Every webhook event and when it is sent.
Subscribe to the events you need with [`PUT /v1/merchant/webhook/events`](/api/operations/setwebhookevents/), or leave the list empty to get all of them. `webhook.test` is always delivered. Every event has the same envelope; see [Receiving webhooks](/webhooks/). ## Status events [Section titled “Status events”](#status-events) | Event | Sent when | Useful `data` | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------ | | `order.placed` | The order is placed (`placed`, or `awaiting_goods` on demand). For a held order, when the sale completes. A successor order sends its own. **Create the order in your system here.** | `predecessorId` on a successor | | `order.scheduled` | A window order is booked on its window. | `window` | | `order.searching` | On demand: goods released, driver search started. | | | `order.driver_assigned` | On demand: a driver accepted. Sent on the first accept only. | `sale` on a held order | | `order.picked_up` | The driver has the goods. | | | `order.delivered` | Delivered. | `proofOfDelivery` | | `order.cancelled` | Cancelled. | `reason` | | `order.failed` | Failed. Your fallback policy may follow. | `reason` | ## Fulfilment events [Section titled “Fulfilment events”](#fulfilment-events) | Event | Sent when | Useful `data` | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------- | | `order.ready_at_changed` | You stated when the goods will be ready. | `readyAt` | | `order.ready_overdue` | The goods are due (`readyBy`) and not reported ready. Send `processing` or `ready`, or the order fails `goods_not_ready` after the grace period. | | | `order.window_changed` | Booked on another window than quoted, after `onWindowFull: reschedule`. | `window`, `previousWindow` | ## Fallback events [Section titled “Fallback events”](#fallback-events) | Event | Sent when | Useful `data` | | -------------------------- | -------------------------------------- | ----------------------------------------- | | `order.awaiting_recipient` | The recipient may choose a window. | `choiceDeadline` | | `order.successor_created` | A failed order got a window successor. | `successorId` | | `order.fallback_cancelled` | No successor follows the failed order. | `fallbackReason` (`expired`, `no_window`) | ## Assisted checkout [Section titled “Assisted checkout”](#assisted-checkout) | Event | Sent when | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | | `order.held` | An order is held for a sale still pending at your checkout. It is **not** an order-creation trigger: `order.placed` follows `POST …/sale`. | ## Test [Section titled “Test”](#test) | Event | Sent when | | -------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `webhook.test` | You call [`POST /v1/merchant/webhooks/test`](/api/operations/sendwebhooktest/). `data.test` is `true` and there is no order. | The [API reference](/api/) has the full schema of every event.
# Testing webhooks
> Try your webhook handler locally with signed sample events, then with real sandbox events.
You can test your handler before Delivr can reach it: sign a sample event yourself, exactly as Delivr does, and post it to your local server. ## Send a signed event to localhost [Section titled “Send a signed event to localhost”](#send-a-signed-event-to-localhost) This needs `openssl`, `jq` and `curl`. Use any secret while you test locally, and the real `whsec_…` once Delivr gives you one.
```bash
SECRET=whsec_local_test_secret
URL=http://localhost:3000/hooks/delivr
ID=$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid)
T=$(date +%s)
BODY=$(jq -cn --arg id "$ID" --arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '{
id: $id, type: "order.picked_up", apiVersion: "2026-11-01", createdAt: $at,
data: { orderId: "3f0e9a52-8a7e-4f8e-9a51-2c0f3c9d1b7a", reference: "DD-482913", externalRef: "order-1001",
type: "on_demand", status: "picked_up", version: 9 }
}')
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
curl -i "$URL" \
-H "Content-Type: application/json" \
-H "User-Agent: Delivr-Webhooks/1" \
-H "X-Delivr-Event: order.picked_up" \
-H "X-Delivr-Event-Id: $ID" \
-H "X-Delivr-Signature: t=$T,v1=$SIG" \
-H "Delivr-Api-Version: 2026-11-01" \
--data-binary "$BODY"
```
Then check what your handler does when you: | Send | Your handler should | | -------------------------------------------------------- | -------------------------------------- | | The same request again (same `ID`) | Answer `2xx` and change nothing. | | A new `ID` with a lower `version` | Answer `2xx` and keep the newer state. | | A `type` it does not know, such as `order.something_new` | Answer `2xx` and ignore it. | | A changed body with the old `SIG` | Reject it. | | A `T` more than 5 minutes old | Reject it. | Check your signature code against the [test vector](/webhooks/signatures/#test-vector) too. ## Receive real sandbox events [Section titled “Receive real sandbox events”](#receive-real-sandbox-events) 1. Expose your local server with a URL that does not change, such as an ngrok static domain or a Cloudflare named tunnel, and give it to Delivr (see [Get sandbox access](/sandbox/#get-sandbox-access)). To only look at events, a request inspector such as webhook.site works too. 2. Send [`POST /v1/merchant/webhooks/test`](/api/operations/sendwebhooktest/). Your endpoint gets `webhook.test`, with `data.test` set to `true`. 3. Run the [quickstart](/quickstart/). A robo driver delivers the order in about two minutes, and every status arrives as an event. 4. Read the [delivery log](/api/operations/listwebhookdeliveries/) to see what your endpoint answered, and [replay](/api/operations/replaywebhookevent/) any event to run it through your handler again.
```bash
curl -s "$DELIVR_API/v1/merchant/webhooks/deliveries?limit=10" \
-H "Authorization: Bearer $DELIVR_API_KEY" | jq '.items[] | {event, attempt, statusCode, succeeded, error}'
curl -s -X POST "$DELIVR_API/v1/merchant/webhooks/events/$EVENT_ID/replay" \
-H "Authorization: Bearer $DELIVR_API_KEY" \
-H "Idempotency-Key: $(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid)"
```
To see retries, answer `500` for a while: the log shows each attempt on the [retry schedule](/webhooks/#retries).
# Requests and responses
> Formats, headers and conventions shared by every endpoint.
## Format [Section titled “Format”](#format) * HTTPS only. Every route lives under `/v1/merchant/`. * Request and response bodies are JSON (`Content-Type: application/json`), with `camelCase` field names. * **Bodies are strict.** An unknown field answers `422 invalid_request`, with `param` naming the field. This catches typos like `externalref` before they become silent bugs. * A request body is at most 64 KB. * Optional fields are omitted from answers when they do not apply. Treat a missing field and `null` the same. ## Money [Section titled “Money”](#money) Amounts are integers in **minor units**, VAT included:
```json
{ "amountMinor": 14900, "vatMinor": 2980, "exVatMinor": 11920, "currency": "NOK" }
```
is NOK 149.00, of which NOK 29.80 VAT. Never use floating point for money. ## Times [Section titled “Times”](#times) Times are RFC 3339. You may send any offset; answers are in UTC. `timeZone` names the market’s IANA zone (`Europe/Oslo`) for showing times to people. Window option keys use the market’s local offset, such as `window:2026-11-03T17:00:00+01:00/2026-11-03T19:00:00+01:00`. ## Identifiers [Section titled “Identifiers”](#identifiers) | Id | Example | Notes | | -------------------------------- | -------------- | -------------------------------------------------- | | Order `id` | `3f0e9a52-…` | A UUID. Equals the `quoteId` it was placed on. | | `reference` | `DD-482913` | Short and human. Paths accept it in place of `id`. | | `externalRef` | `order-1001` | Yours. Unique among your live orders. | | Location `id`, `externalStoreId` | `oslo-sentrum` | Either names a store in a quote or order. | ## Headers [Section titled “Headers”](#headers) | Header | Direction | Meaning | | ----------------------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `Authorization: Bearer dmk_…` | request | Your [API key](/authentication/). | | `Idempotency-Key` | request | A UUID that makes a write safe to retry. See [Idempotency](/conventions/idempotency/). | | `X-Request-Id` | both | The request’s id. Send your own (up to 64 characters of `A-Za-z0-9._-`) to correlate with your logs; otherwise Delivr makes one. Quote it to support. | | `Delivr-Api-Version` | response | The API version that answered. See [Versioning](/conventions/versioning/). | | `Idempotent-Replayed: true` | response | The answer is a replay of an earlier request. | | `RateLimit-Limit`, `-Remaining`, `-Reset` | response | Your [rate limit](/conventions/rate-limits/). | ## Pagination [Section titled “Pagination”](#pagination) List endpoints answer `{ "items": [...], "nextCursor": "…" }`. Pass `cursor=` for the next page, and stop when `nextCursor` is absent. Cursors are opaque; do not build or parse them.
# Idempotency
> Retry any write safely with an Idempotency-Key.
Networks fail. A request can time out after Delivr has booked the delivery, and a naive retry would book a second one. Idempotency keys make retries safe. Every write except `POST /v1/merchant/quotes` needs one `Idempotency-Key` header with a UUID. Without one, with two, or with one that is not a UUID, you get `400 idempotency_key_required`.
```bash
curl -X POST https://driver-staging-api.delivr.no/v1/merchant/orders \
-H "Authorization: Bearer $DELIVR_API_KEY" \
-H "Idempotency-Key: 0b9f0c4e-6f1d-4a5e-9a3c-2d7e8f1a6b42" \
-H "Content-Type: application/json" \
-d @order.json
```
## Rules [Section titled “Rules”](#rules) Make **one key per logical action**, store it with the action, and send the same key on every retry of it. A new key per HTTP attempt defeats the point. | You send | You get | | ------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | | The same key and the same request (method, path and JSON body; key order and spacing do not matter) within 24 hours | The first successful answer again, with `Idempotent-Replayed: true`. | | The same key with a different request | `422 idempotency_key_reused`. | | The same key while the first request is still running | `409 idempotency_key_in_flight`. Retry shortly with the same key. | | A retry after the first attempt was refused or failed | A fresh attempt: refused and failed requests free the key. | ## Built-in idempotency for orders [Section titled “Built-in idempotency for orders”](#built-in-idempotency-for-orders) On top of the key, placing an order is idempotent by `quoteId`, or by `externalRef` when there is no quote. Even with a new key, a second placement for the same quote answers the order already placed. ## Retry strategy [Section titled “Retry strategy”](#retry-strategy) * Retry network errors and timeouts, `409 idempotency_key_in_flight`, `429`, `500` and `503` with the **same key**, with exponential backoff and jitter. * Respect `Retry-After` on `429`. * Do not retry other `4xx` answers unchanged: fix the request first. ## Example [Section titled “Example”](#example) A Node.js helper (Node 18 or later) that does all of the above. Create the key once, store it with your own record before the first attempt, and pass the same key whenever you retry the action, even after a restart.
```js
const RETRY_CODES = new Set(["idempotency_key_in_flight", "rate_limited", "internal", "unavailable"]);
export async function delivrWrite(method, path, body, idempotencyKey, attempts = 5) {
for (let attempt = 1; ; attempt++) {
let res, data;
try {
res = await fetch(process.env.DELIVR_API_URL + path, {
method,
headers: {
Authorization: `Bearer ${process.env.DELIVR_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
});
data = await res.json();
} catch (err) {
if (attempt >= attempts) throw err; // network error, timeout or cut-off body: the same key is safe to retry
await sleep(backoff(attempt));
continue;
}
if (!RETRY_CODES.has(data.error?.code) || attempt >= attempts) return { status: res.status, data };
const retryAfter = Number(res.headers.get("Retry-After"));
await sleep(retryAfter > 0 ? retryAfter * 1000 : backoff(attempt));
}
}
const backoff = (attempt) => Math.min(30_000, 500 * 2 ** attempt) * (0.5 + Math.random() / 2);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
```
```js
// One key per order placement, saved before the first attempt.
order.delivrPlaceKey ??= crypto.randomUUID();
await db.orders.save(order);
const { status, data } = await delivrWrite("POST", "/v1/merchant/orders", placeRequest, order.delivrPlaceKey);
```
# Errors
> Error format, codes, and what to do about each.
Errors use HTTP status codes and a JSON body:
```json
{
"error": {
"code": "invalid_request",
"message": "delivery.contact needs name and phone",
"param": "delivery.contact",
"requestId": "…"
}
}
```
* **Branch on `code`**, never on `message`. Messages are for people and may change. * `param` names the field at fault, when there is one (for an unknown field, its name). * `details` is present for `price_changed`, `option_unavailable`, `window_full` and `fee_changed`. * `requestId` is the `X-Request-Id`. Quote it to support. ## Codes [Section titled “Codes”](#codes) | Status | Code | Meaning | What to do | | ------ | --------------------------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | | 400 | `malformed_json` | The body is not valid JSON. | Fix the request. | | 400 | `idempotency_key_required` | A write without one `Idempotency-Key` that is a UUID (missing, two, or not a UUID). | Send one UUID key. | | 401 | `unauthenticated` | Missing, unknown or revoked key. | Check the key. | | 403 | `insufficient_scope` | The key’s scope does not allow this. | Use an `orders` key. | | 403 | `merchant_suspended` | Your account is suspended. | Contact Delivr. | | 404 | `not_found` | No such resource (or another merchant’s). | Check the id. | | 404 | `quote_not_found` | No such quote. | Quote again. | | 405 | `method_not_allowed` | Wrong HTTP method. | Fix the request. | | 409 | `conflict` | The resource is not in a state that allows this, or another store already has this `externalStoreId`. | Read it, then decide. | | 409 | `cancellation_not_allowed` | The order can no longer be cancelled, such as a window order after its cutoff. | Contact Delivr support if you still need it stopped. | | 409 | `idempotency_key_in_flight` | The same key is still running. | Retry shortly, same key. | | 409 | `price_changed` | Dearer than quoted. `details.option` has the new price. | Show it, or quote again. | | 409 | `option_unavailable` | The option is gone. `details.alternatives` lists others. | Offer an alternative. | | 409 | `window_full` | The window filled up. `details.alternatives` has the nearest. | Offer it, or use `onWindowFull: reschedule`. | | 409 | `fee_changed` | The cancellation fee is above `maxFeeMinor`. `details.fee` has it. | Decide, then cancel again. | | 409 | `quote_already_used` | The quote was placed under another `externalRef`, or was cancelled. | Quote again. | | 409 | `external_ref_taken` | Another live order has this `externalRef`. | Use a unique ref. | | 409 | `order_ended` | The order is cancelled or failed. | Read the order. | | 422 | `invalid_request` | A field is missing, invalid or unknown (`param`), or the body is over 64 KB. | Fix the request. | | 422 | `idempotency_key_reused` | The key was used for a different request. | Use a new key. | | 422 | `no_delivery_options` | Nothing can be offered for this delivery. | Do not offer Delivr for it. | | 422 | `manual_quote_required` | The delivery needs a manual quote (for example very large goods). | Contact Delivr. | | 422 | `invalid_address` | The address was not found. | Check it, or send coordinates. | | 429 | `rate_limited` | Too many requests. | Wait `Retry-After` seconds. | | 500 | `internal` | Something went wrong at Delivr. | Retry with the same key; contact support if it persists. | | 503 | `unavailable` | Temporarily unavailable. | Retry with the same key. | New codes may be added. Handle unknown codes by their HTTP status.
# Rate limits
> How many requests you can make, and how to stay within the limit.
Each merchant can make **600 requests per minute**, across all its keys. Every answer tells you where you stand: | Header | Meaning | | --------------------- | ------------------------------------ | | `RateLimit-Limit` | Requests per minute (600). | | `RateLimit-Remaining` | Requests left in the current window. | | `RateLimit-Reset` | Seconds until the window resets. | Over the limit you get `429 rate_limited` with `Retry-After` in seconds. Wait that long, then retry with the same `Idempotency-Key`. Rate-limit headers are only sent once your key is accepted. Failed sign-ins (missing, unknown or revoked keys) are limited per client address: after 30 in 10 minutes, that address gets `429` with `Retry-After` until the window passes, even with a good key. ## Staying within the limit [Section titled “Staying within the limit”](#staying-within-the-limit) * Cache `GET /availability` for 60 seconds per store and postal code. It is meant for that. * Quote when the shopper has an address, not on every keystroke. * Use webhooks instead of polling orders. Reconcile with the `GET /orders` change feed using `updatedSince`, not by reading every order. * Spread batch jobs out instead of sending them in a burst. Need more? Talk to Delivr.
# Versioning
> How the API changes, and how to stay compatible.
The API is versioned by date. The current version is **`2026-11-01`**. Every answer carries it in `Delivr-Api-Version`, and every webhook in `apiVersion` and the `Delivr-Api-Version` header. ## What can change without notice [Section titled “What can change without notice”](#what-can-change-without-notice) These changes are backwards compatible and ship in the current version: * New endpoints. * New optional request fields. * New response fields, and new fields in webhook `data`. * New webhook event types. * New values in `reason`, error `code` and other open lists. * New options in quotes. Build your integration to tolerate them: ignore fields and events you do not know, and treat unknown reasons and codes by their category. ## What needs a new version [Section titled “What needs a new version”](#what-needs-a-new-version) Removing or renaming fields, changing types or meanings, or making optional fields required. Delivr announces such changes to integrators ahead of time. See the [changelog](/changelog/) for every change.
# Cancelling
> Check what a cancellation costs, then cancel with a fee cap.
What cancelling costs depends on how far the order has come. Always check first, or cap the fee. ## Check the fee [Section titled “Check the fee”](#check-the-fee) [`GET /v1/merchant/orders/{order}/cancellation`](/api/operations/getcancellationfee/) answers what cancelling costs now: Request
```bash
curl https://driver-staging-api.delivr.no/v1/merchant/orders/$ORDER/cancellation \
-H "Authorization: Bearer $DELIVR_API_KEY"
```
Response · 200
```json
{
"cancellable": true,
"stage": "driver_en_route",
"fee": { "amountMinor": 7900, "currency": "NOK" }
}
```
`fee.amountMinor` is VAT included. Cancellation fees come without `vatMinor` and `exVatMinor`. | Stage | On demand | Window | | ----------------- | ------------------------------------------------------------------- | ------------------ | | `free` | No driver has accepted yet, or a driver is handing the job back. | Before `cutoffAt`. | | `driver_en_route` | A driver accepted and is on the way. Your agreement’s en-route fee. | | | `driver_arrived` | The driver is at the store. Your agreement’s arrival fee. | | | `not_cancellable` | Picked up, or ended. | After `cutoffAt`. | On demand there is no grace period: the fee applies from the moment a driver accepts. ## Cancel [Section titled “Cancel”](#cancel) [`POST /v1/merchant/orders/{order}/cancel`](/api/operations/cancelorder/): Request
```bash
curl -X POST https://driver-staging-api.delivr.no/v1/merchant/orders/$ORDER/cancel \
-H "Authorization: Bearer $DELIVR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{ "reasonCode": "changed_mind", "maxFeeMinor": 0 }'
```
The answer has the cancelled `order` and the `fee` it settled with. * **`maxFeeMinor`** caps the fee you accept. Send `0` to cancel only while it is free. If the fee is higher you get `409 fee_changed` with the current fee in `details`, and nothing is cancelled. Without `maxFeeMinor` you accept any fee. * **`reasonCode`**: on demand `too_slow`, `changed_mind`, `price`, `found_other`, `wrong_details` or `other`; for windows `plans_changed` (default), `found_other`, `wrong_details` or `other`. `other` needs a `reasonText`. * A window order after its cutoff answers `409 cancellation_not_allowed`. * Cancelling again answers the same result, so retries are safe. Fees appear on your weekly [invoice](/guides/billing/).
# Fallbacks
> What happens when an on-demand order fails, and how successor orders work.
Sometimes an on-demand delivery cannot happen: no driver accepts it (`no_driver`), or the goods are never ready (`goods_not_ready`). Rather than leaving the shopper with nothing, your **fallback policy** can move the delivery to a window. Delivr sets the policy for you at onboarding. | Policy | What happens after the order fails | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `recipient_choice` (default) | You get `order.awaiting_recipient` with a `choiceDeadline` (30 minutes later). The recipient picks a window on their tracking page, free for them. You are billed for the window delivery. | | `rebook_window` | The nearest free window is booked automatically. | | `cancel` | Nothing more happens. The order stays failed. | ## Successor orders [Section titled “Successor orders”](#successor-orders) When a window is booked, Delivr creates a **successor order**: 1. The failed order sends `order.successor_created` with the `successorId`. 2. The successor sends its own `order.placed`, with `predecessorId` pointing back. 3. The successor keeps your `externalRef`, and the recipient’s tracking link follows along. If no successor follows, you get `order.fallback_cancelled` with `fallbackReason` `expired` (the recipient did not choose in time) or `no_window` (no window was free). ## Handling it [Section titled “Handling it”](#handling-it) * Under `recipient_choice` and `rebook_window`, treat `order.failed` with `no_driver` or `goods_not_ready` as “may continue”: wait for `order.successor_created` or `order.fallback_cancelled` before you refund or tell the shopper. * Under `cancel`, `order.failed` is final. No fallback event follows. * Follow `successorId` with [`GET /v1/merchant/orders/{id}`](/api/operations/getorder/) to reach the current order. `GET /v1/merchant/orders?externalRef=…` lists the failed order and its successors alike. * [Assisted checkout](/guides/assisted-checkout/) orders that are still held have no fallback. In the [sandbox](/sandbox/), put `sandbox:no_driver` in `pickup.note` and send [`ready`](/api/operations/markready/): the order fails within seconds, so you can watch your fallback run. Under `recipient_choice`, open the failed order’s `trackingUrl` to choose a window as the recipient would.
# Assisted checkout
> Find a driver while the customer is still at your till or on the phone, then complete the sale.
In a store, or on the phone, a seller often wants to know a driver is available before taking payment. Assisted checkout holds an on-demand order while the sale runs, and confirms it when the sale completes. ## The flow [Section titled “The flow”](#the-flow) 1. **Quote.** [`POST /v1/merchant/quotes`](/api/operations/createquote/). The `on_demand` option has a `supply` hint with `nearbyDrivers` and a `pickupEta` band. Use it to tell the seller what to expect. It is not a promise, and it is not meant for shoppers. 2. **Hold.** [`POST /v1/merchant/orders`](/api/operations/createorder/) with the `quoteId`, `"option": "on_demand"`, `"sale": "pending"` and `delivery`, and **no `externalRef`**. The driver search starts at once (the goods are taken as ready at `readyBy`, or now). 3. **Wait for a driver.** Poll [`GET /v1/merchant/orders/{id}`](/api/operations/getorder/). A driver is found when `sale.status` is `pending` and `sale.driver` is `assigned`. 4. **Complete the sale.** When the customer has paid, [`POST /v1/merchant/orders/{id}/sale`](/api/operations/completesale/) with your `externalRef`. The recipient gets their SMS and tracking link, and you get `order.placed`. 5. **Or abort.** [`POST /v1/merchant/orders/{id}/cancel`](/api/operations/cancelorder/), with `"maxFeeMinor": 0` to cancel only while free. While the sale is pending: * the recipient gets no SMS and no tracking link, * you get `order.held` instead of `order.placed`, * the order is not listed in `GET /v1/merchant/orders` (read it by id), * no [fallback](/guides/fallbacks/) applies. ## The checkout budget [Section titled “The checkout budget”](#the-checkout-budget) Once a driver accepts, the clock starts. `sale.completeBy` (10 minutes after the driver’s accept by default, or as your agreement says) is how long you have to complete the sale. * If it passes before the pickup, the order is cancelled at its current fee stage, `sale.status` becomes `abandoned`, and a later `/sale` answers `409 order_ended`. * If no driver accepts, the order fails with `no_driver`. * If the driver picks the goods up before you call `/sale`, Delivr records the sale as late and stops the clock. You still have to call `/sale`. A driver handing the job back shows as `sale.driver: reassigning`. It is not sent as a webhook, so keep polling. ## Sale states [Section titled “Sale states”](#sale-states) | `sale.status` | `sale.driver` | Meaning | | ------------- | ------------- | --------------------------------------------------------- | | `pending` | `searching` | Looking for a driver. | | `pending` | `assigned` | A driver accepted. Complete the sale before `completeBy`. | | `pending` | `reassigning` | The driver gave the job back; another is being found. | | `completed` | any | The sale is complete and the order is a normal order. | | `abandoned` | any | The budget passed and the order was cancelled. | Use the order’s `sale` object, not its `status`, to decide whether a driver is found: `status` stays `driver_assigned` while a driver hands the job back.
# The recipient's experience
> What your customer sees, from the SMS to the tracking page.
Once an order is placed, the recipient (`delivery.contact.phone`) gets an SMS from **Delivr** that names your store and links to a live tracking page. The order’s `trackingUrl` is the same link, so you can also show it in your own order confirmation and emails. The tracking page shows: * the order’s progress and the expected arrival, * the driver on a map once they are on the way, * when your store expects the goods to be ready (`readyAt`), * the products you sent in `items`, with images when you gave an `imageUrl`, * after a failed on-demand order with the `recipient_choice` policy, a choice of windows (see [Fallbacks](/guides/fallbacks/)). ## What stays private [Section titled “What stays private”](#what-stays-private) These reach the driver only. They never appear on the tracking page, in SMS or in webhooks: * the door code (`delivery.dropoff.doorCode`), * the order’s `pickup.note`, * the store’s `pickupInstructions`. ## The tracking link [Section titled “The tracking link”](#the-tracking-link) `trackingUrl` is present from placement. It is absent while an [assisted checkout](/guides/assisted-checkout/) sale is pending, and once the order has ended (unless a fallback is still open). When a fallback creates a successor order, the same link follows it.
# Billing
> How Delivr prices and invoices your deliveries.
You charge the shopper in your own checkout, at whatever price you choose. Delivr bills you. * **Prices** come from your agreement with Delivr, and are frozen in the quote. The option’s `price` is exactly what you are billed, VAT included (`vatMinor` and `exVatMinor` give the split). * **Invoices** are weekly, one per market, for delivered orders plus any cancellation fees. Corrections come as credit notes. * **Fallback windows.** When a failed on-demand order moves to a window, you are billed for the window delivery. The recipient pays nothing extra. * **Cancellation fees** follow the stages in [Cancelling](/guides/cancelling/). All amounts are integers in minor units: `{"amountMinor": 14900, "currency": "NOK"}` is NOK 149.00.
# Go-live checklist
> What to check before you switch your integration to production.
Run through this list with your sandbox integration before you ask Delivr for production keys. ## Orders [Section titled “Orders”](#orders) * [ ] Every write sends one `Idempotency-Key` per action, saved before the first attempt and reused on every retry. See [Idempotency](/conventions/idempotency/). * [ ] Your order system stores Delivr’s `id` and `reference` next to your own order id (`externalRef`). * [ ] You handle `409 price_changed`, `409 option_unavailable` and `409 window_full` when an order is placed on an older quote. See [Quotes and orders](/guides/quotes-and-orders/). * [ ] Each order has a recipient name and phone in `delivery.contact`, so the recipient gets the tracking SMS. * [ ] Parcels are declared with a realistic weight and size, or your stores have a `defaultParcel`. ## Stores [Section titled “Stores”](#stores) * [ ] Every store is a [location](/guides/locations/) with the right address, contact phone and `pickupInstructions`. * [ ] Each store’s `readySignal`, `prepMinutes` and `readyDeadlineMinutes` match how it really works. * [ ] If a store uses `readySignal: required`, its staff or system sends `processing` or `ready` for every order. ## Webhooks [Section titled “Webhooks”](#webhooks) * [ ] Your endpoint is public HTTPS and answers `2xx` within 15 seconds, doing slow work afterwards. * [ ] You [verify every signature](/webhooks/signatures/) with the whole `whsec_` secret, pass the [test vector](/webhooks/signatures/#test-vector), and reject old timestamps. * [ ] Every verified event gets a `2xx`, unknown types and events you cannot process included: a failing event holds back that order’s later events. * [ ] You deduplicate on the event `id`, and apply `data` only when its `version` is newer than what you have. * [ ] You create orders in your system on `order.placed` only. * [ ] A nightly job reads `GET /v1/merchant/orders?updatedSince=…` to catch anything missed. See [Reconciling](/guides/order-lifecycle/#reconciling). ## Statuses and failures [Section titled “Statuses and failures”](#statuses-and-failures) * [ ] You handle every status and reason in the [order lifecycle](/guides/order-lifecycle/), including `failed` with `no_driver` and `goods_not_ready`. * [ ] Your customer service knows what your [fallback policy](/guides/fallbacks/) does when an on-demand order fails. * [ ] Cancellations send `maxFeeMinor` when you only want to cancel free of charge. See [Cancelling](/guides/cancelling/). ## Operations [Section titled “Operations”](#operations) * [ ] Logs keep the `X-Request-Id` of every call, so you can quote it to Delivr support. * [ ] Unknown response fields and event types are ignored, not errors. See [Versioning](/conventions/versioning/). * [ ] API keys live in a secret manager, and only on servers. When everything is ticked, send your Delivr contact your sandbox merchant name and your production webhook URL. Delivr hands you production keys, the production base URL and your webhook secret. Each secret is shown once: put it straight into your secret manager, and never send it back to us.
# Build with AI agents
> Give your coding agent everything it needs to integrate the Delivr Merchant API.
These docs are written to be read by people and by AI coding agents such as Claude Code, Cursor, Codex and Copilot. Point your agent at the right file and it can write most of an integration for you. ## Machine-readable files [Section titled “Machine-readable files”](#machine-readable-files) | File | What it is | Use it for | | ------------------------------------ | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | | [`/openapi.yaml`](/openapi.yaml) | The OpenAPI 3.1 contract: every route, schema, error code and webhook. | Generating clients and types, and as the agent’s ground truth for field names. | | [`/llms.txt`](/llms.txt) | An index of every guide, with a link to its Markdown, following [llmstxt.org](https://llmstxt.org). | Letting an agent find the right page. | | [`/llms-full.txt`](/llms-full.txt) | Every guide in one Markdown file, in reading order. | Loading all the docs into an agent’s context at once. | | [`/llms-small.txt`](/llms-small.txt) | A condensed version. | Smaller context windows. | | `/.md` | Any guide as Markdown, such as [`/quickstart.md`](/quickstart.md). | Quoting one page. | Every guide page also has **Copy page**, **View as Markdown**, **Open in Claude** and **Open in ChatGPT** buttons under its title. The API reference pages have no Markdown version: use `/openapi.yaml` for them. ## A prompt to start from [Section titled “A prompt to start from”](#a-prompt-to-start-from)
```text
Integrate the Delivr Merchant API into this codebase.
Read https://docs.delivr.no/llms-full.txt for the guides and
https://docs.delivr.no/openapi.yaml for the exact contract. Prefer the
OpenAPI document for field names and types, and generate types from it.
Build:
1. A server-side Delivr client with the API key from the environment
(DELIVR_API_KEY), base URL from DELIVR_API_URL. Never expose the key
to the browser.
2. Quote at checkout (POST /v1/merchant/quotes) and show the options.
Name the store by pickupExternalStoreId or pickupLocationId, never both.
3. Place the order when payment succeeds (POST /v1/merchant/orders).
Use one Idempotency-Key per logical action: generate it once, save it
with the order before the first attempt, and reuse it on every retry
of that action. Retry network errors, 429, 500 and 503 and
409 idempotency_key_in_flight with the same key.
Handle 409 price_changed, option_unavailable and window_full.
4. A webhook endpoint that verifies X-Delivr-Signature over the raw body
(HMAC-SHA256 keyed with the whole whsec_ secret, not decoded), answers
2xx fast for every verified event including unknown types,
deduplicates on event id, applies data only when version is newer,
and creates orders on order.placed only.
5. Handling for failed orders: with a fallback policy, wait for
order.successor_created (follow successorId) or
order.fallback_cancelled before telling the shopper.
6. A test for the signature check using the test vector at
https://docs.delivr.no/webhooks/signatures/#test-vector.
Test against the sandbox (https://driver-staging-api.delivr.no), where
robo drivers deliver on-demand orders in about two minutes. Call
POST /v1/merchant/orders/{id}/ready after placing so the search starts
at once.
```
## Tips for agent-written integrations [Section titled “Tips for agent-written integrations”](#tips-for-agent-written-integrations) * Ask the agent to **generate types from the OpenAPI document** instead of writing them by hand; see [Client libraries](/client-libraries/). Request bodies are strict, so a guessed field name fails loudly with `422 invalid_request`. * Have it write a **webhook signature test** with the [test vector](/webhooks/signatures/#test-vector) before wiring the endpoint up. * Run the [go-live checklist](/go-live/) yourself. It covers what agents most often miss: idempotency keys reused across retries, version checks on webhooks, and handling `failed` orders.
# Client libraries
> Generate a typed client from the OpenAPI document, or import it into Postman or Insomnia.
Delivr has no official SDKs. The API is plain JSON over HTTPS, and the [OpenAPI 3.1 document](/openapi.yaml) describes every route, field and webhook, so you can generate types and a client for your language. OpenAPI 3.1 support differs between generators; check that yours supports it. ## TypeScript [Section titled “TypeScript”](#typescript) [`openapi-typescript`](https://openapi-ts.dev) generates types, and `openapi-fetch` gives you a typed `fetch`:
```bash
npm install openapi-fetch
npx openapi-typescript https://docs.delivr.no/openapi.yaml -o src/delivr.d.ts
```
```ts
import createClient from "openapi-fetch";
import type { paths } from "./delivr";
const delivr = createClient({
baseUrl: process.env.DELIVR_API_URL,
headers: { Authorization: `Bearer ${process.env.DELIVR_API_KEY}` },
});
const { data, error } = await delivr.POST("/v1/merchant/quotes", {
body: {
pickupExternalStoreId: "oslo-sentrum",
delivery: { address: { street: "Karl Johans gate 22", postalCode: "0026", city: "Oslo" } },
parcels: [{ count: 1, weightKg: 4.5, lengthCm: 40, widthCm: 30, heightCm: 20 }],
},
});
```
Writes other than quotes also need an `Idempotency-Key` header; see [Idempotency](/conventions/idempotency/). ## Other languages [Section titled “Other languages”](#other-languages) | Language | Generator | Command | | -------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | | Python | [openapi-python-client](https://github.com/openapi-generators/openapi-python-client) | `openapi-python-client generate --url https://docs.delivr.no/openapi.yaml` | | Go | [ogen](https://ogen.dev) | `go run github.com/ogen-go/ogen/cmd/ogen@latest --target delivr --package delivr --clean openapi.yaml` | | Others | [OpenAPI Generator](https://openapi-generator.tech) (PHP, Java, C#, Ruby and more) | `npx @openapitools/openapi-generator-cli generate -i https://docs.delivr.no/openapi.yaml -g php -o delivr-php` | Generated clients do not verify webhook signatures. Use the code in [Verifying signatures](/webhooks/signatures/). ## Postman and Insomnia [Section titled “Postman and Insomnia”](#postman-and-insomnia) Import `https://docs.delivr.no/openapi.yaml` by URL (Postman: **Import**, then paste the link; Insomnia: **Import**, **From URL**). Then: * set the base URL to `https://driver-staging-api.delivr.no`, * set bearer auth on the collection with your sandbox key, * send a fresh UUID as `Idempotency-Key` on writes: `{{$guid}}` in Postman, the UUID template tag in Insomnia. Reuse the same value when you retry the same action.
# Glossary
> The terms these docs and the API use.
| Term | Meaning | | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | Merchant | You: the retailer whose stores and orders an API key sees. | | Location | A store or warehouse goods are picked up at. Named by Delivr’s `id` or your `externalStoreId`. See [Store locations](/guides/locations/). | | Quote | The priced options for one delivery from a store to an address. Offered for 5 minutes (`expiresAt`). See [Quotes and orders](/guides/quotes-and-orders/). | | Option | One way to deliver in a quote: `on_demand` or a `window`. | | Option key | An option’s stable name, such as `on_demand` or `window:/`. You book an option by its key. | | On demand | A driver now, timed to arrive when your goods are ready. Has an `eta` band. | | Window | A delivery in a time slot, such as 17:00 to 19:00, on a transport partner’s route. | | Cutoff (`cutoffAt`) | The last time a window can be booked and cancelled free. | | ETA band (`eta`) | When the recipient can expect an on-demand delivery, as a `from` and `to` time. | | Order | A booked delivery. Its `id` equals the `quoteId` it was placed on. | | `reference` | The order’s short id for people, such as `DD-482913`. Paths accept it in place of `id`. | | `externalRef` | Your own order id, unique among your live orders. | | `version` | A number that increases with every change to an order. Keep the highest you have seen. | | `readyBy` | When the goods are due at the latest. See [Goods ready signals](/guides/ready-signals/). | | `readyAt` | When the goods are, or are assumed to be, ready. | | Ready signal | How a store tells Delivr the goods are ready: `assumed` (after `prepMinutes`) or `required` (you send `processing` or `ready`). | | Release | The moment an on-demand order’s goods count as ready and the driver search starts (`searching`). | | Held order | An on-demand order placed with `sale: "pending"`, waiting for a sale at your till. See [Assisted checkout](/guides/assisted-checkout/). | | Checkout budget | How long you have to complete a held order’s sale after a driver accepts (`sale.completeBy`). | | Fallback policy | What happens when an on-demand order fails: `recipient_choice`, `rebook_window` or `cancel`. See [Fallbacks](/guides/fallbacks/). | | Successor order | The window order a fallback creates for a failed on-demand order (`successorId`, `predecessorId`). | | Recipient | Who receives the delivery (`delivery.contact`). Gets the SMS and the tracking page. | | Minor units | Money as an integer in the currency’s smallest unit: `14900` is NOK 149.00. | | Idempotency key | A UUID you send with a write so a retry never does it twice. See [Idempotency](/conventions/idempotency/). | | Event | One webhook, with a stable `id`. See [Event types](/webhooks/events/). | | Delivery attempt | One try at posting an event to your endpoint, in the delivery log. | | Robo driver | A test driver in the sandbox that delivers on-demand orders by itself. See [Sandbox and testing](/sandbox/). | | Directive | A `sandbox:…` instruction in `pickup.note` that tells robo drivers which failure to play. |
# FAQ
> Short answers to the questions integrators ask most.
## Getting started [Section titled “Getting started”](#getting-started) **How do I get an API key?** Ask your Delivr contact; see [Get sandbox access](/sandbox/#get-sandbox-access). Production keys follow the [go-live checklist](/go-live/). **Is there an SDK?** No. Generate a typed client from the OpenAPI document; see [Client libraries](/client-libraries/). **Can I call the API from the browser or a mobile app?** No. Keys are for servers only. Call Delivr from your backend and pass the options on. ## Quotes and orders [Section titled “Quotes and orders”](#quotes-and-orders) **Why is there no `on_demand` option?** No driver can reach the store in time right now. In the sandbox, robo drivers need a minute to reach a new store, and two drivers are shared per area; see [Robo drivers](/sandbox/#robo-drivers). **Why do I get `422 invalid_address`?** The address is not in the national address register. Check the street, number and postal code, or send `latitude` and `longitude`. **Do I need a quote to place an order?** No. Send the store, `parcels` and the option key without a `quoteId`; see [Booking without a quote](/guides/quotes-and-orders/#booking-without-a-quote). **The quote expired before the shopper paid. Now what?** Place the order anyway. Delivr quotes again and books the same key at no more than the quoted price, or answers `409 price_changed`. **How do I avoid booking twice?** Send one `Idempotency-Key` per action and reuse it on every retry; see [Idempotency](/conventions/idempotency/). **Can I remove a store?** Not yet. Change it with `PATCH /v1/merchant/locations/{id}`. ## Webhooks [Section titled “Webhooks”](#webhooks) **My signatures never match.** Compute the HMAC over the raw body bytes, keyed with the whole secret including `whsec_`, and check your code against the [test vector](/webhooks/signatures/#test-vector). **Events arrive out of order.** They can. Apply `data` only when its `version` is higher than the one you have; see [Handling events correctly](/webhooks/#handling-events-correctly). **My endpoint was down. Did I lose events?** No. Delivr retries for about 23 hours, you can replay any event for 30 days, and the [order list](/guides/order-lifecycle/#reconciling) catches anything else. **How do I change my webhook URL?** Ask your Delivr contact. You choose the events yourself with `PUT /v1/merchant/webhook/events`. ## Sandbox [Section titled “Sandbox”](#sandbox) **Why does my window order stay `scheduled`?** Robo drivers only deliver on-demand orders. Ask us if you need a window order run. **Are SMS sent in the sandbox?** No. Open the order’s `trackingUrl` to see what the recipient sees.
# Support
> How to get help with your integration.
We want your integration to go smoothly. Your Delivr contact helps with keys, webhook setup, sandbox drivers and anything in these docs. ## Help us help you [Section titled “Help us help you”](#help-us-help-you) Include these in your message and we can answer much faster: * The **`X-Request-Id`** of the call. Every answer carries one, and every error body repeats it as `error.requestId`. You can also send your own `X-Request-Id` (up to 64 characters of `A-Za-z0-9._-`) to correlate with your logs. * The order’s **`reference`** (`DD-…`) or `id`. * Whether it happened in the **sandbox or production**. * What you expected, and what you got instead (status and `error.code`). Never send us API keys or webhook secrets, by email or otherwise. We never ask for them: a request id or order reference is enough. ## Feedback on the docs [Section titled “Feedback on the docs”](#feedback-on-the-docs) Something unclear, wrong or missing? Tell your Delivr contact. Every page also has a “Copy page” button, which is handy if you want to quote the exact text.
# Changelog
> Changes to the Delivr Merchant API, newest first.
The API version is a date. Every answer carries it in `Delivr-Api-Version`, and every webhook in `apiVersion`. Additive changes, such as new fields, endpoints and event types, ship without a new version; see [Versioning](/conventions/versioning/). ## 2026-11-03 [Section titled “2026-11-03”](#2026-11-03) * **Robo drivers in the sandbox.** Test drivers are parked at every sandbox store and deliver on-demand orders in about two minutes, so you see every status and webhook without a person in the loop. Directives in `pickup.note` test the failure paths (`sandbox:no_driver`, `sandbox:hand_back`, `sandbox:stop_en_route`, `sandbox:stop_at_pickup`). See [Sandbox and testing](/sandbox/). * **Developer site.** These docs, with an `llms.txt`, Markdown pages and the OpenAPI document at [`/openapi.yaml`](/openapi.yaml). * **New guides.** [Showing options at checkout](/guides/checkout-options/), [Testing webhooks](/webhooks/testing/) with a signature [test vector](/webhooks/signatures/#test-vector), [Client libraries](/client-libraries/), a [glossary](/glossary/) and an [FAQ](/faq/). ## 2026-11-01 [Section titled “2026-11-01”](#2026-11-01) The first version of the Merchant API. * Store locations you manage yourself (`/locations`). * Availability hints, quotes, and orders for on-demand and window deliveries. * Goods ready signals (`processing`, `ready`) with `readySignal` per store. * Assisted checkout: hold an on-demand order while the sale runs at your till (`sale: pending`, `/sale`). * Cancellation with fee preview and `maxFeeMinor`. * Fallbacks when an on-demand order fails, with successor orders. * Signed webhooks with retries, a delivery log, replay and test events.