# Receiving webhooks

> Get a signed HTTPS call for every change to your orders.

Source: https://docs.delivr.no/webhooks/

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

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

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

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

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.