Skip to content
Get API key

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

NameTypeDescription
eventstringThe event name, for example booking.requested.
delivered_attimestampWhen this attempt was sent (it changes on a retry).
dataobjectThe order, booking or reservation; see Payloads below.

Headers

NameTypeRequiredDescription
X-Sansflow-EventstringOptionalThe event name, the same as the body’s event.
X-Sansflow-SignaturestringOptionalsha256= followed by the hex HMAC-SHA256 of the raw body, keyed with the endpoint’s signing secret.
X-Sansflow-DeliveryuuidOptionalThe delivery’s id. It stays the same across retries: use it to drop duplicates.
X-Sansflow-AttemptintegerOptional1 for the first try, up to 6.
X-Sansflow-TeststringOptional1 on a test delivery from Send test. Absent otherwise.
User-AgentstringOptionalSansflow-Webhooks/1.0
Content-TypestringOptionalapplication/json
Delivery body
{
  "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

EventFires whendata
order.pending_confirmationAn order created through the API is submitted.Order
order.acceptedThe venue accepts it (or the outlet accepts it automatically; then both events fire, in this order).Order
order.readyIts bill is marked ready.Order
order.completedIts bill is closed (payment collected).Order
order.cancelledThe venue rejects it, it expires unaccepted, or its bill is cancelled or voided.Order

Bookings

EventFires whendata
booking.requestedA guest submits a booking on the public booking page.Booking
booking.confirmedStaff or the API confirm it; reservation_id and reservation_code are set.Booking
booking.rejectedStaff or the API reject it; rejection_reason says why.Booking
booking.expiredA pending_payment request passes the outlet’s expiry window.Booking

Reservations

EventFires whendata
reservation.createdA reservation is written, from a confirmed booking or typed in by staff.Reservation
reservation.updatedTime, duration, party size, table, guest name or phone, or notes change, or it returns to booked.Reservation
reservation.seatedThe party is seated.Reservation
reservation.completedThe visit is closed out.Reservation
reservation.cancelledIt is cancelled.Reservation
reservation.no_showThe 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.

order.pending_confirmation
{
  "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.

booking.requested
{
  "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.

reservation.created
{
  "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.

  1. Read the body as bytes, before any JSON parsing; re-serialised JSON will not match.
  2. Compute the HMAC with your secret and compare it to the header in constant time.
  3. Answer 401 on a mismatch, otherwise 2xx within 5 seconds and do the work afterwards.
  4. Drop a delivery whose X-Sansflow-Delivery you have already handled.
Verify
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
});

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:

  1. 1at once
  2. 2+1 min
  3. 3+5 min
  4. 4+30 min
  5. 5+2 h
  6. 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.

Test delivery

Headers: X-Sansflow-Event: order.accepted · X-Sansflow-Test: 1 · X-Sansflow-Attempt: 1

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