For whoever connects a till system (POS) to Tavoloo. The reference below mirrors the OpenAPI contract.
Loading the reference…
Verify Tavoloo-Signature against the raw body bytes before parsing, compare in constant time, reject a t more than 300 seconds from your clock, and accept the request if any v1 matches. Answer a non-2xx status (for example 401) when it does not verify: Tavoloo retries.
import crypto from 'node:crypto';
import express from 'express';
const SECRET = process.env.TAVOLOO_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SEC = 300;
export function verifyTavolooSignature(secret, header, rawBody, now = Math.floor(Date.now() / 1000)) {
if (!secret || !header) return false;
let t = null;
const sigs = [];
for (const part of header.split(',')) {
const i = part.indexOf('=');
if (i < 0) continue;
const k = part.slice(0, i).trim();
const v = part.slice(i + 1).trim();
if (k === 't') t = v;
else if (k === 'v1') sigs.push(v);
}
if (!t || !/^\d{1,12}$/.test(t) || Math.abs(now - Number(t)) > TOLERANCE_SEC) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
return sigs.some(
(s) => /^[0-9a-f]{64}$/.test(s) && crypto.timingSafeEqual(Buffer.from(s, 'hex'), expected),
);
}
const app = express();
const seen = new Set(); // in production: a table or Redis keyed by (connection, event id)
// express.raw hands over the body as a Buffer: the exact bytes Tavoloo signed.
app.post('/tavoloo/webhook', express.raw({ type: 'application/json', limit: '512kb' }), (req, res) => {
if (!verifyTavolooSignature(SECRET, req.get('Tavoloo-Signature'), req.body)) {
return res.sendStatus(401); // non-2xx: Tavoloo retries
}
const event = JSON.parse(req.body.toString('utf8'));
if (seen.has(event.id)) return res.sendStatus(200); // duplicate: already handled
seen.add(event.id);
switch (event.type) {
case 'order.created':
// send event.data to the kitchen or bar printer
break;
case 'order.updated':
case 'waiter_call.created':
case 'waiter_call.updated':
case 'table_session.opened':
case 'table_session.closed':
case 'integration.test':
break;
default:
break; // new type: ignore it, do not fail
}
return res.sendStatus(200);
});
app.listen(3000);<?php
const TAVOLOO_TOLERANCE_SEC = 300;
function verify_tavoloo_signature(string $secret, ?string $header, string $rawBody, int $tolerance = TAVOLOO_TOLERANCE_SEC): bool
{
if ($secret === '' || $header === null || $header === '') {
return false;
}
$t = null;
$sigs = [];
foreach (explode(',', $header) as $part) {
$pos = strpos($part, '=');
if ($pos === false) {
continue;
}
$k = trim(substr($part, 0, $pos));
$v = trim(substr($part, $pos + 1));
if ($k === 't') {
$t = $v;
} elseif ($k === 'v1') {
$sigs[] = $v;
}
}
if ($t === null || !preg_match('/^\d{1,12}$/', $t) || abs(time() - (int) $t) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
foreach ($sigs as $s) {
if (hash_equals($expected, $s)) {
return true;
}
}
return false;
}
$rawBody = file_get_contents('php://input'); // exact bytes, before json_decode
$header = $_SERVER['HTTP_TAVOLOO_SIGNATURE'] ?? null; // the Tavoloo-Signature header
$secret = getenv('TAVOLOO_WEBHOOK_SECRET') ?: ''; // whsec_...
if (!verify_tavoloo_signature($secret, $header, $rawBody)) {
http_response_code(401); // non-2xx: Tavoloo retries
exit;
}
$event = json_decode($rawBody, true);
// de-duplicate on $event['id'] (for example an INSERT with a unique key), then:
// if ($event['type'] === 'order.created') { /* print in the kitchen */ }
http_response_code(200);