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}.
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}.
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
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{
"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} returns it at the
moment of sending. The full schema is in the
API reference.
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:
- Read the raw request body, exactly the bytes you received, before any JSON parsing. A re-serialised body does not match.
- Compute the signature over
<X-Unipage-Timestamp>.<raw body>and compare it withX-Unipage-Signaturein constant time. - Refuse a timestamp more than 5 minutes from your clock, so a captured request cannot be replayed.
- Answer
401when the check fails. Otherwise answer2xxat once and do the work afterwards.
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:
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.
});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
2xxwithin 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.statusas the current state, not the order of arrival, and readGET /orders/{id}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
2xxand ignore it.
The example storefront
contains a complete receiver (server/src/webhooks.js).