Concepts
Webhooks
Instead of polling, let Sansflow call you. When an order, booking or reservation changes, Sansflow POSTs a signed JSON event to your HTTPS endpoint within about a minute.
Setting up an endpoint
- Add the endpoint in Sansflow An owner or manager opens Developers → Webhooks, enters your HTTPS URL and ticks the events it should receive.
- Copy the signing secret It is shown once, at creation. Store it on your server as
SANSFLOW_WEBHOOK_SECRET. - Verify every delivery Check the signature before you trust the body (below), answer
2xxwithin 5 seconds, and do the slow work afterwards.
The payload
Every delivery is a POST with a JSON body of three fields: event (the event name), delivered_at (the time of this attempt) and data (the object, in the same shape the API returns it).
POST /webhooks/sansflow HTTP/1.1
Content-Type: application/json
X-Sansflow-Event: booking.requested
X-Sansflow-Signature: sha256=5d41402abc4b2a76b9719d911017c592…
X-Sansflow-Delivery: 9a3c…
X-Sansflow-Attempt: 1
{
"event": "booking.requested",
"delivered_at": "2026-09-18T03:12:51+00:00",
"data": {
"id": "…",
"status": "awaiting_confirmation",
"short_code": "7K2QM4X9PD3R",
"customer_name": "Budi",
"party_size": 4,
"requested_at": "2026-09-20T19:00:00+00:00",
"location_id": "…"
}
}| Header | Meaning |
|---|---|
X-Sansflow-Event | The event name, same as event in the body. |
X-Sansflow-Signature | sha256= followed by the hex HMAC-SHA256 of the raw body, keyed with your endpoint's secret. |
X-Sansflow-Delivery | A unique id for this delivery. De-duplicate on it. |
X-Sansflow-Attempt | 1 for the first try, then counting up on retries. |
X-Sansflow-Test | 1 on a delivery sent with the Send test button. |
Verifying the signature
Compute the HMAC over the raw request body, before any JSON parsing, and compare it to the header in constant time. Reject anything that doesn't match.
import crypto from "node:crypto";
import express from "express";
const app = express();
// Verify against the RAW body: parsing and re-serialising JSON changes the bytes.
app.post("/webhooks/sansflow", express.raw({ type: "*/*" }), (req, res) => {
const raw = req.body; // Buffer
const expected = "sha256=" + crypto
.createHmac("sha256", process.env.SANSFLOW_WEBHOOK_SECRET)
.update(raw)
.digest("hex");
const got = req.get("X-Sansflow-Signature") || "";
const ok = got.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
if (!ok) return res.status(401).send("invalid signature");
const evt = JSON.parse(raw.toString("utf8"));
// De-duplicate on X-Sansflow-Delivery, then hand off to a queue and answer fast.
console.log(req.get("X-Sansflow-Delivery"), evt.event, evt.data.id);
res.sendStatus(200);
});import hashlib, hmac, json, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["SANSFLOW_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/sansflow")
def sansflow_webhook():
raw = request.get_data() # the raw bytes, before any JSON parsing
expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("X-Sansflow-Signature", "")):
abort(401)
evt = json.loads(raw)
# De-duplicate on X-Sansflow-Delivery, then hand off to a queue and answer fast.
print(request.headers.get("X-Sansflow-Delivery"), evt["event"], evt["data"]["id"])
return "", 200<?php
$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_SANSFLOW_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $raw, getenv('SANSFLOW_WEBHOOK_SECRET'));
if (!hash_equals($expected, $sig)) {
http_response_code(401);
exit('invalid signature');
}
$evt = json_decode($raw, true);
error_log($evt['event'] . ' ' . $evt['data']['id']);
http_response_code(200);
echo 'ok';Retries and delivery
- Answer with any
2xxwithin 5 seconds. Anything else, or no answer, is a failed attempt. - Failed attempts are retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours.
- After 8 consecutive failed deliveries the endpoint is switched off until someone switches it back on in Sansflow.
- Events are queued in the same transaction as the change and sent every minute, so expect up to about 60 seconds of delay.
- Each attempt is one POST, but a retry after a timeout can reach you after the first try did. Always de-duplicate on
X-Sansflow-Delivery. - Order isn't guaranteed across retries. Use the object's own status and timestamps, not arrival order, as the truth.
Events
| Family | Events | Fires for |
|---|---|---|
order.* | pending_confirmation, accepted, ready, completed, cancelled | Orders created through the API change state. |
booking.* | requested, confirmed, rejected, expired | Any booking request of the business, whoever made or decided it. |
reservation.* | created, updated, seated, completed, cancelled, no_show | Any reservation, from a confirmed booking or typed in by staff. |
A confirmed booking sends reservation.created then booking.confirmed, from the same transaction. Every event and its data is in the events reference.
Testing
Test orders made with an sf_test_ key never send webhooks. To test your endpoint, use Send test on the endpoint in Sansflow: it posts a signed order.accepted sample in the real shape, with X-Sansflow-Test: 1.
Stuck? Quote the request_id from the response and write toSansflow support.Checked against API v1 on .