Instead of polling for sales, the restaurant can have Unipage POST every new receipt to your system the moment the till syncs it.
Setting one up
The restaurant adds the webhook in its management settings (Instellingen > Api > Webhooks > “Webhook toevoegen”):
| Setting | Notes |
|---|---|
| URL | https:// only, publicly reachable, no username or password in the URL |
| Authentication | none, a Bearer token, Basic (username and password), or an API key in a header of your choice |
| Extra headers | up to 20, sent with every request |
| Events | today: pos.sale |
On save, the signing secret (whsec_...) is shown once. The restaurant
hands it to you together with the URL. A restaurant can have up to 10
webhooks. “Verstuur test” sends a ping event right away, so you can check
your endpoint.
Events
| Event | When | data |
|---|---|---|
pos.sale |
a new VAT receipt arrives from the till, refunds included | the receipt, with ticket_lines, discount_lines and payment_lines |
ping |
the restaurant clicks “Verstuur test” | {"message": "Test event from Unipage"} |
A receipt the till sends again does not fire a second pos.sale. Training
mode receipts are never sent.
The request
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Unipage-Webhooks/1.0
X-Unipage-Event: pos.sale
X-Unipage-Timestamp: 1761655267
X-Unipage-Signature: sha256=5d41402abc4b2a76b9719d911017c592...{
"id": "9b2f6c1e-4a7d-4f0e-9a51-2c3d8e7f1a20",
"event": "pos.sale",
"created_at": "2026-10-28T12:41:09+00:00",
"business_id": "x94d3",
"data": { "id": "k3m9a", "type": "VAT", "ticket_lines": [], "payment_lines": [], "discount_lines": [] }
}When the webhook has a Bearer token or Basic credentials configured, they
come in the Authorization header; an API key comes in the header the
restaurant chose.
Answer with any 2xx within 5 seconds. Do the real work after you answer,
for example on your own queue.
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);Deliveries, failures and resending
- No automatic retries. A delivery that fails (no answer within 5
seconds, or a status outside
2xx) is logged and not tried again by itself. Fetch the day withGET /pos-tickets-by-dateto catch up after an outage of your endpoint. - Resending by hand. The restaurant sees every attempt of the last 30
days under “Leveringen”, with the exact payload and your answer, and can
send one again. A resend carries the same event
id, so deduplicate on it: a resent sale must not be booked twice. - Switched off after 50 failures in a row. The webhook is then disabled until the restaurant switches it on again, which resets the counter.
- No redirects. A
3xxanswer counts as a failure: point the webhook at the final URL. - Public addresses only. A URL whose host resolves to a private, local or reserved address is refused.
Rotating the secret
“Nieuw signing secret” replaces the secret at once and shows the new one once. Deliveries from that moment are signed with the new secret, so update your endpoint at the same time.