---
title: "Webhooks"
description: "Get every new till sale pushed to your system as a signed HTTP POST, and verify the signature."
---

> 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.

# Webhooks

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](/core/endpoints/#the-receipt-fields), 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

```http
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...
```

```json
{
  "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.

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

## 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`](/core/endpoints/)
  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.

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