Skip to content

Webhooks

Get every new till sale pushed to your system as a signed HTTP POST, and verify the signature.

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:

  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);

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 with GET /pos-tickets-by-date to 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 3xx answer 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.

Type to search…

↑↓ navigate↵ selectEsc close