API reference
Webhooks reference
Sansflow POSTs a signed JSON event to your HTTPS endpoint when an API order, a booking or a reservation changes. Fifteen events, one envelope, one signature.
Add an endpoint in the Sansflow app under Settings → API keys → Webhooks → Add endpoint(owner or manager): a name, an https:// URL and the events to subscribe to. The signing secret is shown once. For a walkthrough, see Webhooks in the docs.
The delivery
Every delivery is a POST with a JSON body of three fields:
Body
| Name | Type | Description |
|---|---|---|
event | string | The event name, for example booking.requested. |
delivered_at | timestamp | When this attempt was sent (it changes on a retry). |
data | object | The order, booking or reservation; see Payloads below. |
Headers
| Name | Type | Required | Description |
|---|---|---|---|
X-Sansflow-Event | string | Optional | The event name, the same as the body’s event. |
X-Sansflow-Signature | string | Optional | sha256= followed by the hex HMAC-SHA256 of the raw body, keyed with the endpoint’s signing secret. |
X-Sansflow-Delivery | uuid | Optional | The delivery’s id. It stays the same across retries: use it to drop duplicates. |
X-Sansflow-Attempt | integer | Optional | 1 for the first try, up to 6. |
X-Sansflow-Test | string | Optional | 1 on a test delivery from Send test. Absent otherwise. |
User-Agent | string | Optional | Sansflow-Webhooks/1.0 |
Content-Type | string | Optional | application/json |
{
"event": "booking.requested",
"delivered_at": "2026-11-08T03:06:41.512Z",
"data": {
"id": "e7a3c5d9-2b4f-4c6e-8d18-3f9a1b5c7e46",
"business_id": "7c9e1a3b-5d7f-4b9a-8c2e-4f6a8b0c2d91",
"location_id": "3f6c2a1e-8b4d-4c2a-9e51-7a0d2b6c9f10",
"short_code": "7K2QM4X9PD3R",
"status": "awaiting_confirmation",
"customer_name": "Budi",
"customer_phone": "+6281300000000",
"party_size": 4,
"requested_at": "2026-11-08T12:00:00+00:00",
"notes": "Window seat if possible",
"requested_table": {
"id": "0c2e4a6b-8d1f-4b3c-9e75-2a6c8e0b4d79",
"name": "T12"
},
"payment_proof_uploaded_at": null,
"preorder": null,
"rejection_reason": null,
"reservation_id": null,
"reservation_code": null,
"created_at": "2026-11-06T03:12:45+00:00",
"updated_at": "2026-11-06T03:12:45+00:00"
}
}Events
order.* events are sent for orders created through the API only, and never for test orders. booking.* and reservation.* events are sent for every booking and reservation of the business, whoever made it.
Orders
| Event | Fires when | data |
|---|---|---|
order.pending_confirmation | An order created through the API is submitted. | Order |
order.accepted | The venue accepts it (or the outlet accepts it automatically; then both events fire, in this order). | Order |
order.ready | Its bill is marked ready. | Order |
order.completed | Its bill is closed (payment collected). | Order |
order.cancelled | The venue rejects it, it expires unaccepted, or its bill is cancelled or voided. | Order |
Bookings
| Event | Fires when | data |
|---|---|---|
booking.requested | A guest submits a booking on the public booking page. | Booking |
booking.confirmed | Staff or the API confirm it; reservation_id and reservation_code are set. | Booking |
booking.rejected | Staff or the API reject it; rejection_reason says why. | Booking |
booking.expired | A pending_payment request passes the outlet’s expiry window. | Booking |
Reservations
| Event | Fires when | data |
|---|---|---|
reservation.created | A reservation is written, from a confirmed booking or typed in by staff. | Reservation |
reservation.updated | Time, duration, party size, table, guest name or phone, or notes change, or it returns to booked. | Reservation |
reservation.seated | The party is seated. | Reservation |
reservation.completed | The visit is closed out. | Reservation |
reservation.cancelled | It is cancelled. | Reservation |
reservation.no_show | The party does not arrive. | Reservation |
Endpoints created before 18 Sep 2026 receive the five order events only. To subscribe to booking and reservation events, add a new endpoint and retire the old one.
Payloads
Order
The order exactly as Retrieve an order returns it, without request_id. Only orders created through the API send order.* events.
See Retrieve an order for every field and the status values.
{
"event": "order.pending_confirmation",
"delivered_at": "2026-11-08T03:06:41.512Z",
"data": {
"id": "a9c1e3f5-7b2d-4f8a-9e63-5c0b4d6a8f57",
"status": "accepted",
"queue_number": "K-14",
"external_ref": "shop_order_1234",
"order_type": "dine_in",
"table_ref": "T-4",
"is_test": false,
"totals": {
"gross": 60000
},
"items": [
{
"product_id": "f1b3d5a7-9c2e-4a6b-8d14-7e3c5a9b1d68",
"product_name": "Iced",
"variant_id": "6e8a0c2d-4f1b-4d5e-8a96-0c2e4a6b8d81",
"variant_name": "Iced",
"quantity": 2,
"unit_price": 30000,
"base_price": 25000,
"modifiers": [
{
"modifier_id": "9b1d3f5a-7c2e-4a8b-8d49-3f5b7d9f1a03",
"modifier_name": "Extra shot",
"price": 3000,
"group_id": "8a0c2e4f-6b1d-4f7a-9c38-2e4a6c8e0f92"
}
],
"notes": "less sugar",
"line_total": 60000
}
],
"created_at": "2026-11-08T03:05:12.481+00:00",
"updated_at": "2026-11-08T03:06:40.102+00:00"
}
}Booking
The booking as Retrieve a booking returns it, without the nested reservation. booking.* events fire for every booking of the business, whoever made it.
See Retrieve a booking for every field.
{
"event": "booking.requested",
"delivered_at": "2026-11-08T03:06:41.512Z",
"data": {
"id": "e7a3c5d9-2b4f-4c6e-8d18-3f9a1b5c7e46",
"business_id": "7c9e1a3b-5d7f-4b9a-8c2e-4f6a8b0c2d91",
"location_id": "3f6c2a1e-8b4d-4c2a-9e51-7a0d2b6c9f10",
"short_code": "7K2QM4X9PD3R",
"status": "awaiting_confirmation",
"customer_name": "Budi",
"customer_phone": "+6281300000000",
"party_size": 4,
"requested_at": "2026-11-08T12:00:00+00:00",
"notes": "Window seat if possible",
"requested_table": {
"id": "0c2e4a6b-8d1f-4b3c-9e75-2a6c8e0b4d79",
"name": "T12"
},
"payment_proof_uploaded_at": null,
"preorder": null,
"rejection_reason": null,
"reservation_id": null,
"reservation_code": null,
"created_at": "2026-11-06T03:12:45+00:00",
"updated_at": "2026-11-06T03:12:45+00:00"
}
}Reservation
The reservation with business_id, table_id, booking_id and booking_short_code besides the List reservations fields. reservation.* events fire for every reservation of the business.
See List reservations for the shared fields.
{
"event": "reservation.created",
"delivered_at": "2026-11-08T03:06:41.512Z",
"data": {
"id": "d2f6b8a4-1c3e-4b5d-9a87-6e0f2c4b8d35",
"business_id": "7c9e1a3b-5d7f-4b9a-8c2e-4f6a8b0c2d91",
"code": "R-261108-003",
"status": "booked",
"customer_id": "c4e8a2f1-6b9d-4e3a-8f72-1d5c9b0a3e24",
"customer_name": "Budi",
"customer_phone": "+6281300000000",
"customer_email": null,
"party_size": 4,
"reserved_at": "2026-11-08T12:00:00+00:00",
"duration_minutes": 90,
"notes": null,
"location_id": "3f6c2a1e-8b4d-4c2a-9e51-7a0d2b6c9f10",
"table_id": "0c2e4a6b-8d1f-4b3c-9e75-2a6c8e0b4d79",
"table": "T12",
"booking_id": "e7a3c5d9-2b4f-4c6e-8d18-3f9a1b5c7e46",
"booking_short_code": "7K2QM4X9PD3R",
"created_at": "2026-11-06T03:20:10+00:00",
"updated_at": "2026-11-06T03:20:10+00:00"
}
}Verify the signature
X-Sansflow-Signature is sha256= plus the hex HMAC-SHA256 of the raw request body, keyed with your endpoint’s signing secret.
- Read the body as bytes, before any JSON parsing; re-serialised JSON will not match.
- Compute the HMAC with your secret and compare it to the header in constant time.
- Answer
401on a mismatch, otherwise2xxwithin 5 seconds and do the work afterwards. - Drop a delivery whose
X-Sansflow-Deliveryyou have already handled.
import crypto from "node:crypto";
import express from "express";
const app = express();
// Read the raw body: the signature is over the exact bytes Sansflow sent.
app.post("/webhooks/sansflow", express.raw({ type: "*/*" }), (req, res) => {
const raw = req.body; // Buffer
const given = req.get("X-Sansflow-Signature") || "";
const expected = "sha256=" + crypto
.createHmac("sha256", process.env.SANSFLOW_WEBHOOK_SECRET)
.update(raw)
.digest("hex");
const ok = given.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(given), 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.
console.log(req.get("X-Sansflow-Delivery"), evt.event, evt.data.id);
res.status(200).send("ok"); // within 5 seconds
});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():
raw = request.get_data() # the exact bytes Sansflow sent
expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
given = request.headers.get("X-Sansflow-Signature", "")
if not hmac.compare_digest(given, expected):
abort(401)
evt = json.loads(raw)
# De-duplicate on X-Sansflow-Delivery, then hand off to a queue.
print(request.headers.get("X-Sansflow-Delivery"), evt["event"], evt["data"]["id"])
return "ok", 200 # within 5 seconds<?php
$raw = file_get_contents('php://input');
$given = $_SERVER['HTTP_X_SANSFLOW_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $raw, getenv('SANSFLOW_WEBHOOK_SECRET'));
if (!hash_equals($expected, $given)) {
http_response_code(401);
exit('invalid signature');
}
$evt = json_decode($raw, true);
// De-duplicate on X-Sansflow-Delivery, then hand off to a queue.
error_log($_SERVER['HTTP_X_SANSFLOW_DELIVERY'] . ' ' . $evt['event'] . ' ' . $evt['data']['id']);
http_response_code(200);
echo 'ok';Delivery and retries
Events are queued in the same transaction as the change and sent within about a minute. A delivery succeeds on any 2xx answered within 5 seconds. Anything else, a timeout or a network error is retried on this schedule, six attempts over about 8.6 hours:
- 1at once
- 2+1 min
- 3+5 min
- 4+30 min
- 5+2 h
- 6+6 h
A delivery whose six attempts all fail is marked dead. After 8 dead deliveries in a row the endpoint is disabled; re-enable it in Webhooks once it answers again. Each endpoint’s Deliveries list shows every attempt with its status code.
One attempt is one POST, but a retry after a timeout can arrive after the first try did, and events are not guaranteed to arrive in order. De-duplicate on X-Sansflow-Delivery and read the current state with a GET when order matters.
Send a test delivery
Orders made with an sf_test_ key never send webhooks. To test your endpoint, press Send test next to it in Settings → API keys → Webhooks.
Sansflow signs and posts a sample order.accepted event in the real shape, with all-zero ids, is_test: true and the extra header X-Sansflow-Test: 1. The app then shows what your endpoint answered: the status code, the time it took, its response body, the signature it sent and the exact body, so you can check your verification against it.
Send test is in the app and needs an owner or manager login, not an API key.
Headers: X-Sansflow-Event: order.accepted · X-Sansflow-Test: 1 · X-Sansflow-Attempt: 1
POST https://your-endpoint.example/webhooks/sansflow
{
"event": "order.accepted",
"delivered_at": "2026-11-08T03:10:00.000Z",
"data": {
"id": "00000000-0000-0000-0000-000000000000",
"status": "accepted",
"queue_number": "TEST-001",
"external_ref": "sample_test",
"order_type": "dine_in",
"table_ref": "T-1",
"is_test": true,
"totals": {
"gross": 45000
},
"items": [
{
"product_id": "00000000-0000-0000-0000-000000000001",
"product_name": "Sample Item",
"variant_id": null,
"variant_name": null,
"quantity": 1,
"unit_price": 45000,
"base_price": 45000,
"modifiers": [],
"notes": null,
"line_total": 45000
}
],
"created_at": "2026-11-08T03:10:00.000Z",
"updated_at": "2026-11-08T03:10:00.000Z"
}
}Example values. Run replays this response here; nothing is sent.
Stuck? Quote the request_id from the response and write toSansflow support.Checked against API v1 on .