Skip to content

Order webhooks

Get your storefront's orders pushed to your server as they are placed, paid and prepared.

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:

  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.
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 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} 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 contains a complete receiver (server/src/webhooks.js).

Type to search…

↑↓ navigate↵ selectEsc close