This is the full 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, and you only need `curl`. Before you start Ask your Delivr contact for a **sandbox API key** (`dmk_…`) and, if you want webhooks, give them an HTTPS URL to send them to. Keys are shown once, so store yours in a secret manager. ```bash export DELIVR_API=https://driver-staging-api.delivr.no export DELIVR_API_KEY=dmk_your_sandbox_key ``` 1. **Check your key** List your store locations. An empty list is fine; it means your key works. ```bash curl $DELIVR_API/v1/merchant/locations \ -H "Authorization: Bearer $DELIVR_API_KEY" ``` ```json { "items": [] } ``` A `401 unauthenticated` means the key is wrong or revoked. See [Authentication](/authentication/). 2. **Add a store** Register the store your goods are picked up from. Delivr finds it on the map from its address. ```bash curl $DELIVR_API/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 }' ``` The answer is the location, with its `id`. From now on you can name this store by your own `externalStoreId`. 3. **Quote a delivery** Ask what Delivr can offer from the store to an address, for one 4.5 kg parcel. ```bash curl $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": "0159", "city": "Oslo" } }, "parcels": [{ "count": 1, "weightKg": 4.5, "lengthCm": 40, "widthCm": 30, "heightCm": 20 }] }' ``` ```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, "currency": "NOK" } }, { "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, "currency": "NOK" } } ] } ``` Show these options to the shopper. Prices are what Delivr bills you, VAT included; what you charge the shopper is up to you. Tip No `on_demand` option? There is no driver capacity for that address right now. The sandbox has a few test drivers in Oslo; see [Sandbox and testing](/sandbox/). 4. **Place the order** When the shopper pays, book the option they chose. Use your own order id as `externalRef`, and give the recipient’s name and phone so Delivr can text them a tracking link. ```bash curl $DELIVR_API/v1/merchant/orders \ -H "Authorization: Bearer $DELIVR_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "quoteId": "3f0e9a52-8a7e-4f8e-9a51-2c0f3c9d1b7a", "option": "on_demand", "externalRef": "order-1001", "delivery": { "address": { "street": "Karl Johans gate 22", "postalCode": "0159", "city": "Oslo" }, "contact": { "name": "Kari Nordmann", "phone": "+4791234567" }, "dropoff": { "floor": 3, "doorCode": "1234" } } }' ``` You get `201 Created` and the order. Its `id` is the `quoteId`, and `reference` (`DD-…`) is what you quote to Delivr support. ```json { "id": "3f0e9a52-8a7e-4f8e-9a51-2c0f3c9d1b7a", "reference": "DD-482913", "externalRef": "order-1001", "type": "on_demand", "status": "awaiting_goods", "readyBy": "2026-11-03T14:00:00Z", "readyAt": "2026-11-03T13:10:00Z", "trackingUrl": "https://tracking-stage.delivr.no/t/…", "eta": { "from": "2026-11-03T13:25:00Z", "to": "2026-11-03T13:55:00Z" }, "timeZone": "Europe/Oslo", "version": 3, "createdAt": "2026-11-03T13:00:12Z", "updatedAt": "2026-11-03T13:00:13Z" } ``` 5. **Say the goods are ready** The store has packed the parcel. Tell Delivr, and the driver search starts at once. Order paths take Delivr’s `id` or the `DD-` reference. ```bash export ORDER=3f0e9a52-8a7e-4f8e-9a51-2c0f3c9d1b7a curl -X POST $DELIVR_API/v1/merchant/orders/$ORDER/ready \ -H "Authorization: Bearer $DELIVR_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` The order moves to `searching`. With the store’s default (`readySignal: assumed`) this step is optional: the goods count as ready after `prepMinutes`. See [Goods ready signals](/guides/ready-signals/). 6. **Watch a robo driver deliver it** The sandbox has **robo drivers** parked at every store. One accepts within seconds, drives to the store, picks up and delivers. A delivery takes about two minutes. Poll the order to watch it move: ```bash curl $DELIVR_API/v1/merchant/orders/$ORDER -H "Authorization: Bearer $DELIVR_API_KEY" ``` It goes `searching` → `driver_assigned` → `picked_up` → `delivered`, and the last answer has a `proofOfDelivery`. To test failures instead, put a directive such as `sandbox:no_driver` in `pickup.note`. See [Sandbox and testing](/sandbox/). 7. **Receive the webhooks** If Delivr set up your webhook endpoint, it received `order.placed`, `order.searching`, `order.driver_assigned`, `order.picked_up` and `order.delivered`. Check the delivery log: ```bash curl "$DELIVR_API/v1/merchant/webhooks/deliveries?orderId=$ORDER" \ -H "Authorization: Bearer $DELIVR_API_KEY" ``` Then [verify the signatures](/webhooks/signatures/) in your handler. ## What to read next [Section titled “What to read next”](#what-to-read-next) * [Quotes and orders](/guides/quotes-and-orders/): expired quotes, price changes and booking without a quote. * [Order lifecycle](/guides/order-lifecycle/): every status and reason to handle. * [Receiving webhooks](/webhooks/): ordering, retries and deduplication. * [Go-live checklist](/go-live/): what to check before you switch to production. # Authentication > 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. 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 repeated failed sign-ins 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 phone number of the market. * **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_…`) from your Delivr contact. They never work in production. | ## Getting set up [Section titled “Getting set up”](#getting-set-up) 1. Ask your Delivr contact for a sandbox merchant and API key. Give them your webhook URL if you want webhooks; it must be public HTTPS. For local development, use a tunnel such as `cloudflared` or `ngrok`. 2. Add your stores with [`POST /v1/merchant/locations`](/guides/locations/). Robo drivers park at a new store within a minute. 3. Check your webhook endpoint with `POST /v1/merchant/webhooks/test`, then `GET /v1/merchant/webhooks/deliveries` to see what your server answered. ## Robo drivers [Section titled “Robo drivers”](#robo-drivers) Every store in the sandbox has robo drivers parked outside, 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. Note Robo drivers only take Merchant API orders. Up to two park at each store, so a third order placed while both are busy may get no `on_demand` option until one is back. They return to the store after each delivery. ## 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 at once 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. | 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, and the order then searches again. ## 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` and send no `ready` or `processing`. You get `order.ready_overdue` at `readyBy`, then the order fails with `goods_not_ready` after the grace period. Send a `readyBy` a few minutes ahead to make it quick. | | 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 older than five minutes. You get either the order at no more than the quoted price, or `409 price_changed`. | | Free cancellation | Cancel before a driver accepts, with `maxFeeMinor: 0`. | | 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. # 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 page, 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. | 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. ## 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. 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. 3. Place the order when payment succeeds (POST /v1/merchant/orders), with a new Idempotency-Key per attempt, stored and reused on retry. Handle 409 price_changed, option_unavailable and window_full. 4. A webhook endpoint that verifies X-Delivr-Signature over the raw body, deduplicates on event id, applies data only when version is newer, and creates orders on order.placed only. Test against the sandbox (https://driver-staging-api.delivr.no), where robo drivers deliver on-demand orders in about two minutes. ``` ## 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. Request bodies are strict, so a guessed field name fails loudly with `422 invalid_request`. * Have it write a **webhook signature test** with a known secret and body 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. # 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. * 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. # 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 `Idempotency-Key`. | Add a 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. | 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 booked another option already. | 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`). | 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. # 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 an `Idempotency-Key` header with a UUID. Without one 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. | You send | You get | | ------------------------------------------------------------------------------ | -------------------------------------------------------------------- | | The same key and the same request (method, path and JSON body) 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, `409 idempotency_key_in_flight`, `429` 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. # 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`. Repeated failed sign-ins (bad keys) from one address are also blocked for a while, and answer `429`. ## 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. # 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 a new `Idempotency-Key` per action and reuses it on 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/) and reject old timestamps. * [ ] 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. ## 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, email your Delivr contact with your sandbox merchant name. You get production keys, the production base URL and your webhook secret. # 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`. 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` 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}`. 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` 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`, 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` (by default 10 minutes, per your agreement) 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. # 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. # 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) ```bash curl https://driver-staging-api.delivr.no/v1/merchant/orders/$ORDER/cancellation \ -H "Authorization: Bearer $DELIVR_API_KEY" ``` ```json { "cancellable": true, "stage": "driver_en_route", "fee": { "amountMinor": 7900, "vatMinor": 1580, "exVatMinor": 6320, "currency": "NOK" } } ``` | 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) ```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). 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. Your `externalRef` now points to the newest order, 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) * 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. * Look orders up by `externalRef` to always get the current one. * [Assisted checkout](/guides/assisted-checkout/) orders that are still held have no fallback. In the [sandbox](/sandbox/), `sandbox:no_driver` in `pickup.note` fails an order at once, so you can watch your fallback run. # 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) ```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`. | 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}` 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` lists your stores, and `GET /v1/merchant/locations/{id}` 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. # 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 and billed to you. 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 goes straight to `awaiting_goods` while it waits for the goods. ## 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}` takes Delivr’s `id` or the `DD-` reference. It is the source of truth. * `GET /v1/merchant/orders` 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. ## 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. | # 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 for a cheap hint by postal code: ```bash curl "https://driver-staging-api.delivr.no/v1/merchant/availability?pickupExternalStoreId=oslo-sentrum&postalCode=0159" \ -H "Authorization: Bearer $DELIVR_API_KEY" ``` ```json { "postalCode": "0159", "onDemand": { "available": true, "eta": { "fromMinutes": 35, "toMinutes": 60 } }, "windows": { "available": true }, "priceFrom": { "amountMinor": 9900, "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` 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`). | | `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`. ## 3. Order [Section titled “3. Order”](#3-order) When the shopper pays, `POST /v1/merchant/orders` 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 a short grace when you place. After that, or when the address or parcels changed, 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. ## 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 | # 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}` | 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` | 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 grace period 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. # 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. # Same-day delivery, built into your checkout > Quote a driver now or a delivery window, book it in one call, and follow every step with signed webhooks. One REST API for your web shop, your till and your back office. Quote a delivery from your store ```bash curl https://driver-staging-api.delivr.no/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": "0159", "city": "Oslo" } }, "parcels": [{ "count": 1, "weightKg": 4.5 }] }' ``` ## What you can build [Section titled “What you can build”](#what-you-can-build) Delivery at checkout Show “Delivered today” in the cart, price on-demand and window options for the shopper’s address, and book the one they pick. Store fulfilment Tell Delivr when packing starts and when goods are ready. The driver is timed to arrive when the parcel is. Sell in store, deliver home Find a driver while the customer is still at your till, then confirm the sale. Assisted checkout holds the job for you. Order sync Signed webhooks for every status, a change feed for reconciliation, and replay for anything you missed. ## Start here [Section titled “Start here”](#start-here) [Quickstart](/quickstart/)Your first quote, order and webhook in about ten minutes. [Sandbox and testing](/sandbox/)Drive an order from placed to delivered with sandbox test helpers. [Order lifecycle](/guides/order-lifecycle/)Every status, event and reason an order can have. [Build with AI agents](/ai/)llms.txt, Markdown pages and an OpenAPI 3.1 contract your coding agent can read. # Support > How to get help with your integration. We want your integration to go smoothly. Contact us at for 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 email API keys or webhook secrets. We never ask for them. ## Feedback on the docs [Section titled “Feedback on the docs”](#feedback-on-the-docs) Something unclear, wrong or missing? Tell us at . Every page also has a “Copy page” button, which is handy if you want to quote the exact text. # 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_…`). Then you manage it through the API: | Call | Does | | ----------------------------------------------- | ------------------------------------------------------------------------- | | `GET /v1/merchant/webhook` | Your endpoint, the subscribed events and the secret’s prefix. | | `PUT /v1/merchant/webhook/events` | Choose the events you want. An empty list means all. | | `POST /v1/merchant/webhooks/test` | Send a `webhook.test` event. | | `GET /v1/merchant/webhooks/deliveries` | The delivery log: every attempt, its status code and timing, for 30 days. | | `POST /v1/merchant/webhooks/events/{id}/replay` | Send an event again, with the same id. | ## The event [Section titled “The event”](#the-event) ```http POST /hooks/delivr HTTP/1.1 Content-Type: application/json X-Delivr-Event: order.picked_up X-Delivr-Event-Id: 6b0f4f1e-2b7a-4d4e-9a6b-1f3e5d2c7a90 X-Delivr-Signature: t=1762175400,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.** New ones may be added. When in doubt, `GET /v1/merchant/orders/{id}` is the source of truth. ```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); } const event = JSON.parse(req.body); if (await seen(event.id)) return res.sendStatus(200); await queue.add("delivr-event", event); // do the work after answering res.sendStatus(200); }); ``` ## 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. 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. # Event types > Every webhook event and when it is sent. Subscribe to the events you need with `PUT /v1/merchant/webhook/events`, 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`. `data.test` is `true` and there is no order. | The [API reference](/api/) has the full schema of every event. # 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=1762175400,v1=5f2b9c… ``` * `t` is the attempt’s Unix time in seconds. * `v1` is the hex HMAC-SHA256 of `"."`, keyed with your signing secret (`whsec_…`). 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; } ``` ## 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. # 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). ## 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.