---
title: "Order webhooks"
description: "Get your storefront's orders pushed to your server as they are placed, paid and prepared."
---

> Documentation Index
> Fetch the complete documentation index at: https://developer.unipage.be/llms.txt
> Use this file to discover all available pages before exploring further.

# Order webhooks

Unipage can send your server an HTTPS `POST` every time one of your
storefront's orders is placed, paid or changes status, so you never have to
poll [`GET /orders/{id}`](/storefront/api/orders/retrieve/).

## Setting it up

Ask Unipage to set a webhook URL on your storefront. It must be `https://`
on a public address, and no redirect is followed. You receive a **signing
secret** once: keep it on your server, next to the secret key. The webhook
belongs to the storefront, not to a key: rotating the public key keeps it.

Events are only sent for orders placed with your storefront's own secret key.
After the secret key is rotated, orders placed with the old one send no more
events: read those with [`GET /orders/{id}`](/storefront/api/orders/retrieve/).

## Events

| Event | When |
|---|---|
| `order.created` | right after `POST /orders` placed the order, any payment type. Always the first event of an order. |
| `order.paid` | an `online` order's payment was confirmed (`isPaid` became true) |
| `order.status_changed` | every change of `status` after that, with `data.previousStatus` |

A typical `online` order sends `order.created` (`PENDING_PAYMENT`), then
`order.paid` and `order.status_changed` to `PLACED` when the payment is
confirmed, then `order.status_changed` to `CHEF_STARTED` and
`CHEF_COMPLETED`. An `external` order starts `PLACED` and paid: no
`order.paid` follows.

## The request

```http
POST /unipage/webhooks HTTP/1.1
Content-Type: application/json
User-Agent: Unipage-Webhooks/1.0
X-Unipage-Event: order.status_changed
X-Unipage-Timestamp: 1792749000
X-Unipage-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
```

```json
{
  "id": "3f6c2a1e-9b7d-4c1a-8e2f-0a5b6c7d8e9f",
  "event": "order.status_changed",
  "created_at": "2026-10-28T11:30:00+00:00",
  "business_id": "k3x9p",
  "data": {
    "order": { "id": "q4m8z", "status": "CHEF_STARTED", "isPaid": true, "...": "..." },
    "previousStatus": "PLACED"
  }
}
```

`data.order` is the order exactly as
[`GET /orders/{id}`](/storefront/api/orders/retrieve/) returns it at the
moment of sending. The full schema is in the
[API reference](/storefront/api/).

## Verifying the signature

Every webhook request carries three headers:

| Header | Value |
|---|---|
| `X-Unipage-Event` | the event name |
| `X-Unipage-Timestamp` | when the request was signed, in Unix seconds |
| `X-Unipage-Signature` | `sha256=` plus the hex HMAC-SHA256 of `<timestamp>.<raw body>`, keyed with your signing secret (`whsec_...`) |

The `User-Agent` is `Unipage-Webhooks/1.0`. To verify a request:

1. Read the **raw** request body, exactly the bytes you received, before any
   JSON parsing. A re-serialised body does not match.
2. Compute the signature over `<X-Unipage-Timestamp>.<raw body>` and compare
   it with `X-Unipage-Signature` in constant time.
3. Refuse a timestamp more than 5 minutes from your clock, so a captured
   request cannot be replayed.
4. Answer `401` when the check fails. Otherwise answer `2xx` at once and do
   the work afterwards.

### Node.js

```js
import { createHmac, timingSafeEqual } from "node:crypto";

// headers: lower-cased header names, rawBody: the body as a string.
export function verify(secret, headers, rawBody) {
  const timestamp = String(headers["x-unipage-timestamp"] || "");
  const signature = String(headers["x-unipage-signature"] || "");
  if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return false;
  }
  const expected = Buffer.from(
    "sha256=" + createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex"),
  );
  const given = Buffer.from(signature);
  return given.length === expected.length && timingSafeEqual(given, expected);
}
```

With Express, keep the body raw on the webhook route:

```js
app.post("/unipage/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const rawBody = req.body.toString("utf8");
  if (!verify(process.env.UNIPAGE_WEBHOOK_SECRET, req.headers, rawBody)) {
    return res.sendStatus(401);
  }
  res.sendStatus(200);
  const event = JSON.parse(rawBody);
  // Drop event.id if you handled it before, then do the work.
});
```

### PHP

```php
function verify(string $secret, string $timestamp, string $signature, string $rawBody): bool
{
    if (! ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
        return false;
    }
    $expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

    return hash_equals($expected, $signature);
}

$rawBody = file_get_contents('php://input');
$ok = verify(
    getenv('UNIPAGE_WEBHOOK_SECRET'),
    $_SERVER['HTTP_X_UNIPAGE_TIMESTAMP'] ?? '',
    $_SERVER['HTTP_X_UNIPAGE_SIGNATURE'] ?? '',
    $rawBody,
);
if (! $ok) {
    http_response_code(401);
    exit;
}

$event = json_decode($rawBody, true);
// Drop $event['id'] if you handled it before, then do the work.
http_response_code(200);
```

## Answering, retries and order

- Answer with any `2xx` within 5 seconds. Do the work after answering.
- Anything else (an error status, a redirect, a timeout) is a failed delivery.
  It is tried 3 times in total, about 30 seconds and 2 minutes apart, always
  with the same event `id`. Store the ids you handled and drop repeats.
- Events can arrive out of order, for instance when an earlier one is being
  retried. Use `data.order.status` as the current state, not the order of
  arrival, and read [`GET /orders/{id}`](/storefront/api/orders/retrieve/)
  when in doubt.
- After 50 failed deliveries in a row the webhook is switched off. Ask Unipage
  to set the URL again to switch it back on.
- New events may be added: answer an event you do not know with a `2xx` and
  ignore it.

The [example storefront](https://github.com/UnipageEU/storefront-example)
contains a complete receiver (`server/src/webhooks.js`).

Source: https://developer.unipage.be/storefront/webhooks/index.mdx
