Sahtein · Developer Docs
POS integration
Connect a POS, a thermal-printer bridge, or a no-code tool (Make, Zapier, n8n) to a Sahtein restaurant: we push signed webhooks for every order event, and you push statuses, sold-out flags and menu reads back through the REST API.
1. Overview
The integration is two one-way channels sharing one credential. The restaurant issues an API key from Dashboard → Settings → Integrations, and registers your webhook receiver there too.
| Direction | Mechanism | Used for |
|---|---|---|
| Sahtein → you | Webhooks (signed POSTs) | New orders, status changes, cancellations, sold-out flags |
| You → Sahtein | REST API | Advancing order status from the kitchen, marking items sold out, reading the menu |
Start from the edges: register a webhook and confirm you receive order.created, then add the status PATCH. Base URL: https://api.sahtein.app/v1 (self-hosted installations use their own host).
2. Authentication
Every API call carries the key in an Authorization header:
Authorization: Bearer shk_<12-char prefix>_<48-char secret>
The full token is shown once, at creation — we store only a hash. A key carries scopes chosen at creation (orders:read, orders:write, menu:read, menu:write); calls outside them return 403. Keys are server-to-server only — never ship one in a browser or a mobile app. If a key leaks, revoke it in the dashboard; revocation is immediate.
3. Error format
Every error is JSON with a stable code:
{
"error": {
"code": "invalid_transition",
"message": "Invalid transition: ready → accepted",
"requestId": "..."
}
}| HTTP | code | When |
|---|---|---|
| 401 | unauthorized | Missing, malformed, revoked or expired key |
| 403 | forbidden | Key lacks the required scope |
| 404 | not_found | Resource does not exist in your restaurant |
| 409 | invalid_transition | Status move the state machine forbids |
| 422 | validation_error | Request body fails validation |
| 429 | rate_limited | Over the limits — see Rate limits |
4. Webhooks (outbound)
When something happens we POST JSON to your URL. Respond 2xx within 8 seconds; anything else is retried on the schedule below. Heavy processing belongs on your own queue — acknowledge first, work later.
Envelope and headers
POST <your-url>
Content-Type: application/json
X-Sahtein-Event: order.created
X-Sahtein-Delivery: <uuid — use as your dedup key>
X-Sahtein-Signature: t=<unix seconds>,v1=<hex hmac>
{
"id": "<delivery uuid>",
"event": "order.created",
"created_at": "2026-08-18T10:30:45.123Z",
"tenant_id": "<restaurant uuid>",
"data": { ...event-specific payload... }
}Verifying the signature
The signature is HMAC-SHA256 over `${t}.${rawBody}` with the endpoint secret shown when the endpoint was registered. Verify against the raw request body, before any JSON parsing:
const crypto = require("crypto");
function verify(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => p.split("="))
);
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected), Buffer.from(parts.v1)
);
}Reject unverified payloads with 401 and do not process them. Retries mint a new delivery id per attempt, so dedup on X-Sahtein-Delivery and you will never double-process.
Events
| Event | Fires when | Key payload fields |
|---|---|---|
order.created | An order is placed (any channel: web, app, kiosk, table QR) | order_id, number, total, items[], customer |
order.status_changed | Every fulfillment transition except cancellation | order_id, from, to, changed_at |
order.cancelled | An order is cancelled by staff | order_id, reason |
menu.availability_changed | An item is marked sold out / back in stock | product_id, available |
Money fields are integer agorot (₪108.50 = 10850). Item and product names are localized objects ({"ar": "...", "he": "...", "en": "..."}) — pick the language your screen prints.
Retry schedule
| Attempt | Delay after failure |
|---|---|
| 1 | immediate |
| 2 | +1 minute |
| 3 | +5 minutes |
| 4 | +15 minutes |
| 5 | +1 hour |
| 6 | +6 hours |
After six failures the delivery is marked abandoned. Nothing is deleted — the restaurant sees the full history in the dashboard and can retry an abandoned delivery manually. Webhooks are emitted transactionally with the change they describe: if you receive an event, a GET for that order will always succeed.
5. Orders API
GET /partner/v1/orders?status=active&page=1&per_page=30
GET /partner/v1/orders/{id}
PATCH /partner/v1/orders/{id}/status { "status": "in_preparation" }status=active returns everything in flight. The PATCH drives the same state machine as the restaurant's own console and kitchen screen — your update moves the customer's live tracking page, prints tickets, and the console in one stroke. Setting accepted performs full acceptance: kitchen tickets are routed and print jobs queued exactly as if staff had tapped Accept.
| You may set | Notes |
|---|---|
accepted | Full acceptance — tickets + prints + delivery job |
in_preparation | Kitchen started |
ready | All stations done; clears the KDS lanes |
picked_up · out_for_delivery · delivered · completed | Hand-off and completion |
Cancellation and refunds are staff-only (they touch money) and return 409 from this API. Illegal moves — e.g. delivered straight from accepted — return 409 invalid_transition.
Loop warning: your own PATCH triggers an order.status_changed webhook back at you. Dedup on the delivery id and never react to a transition you caused.
7. Rate limits
600 requests/minute per key, plus a burst shield of 40 requests/second. Both are far above what a synchronized POS needs — approaching either usually means an event loop reacting to itself. Over a limit you get 429 with Retry-After; successful partner responses carry X-RateLimit-Limit / X-RateLimit-Remaining / X-RateLimit-Reset for the per-minute budget.
8. Integration checklist
- Get an API key from the restaurant (Settings → Integrations); store it in a vault, never in client code.
- Smoke-test with
GET /partner/v1/orders?per_page=1. - Register your webhook endpoint; verify the HMAC against the raw body; dedup on
X-Sahtein-Delivery. - Acknowledge within 8 seconds; queue heavy work on your side.
- PATCH
/orders/{id}/statusat each kitchen step; ignore the webhook echoes of your own transitions. - Optional: pull
/menudaily and push sold-out flags as stock runs out.
Questions: [email protected] — include the X-Sahtein-Delivery id or order_id you're asking about.