Order Partner API
Place phone orders, move the kitchen status, pause a location and receive signed events from a Frontspace restaurant store.
For a system that takes food orders for a Frontspace restaurant (by phone, an AI agent, or your own channel), follows them through the kitchen, and is told about every change.
What you can do:
| Read the menu | Locations, opening hours, pause state, dishes, prices and option groups |
| Place orders | Priced by Frontspace, paid by the customer at pickup |
| Cancel | An unpaid order you placed |
| Move the kitchen status | Accepted → ready → picked up, or rejected |
| Pause and resume orders | When the kitchen is full, from your system |
| Be told | A signed webhook for every order event, plus a feed to catch up from |
Every endpoint, scope, event and error code in this guide is checked against our code on every change. If the API does something this guide does not say, tell us: it is a bug.
1. Getting access
You integrate as an app installed on each restaurant's store. Each installation has its own access token, which only ever sees that one store.
- We register your app. We set it up with the scopes in the table below and your URLs. You get
an
api_secret, which you use to verify install callbacks (§1.2). Store it as a secret on your side. - We install it on the Frontspace Teststore. This is your sandbox: a real store with a real menu, kitchen screen and till, where nothing reaches a real customer. Build and test everything here.
- The restaurant installs it. Installing is the restaurant's consent: it can uninstall at any time, which revokes your token at once.
1.1 Scopes
| Scope | Lets you |
|---|---|
read_orders |
Read orders and receive order events, without customer contact details |
read_customers |
Adds customer name, email, phone and address to orders and events |
read_food_menu |
Read locations, hours, pause state and the orderable menu |
create_orders |
Place phone orders and cancel the ones you placed |
write_merchant_managed_fulfillment_orders |
Move the kitchen status of the restaurant's orders |
write_food_locations |
Pause and resume orders for a location, and set its preparation time |
The restaurant can remove a scope at any time. Scopes are checked again when each webhook is sent, so removing one also stops the data on webhooks you are already subscribed to.
1.2 Receiving your access token
When a store installs your app, its token (fsapp_…) can reach you in two ways:
- Install callback (recommended). If you give us an
install_callback_url, wePOSTthe token to it, signed. Nobody has to copy or paste a credential. - Shown once in the admin. Whoever clicks Install sees the token once, and hands it over to you.
The callback body:
{
"event": "installed",
"app_id": "<uuid>",
"store_id": "<uuid>",
"access_token": "fsapp_…",
"granted_scopes": ["read_orders", "read_customers", "read_food_menu", "create_orders", "write_merchant_managed_fulfillment_orders", "write_food_locations"],
"timestamp": "2026-10-10T10:00:00.000Z"
}event is installed, uninstalled (no token; stop calling for that store) or settings_updated.
Verify every callback before trusting it:
X-Frontspace-Timestamp: <unix seconds>
X-Frontspace-Signature: hex( HMAC-SHA256( api_secret, "<timestamp>.<raw body>" ) )Reject a timestamp more than 5 minutes from your clock, and compare signatures in constant time.
⚠️ The callback is sent once, with a 10-second limit, and is not retried. If your endpoint is down when a store installs, that token is lost: tell us, and we reinstall to issue a new one. Store the token keyed by
store_id, and treat it like a password.
2. Calling the API
Base URL: https://admin.api.frontspace.se/api/v1
Auth: Authorization: Bearer fsapp_…
Version: Frontspace-Api-Version: 2026-10- Send
Frontspace-Api-Version: 2026-10on every call. On/ordersit selects the order shape described here; without it you get an older shape kept for existing plugins. An unknown version is a400 UNSUPPORTED_API_VERSION, never a silent fallback. - The store is decided by the token. No endpoint takes a store id.
- Rate limits (per app, per store): 60 reads and 120 writes a minute. Over that you get
429; back off and retry. - Request bodies: JSON, at most 1 MB. Unknown fields are refused with a
400rather than ignored, so a typo cannot silently drop data. - Money is a decimal string with two places, in the order's
currency("192.00"), so nothing rounds through a float. - Errors are JSON:
{ "error": "<human message>", "code": "<MACHINE_CODE>" }, sometimes withdetails. Branch oncode, never onerror. - IP allowlist (optional). Give us your fixed egress IPs and we lock the app to them; any other
address gets
403 IP_NOT_ALLOWED.
2.1 Idempotency
POST /orders and POST /orders/:id/fulfillment require an Idempotency-Key header: 1–255
visible ASCII characters, unique per action. Generate one per action (a UUID is fine) and reuse it
when you retry that same action.
| Situation | Answer |
|---|---|
| A repeat of a completed request | The first answer again, with Idempotent-Replayed: true |
| The same key with a different body | 422 IDEMPOTENCY_KEY_REUSED |
| The first request is still running | 409 IDEMPOTENCY_IN_PROGRESS; retry shortly |
A 5xx, 409 or 429 |
Not stored: retry with the same key |
Keys are kept for 24 hours. For orders, the key is also stored on the order itself, so one key can never place two orders, even after those 24 hours.
2.2 Auth errors
| HTTP | code |
Meaning |
|---|---|---|
| 401 | APP_AUTH_REQUIRED |
No Bearer fsapp_… header |
| 401 | INVALID_TOKEN |
Unknown or revoked token: the store has uninstalled you |
| 403 | INSTALLATION_INACTIVE / APP_INACTIVE |
The installation or app is switched off |
| 403 | IP_NOT_ALLOWED |
Called from an address outside your allowlist |
| 403 | SCOPE_REQUIRED |
Your installation lacks the scope this endpoint needs (INSUFFICIENT_SCOPE on /events when no scope allows any event) |
3. The menu and pausing orders
GET /food/locations — read_food_menu
Where the restaurant takes orders, and whether it does so right now.
{
"locations": [{
"id": "<uuid>",
"name": "Kungsgrillen Centrum",
"address": "…",
"phone": "…",
"timezone": "Europe/Stockholm",
"hours": { "mon": ["10:00", "21:00"], "tue": ["10:00", "21:00"] },
"open_now": true,
"closes_at": "21:00",
"opens_at": null,
"accepting_orders": true,
"paused_until": null,
"prep_minutes": 20
}]
}hoursuses the location's owntimezone. A missing day means closed.hours: nullmeans unknown, and then hours never block an order.accepting_orders: falsemeans the kitchen has paused orders from its screen. That includes yours.paused_untilsays when the pause ends by itself;nullmeans until someone resumes it.
GET /food/menu?location=<id>&channel=takeaway — read_food_menu
The orderable menu for one location and channel. channel is takeaway (the default) or dine_in.
location may be left out when the store has a single location.
{
"menu": {
"location_id": "<uuid>",
"channel": "takeaway",
"currency": "SEK",
"next_price_change_at": "2026-10-10T16:00:00.000Z",
"categories": [{ "id": "<uuid>", "name": "Burgare", "parent_id": null }],
"products": [{
"id": "<uuid>",
"name": "Cheeseburgare",
"price": "119.00",
"image_url": "https://…",
"vegetarian": false,
"dietary": [],
"category_ids": ["<uuid>"],
"option_groups": [{
"id": "<uuid>",
"name": "Välj dryck",
"kind": "choice",
"min_select": 1,
"max_select": 1,
"items": [{ "id": "<uuid>", "name": "Cola", "price_delta": "0.00", "max_quantity": 1, "default": false }]
}]
}]
}
}kind: "removal"groups list ingredients the customer may leave out, at no charge.- Re-read the menu at
next_price_change_at. Scheduled prices (for example an evening price) change the menu at that time. - A price-on-request dish is not on the menu: it cannot be ordered through the API. Whether a dish
can be sold right now is checked again when you place the order (
UNSELLABLE_ITEMS,OUT_OF_STOCK).
| HTTP | code |
Meaning |
|---|---|---|
| 404 | FOOD_NOT_ENABLED |
The store does not take food orders |
| 400 | LOCATION_REQUIRED |
The store has several locations; pick one (they are listed in the answer) |
| 404 | LOCATION_NOT_FOUND |
No such location in this store |
| 400 | INVALID_CHANNEL |
channel is not takeaway or dine_in |
PATCH /food/locations/:id — write_food_locations
Pause or resume orders, or change the preparation time. The field names are the ones
GET /food/locations returns.
{ "accepting_orders": false, "paused_until": "2026-10-10T18:30:00+02:00", "pause_reason": "Köket är fullt" }{ "accepting_orders": true }{ "prep_minutes": 25 }| Field | |
|---|---|
accepting_orders |
false pauses, true resumes. Resuming clears the end time and the reason |
paused_until |
When the pause ends by itself: in the future, at most 7 days ahead. Only with accepting_orders: false. Leave it out to pause until someone resumes |
pause_reason |
Shown to the restaurant's staff, at most 200 characters. Only with accepting_orders: false. It is never returned by the API |
prep_minutes |
The kitchen's usual preparation time, 1–240, or null |
The answer is the location, exactly as GET /food/locations shows it, and the fields you changed:
{ "location": { "id": "<uuid>", "accepting_orders": false, "paused_until": "2026-10-10T16:30:00.000Z", … }, "changed": ["accepting_orders", "paused_until", "pause_reason"] }It is the restaurant's one pause. It is the same switch the kitchen screen and the till use: while
paused, both web orders and phone orders are refused, and sales at the till go on. Whoever changed it
last, you or the restaurant's staff, decides. Subscribe to food_location.updated to hear about the
changes you did not make yourself.
Every field is checked before anything is written: one invalid field and nothing changes.
| HTTP | code |
Meaning |
|---|---|---|
| 400 | INVALID_FIELDS |
A value is invalid, or the field is unknown; details[] names each one, and writable lists what you may send |
| 403 | FIELD_NOT_WRITABLE |
A field your app may not change, such as the restaurant's own kitchen settings |
| 400 | INVALID_BODY |
The body is not a JSON object |
| 404 | LOCATION_NOT_FOUND |
No such location in this store |
| 404 | FOOD_NOT_ENABLED |
The store does not take food orders |
4. Placing an order
POST /orders — create_orders, Idempotency-Key required
{
"location_id": "<uuid>",
"channel": "takeaway",
"pickup_at": "2026-10-10T17:30:00+02:00",
"customer": { "name": "Anna", "phone": "+46701234567", "email": "anna@example.com" },
"note": "Ring på när den är klar",
"payment": { "method": "pay_at_pickup" },
"expected_total": "238.00",
"line_items": [
{ "product_id": "<uuid>", "quantity": 2,
"options": [{ "item_id": "<uuid>" }, { "item_id": "<uuid>", "quantity": 2 }] }
]
}| Field | |
|---|---|
location_id |
Optional when the store has one location |
channel |
takeaway (default) or dine_in |
pickup_at |
ISO 8601 with a timezone, at most 3 days ahead. Leave it out for as soon as possible |
customer |
name and phone are required. The phone is what the kitchen calls back on |
note |
At most 500 characters; printed for the kitchen |
payment.method |
Only pay_at_pickup. The customer pays at the restaurant's till |
expected_total |
Recommended. The total you told the customer. It is a check, never a price: if our total differs, nothing is placed and you get 409 PRICE_CHANGED |
line_items |
1–50 lines. Each has quantity 1–50 and options with item_ids from that dish's option_groups |
Frontspace prices the order. You never send a price. We check every line against the menu, the location's hours and pause state, stock, and the dish's option rules.
201 for a new order, 200 when the key already placed one:
{
"order": {
"id": "<uuid>",
"number": "1058",
"status": "processing",
"payment": { "method": "pay_at_pickup", "status": "pending" },
"currency": "SEK",
"subtotal": "190.40",
"tax": "47.60",
"total": "238.00",
"location_id": "<uuid>",
"channel": "takeaway",
"pickup_at": "2026-10-10T15:30:00.000Z",
"created_at": "2026-10-10T15:02:11.000Z",
"line_items": [{
"id": "<uuid>", "product_id": "<uuid>", "name": "Cheeseburgare", "quantity": 2,
"unit_price": "119.00", "total": "238.00",
"options": [{ "item_id": "<uuid>", "name": "Cola", "quantity": 1 }]
}]
}
}This answer leaves out the customer's details on purpose, because it is stored for idempotency.
Read the full order with GET /orders/:id.
The order goes straight to the restaurant's kitchen screen, with the caller's name and phone. In
the order API its channel is phone.
| HTTP | code |
What to do |
|---|---|---|
| 400 | INVALID_BODY |
Fix the request (error has field-level detail) |
| 422 | INVALID_LINE_ITEMS |
A line does not match the menu; details[] names the line and reason (not_on_menu, option_not_on_dish, duplicate_option, at_most_<n>, requires_at_least_<n>, allows_at_most_<n>) |
| 422 | UNSELLABLE_ITEMS |
A dish cannot be sold right now; details[] names it |
| 422 | INVALID_OPTIONS |
The options break a dish's rules |
| 409 | OUT_OF_STOCK |
Sold out since you read the menu |
| 409 | PRICE_CHANGED |
The total is not expected_total; re-read the menu and tell the customer the new total |
| 409 | LOCATION_CLOSED |
Closed now, or at pickup_at |
| 409 | LOCATION_PAUSED |
The kitchen has paused orders |
| 409 | LOCATION_INACTIVE |
The location does not take orders |
| 422 | PICKUP_IN_PAST / PICKUP_TOO_FAR |
pickup_at is in the past, or more than 3 days ahead |
| 404 | FOOD_NOT_ENABLED / LOCATION_NOT_FOUND |
As for the menu |
| 400 | LOCATION_REQUIRED |
Name a location_id |
| 503 | MENU_UNAVAILABLE / UNAVAILABLE |
Retry with the same Idempotency-Key |
| 500 | INTERNAL |
Retry with the same Idempotency-Key |
⭐ After a timeout or a
5xx, always retry with the same key. The order may have been placed: the retry then returns that order, and never places a second one.
POST /orders/:id/cancel — create_orders
Cancels an order you placed that is not yet paid: the caller rang back, or never came. No body and no idempotency key; cancelling twice is safe.
{ "order": { "id": "<uuid>", "number": "1058", "status": "cancelled" }, "changed": true }The second time, changed is false. The order leaves the kitchen screen, and its stock goes back.
| HTTP | code |
Meaning |
|---|---|---|
| 404 | ORDER_NOT_FOUND |
Not an order your app placed in this store |
| 409 | ORDER_NOT_CANCELLABLE |
Already paid, or past the stage where you may cancel. A paid order is refunded by the restaurant |
| 409 | ORDER_BEING_PAID |
The till is taking payment for it right now |
| 409 | ORDER_CHANGED |
It changed while you cancelled; read it again |
5. Reading orders
GET /orders — read_orders
All the store's orders, not only yours, oldest first.
| Query | |
|---|---|
limit |
1–100, default 50 |
cursor |
next_cursor from the previous page |
created_after |
ISO 8601 with a timezone |
status |
One of pending, paid, confirmed, processing, shipped, delivered, completed, cancelled, refunded |
include |
food adds the kitchen's own state (below) |
GET /orders/:id — read_orders
{ "order": { … } }, in the shape below. Takes include as well.
The order (2026-10)
{
"id": "<uuid>",
"api_version": "2026-10",
"order_number": "1058",
"created_at": "2026-10-10T15:02:11.000Z",
"updated_at": "2026-10-10T15:20:40.000Z",
"status": "processing",
"financial_status": "pending",
"cancelled_at": null,
"channel": "phone",
"currency": "SEK",
"subtotal": "238.00",
"discount_total": "0.00",
"shipping_amount": "0.00",
"tax_amount": "47.60",
"tip_amount": "0.00",
"total_amount": "238.00",
"refunded_amount": "0.00",
"note": "Ring på när den är klar",
"fulfillment": {
"type": "pickup",
"status": "in_progress",
"location_id": "<uuid>",
"pickup_at": "2026-10-10T15:30:00.000Z",
"service": { "handle": "kitchen", "name": "Kök" }
},
"line_items": [{
"id": "<uuid>", "product_id": "<uuid>", "variant_id": null, "sku": null,
"name": "Cheeseburgare", "quantity": 2,
"unit_price": "119.00", "total_price": "238.00", "discount_amount": "0.00",
"options": [{ "group_id": "<uuid>", "group_name": "Välj dryck", "item_id": "<uuid>", "item_name": "Cola" }],
"parent_line_id": null
}],
"extensions": {
"food": { "kitchen_status": "preparing", "kitchen_note": null, "dining": "takeaway" }
},
"customer": { "id": null, "first_name": "Anna", "last_name": null, "email": "anna@example.com", "phone": "+46701234567" },
"shipping_address": null
}channelisonline,pos,kioskorphone(orders placed through this API).financial_statusbecomespaidwhen the customer pays at the till; you get anorder.updatedevent when that happens.fulfillment.statusis the general progress:unfulfilled,in_progress,ready,fulfilledorcancelled.extensions.foodis present only withinclude=food.kitchen_statusispending,new,preparing,ready,picked_uporrejected.customerandshipping_addressare present only withread_customers.- New fields may be added within
2026-10; ignore fields you do not know. Nothing is removed or changed within a version.
6. Moving the kitchen status
POST /orders/:id/fulfillment — write_merchant_managed_fulfillment_orders, Idempotency-Key required
{ "status": "in_progress", "extensions": { "food": { "prep_minutes": 20 } } }status |
In the kitchen | Notes |
|---|---|---|
in_progress |
Accepted, being prepared | Only accepting takes extensions.food.prep_minutes (1–240) |
ready |
Ready for pickup | The order must be accepted first (409 NOT_ACCEPTED) |
fulfilled |
Picked up | |
cancelled |
Rejected | Only a rejection takes a note (at most 200 characters), the reason shown to staff |
{
"order_id": "<uuid>",
"fulfillment": { "status": "in_progress", "service": { "handle": "kitchen", "name": "Kök" }, "changed": true }
}Repeating the current status answers 200 with changed: false.
The restaurant's own kitchen screen moves the same orders. If both move an order at once, one of
you gets 409 CONFLICT: read the order and decide again.
| HTTP | code |
Meaning |
|---|---|---|
| 404 | ORDER_NOT_FOUND |
No such order in this store |
| 409 | INVALID_TRANSITION |
Not a move the kitchen can make from where the order is |
| 409 | NOT_ACCEPTED |
Move it to in_progress first |
| 409 | NOT_IN_KITCHEN |
The order is no longer in the kitchen's queue |
| 409 | CONFLICT |
It moved while you asked; read it and retry |
| 403 | FULFILLMENT_NOT_PERMITTED |
Your app may not move orders fulfilled by this service |
| 409 | NO_FULFILLMENT_SERVICE |
Nothing fulfils this order (for example, a shipped web order) |
| 422 | INVALID_PREP_MINUTES / PREP_ONLY_WHEN_ACCEPTING |
prep_minutes is outside 1–240, or sent on anything but accepting |
| 422 | NOTE_ONLY_WHEN_CANCELLED / NOTE_TOO_LONG |
note on anything but a rejection, or over 200 characters |
| 400 | UNKNOWN_EXTENSION / UNKNOWN_EXTENSION_FIELD |
Only extensions.food.prep_minutes is accepted |
7. Being told: webhooks
POST /webhooks
One subscription per app per store.
{
"events": ["order.created", "order.updated", "order.cancelled", "order.refunded", "order.fulfillment_updated", "food_location.updated"],
"target_url": "https://partner.example.com/frontspace/webhooks",
"include": ["food"]
}201: { "webhook": { "id": "<uuid>", … }, "secret": "whsec_…" }
⚠️ The secret is shown once, in this answer only. Store it as a secret. If you lose it, rotate.
| Endpoint | |
|---|---|
GET /webhooks |
Your subscription (never its secret), delivery health, and the events and includes you may use |
PUT /webhooks/:id |
Change events, include or target_url, or pause it with is_active: false |
POST /webhooks/:id/rotate-secret |
A new secret now. The old one keeps working for 24 hours, and during that time each delivery is signed with both |
DELETE /webhooks/:id |
Stop deliveries |
target_url must be https and publicly reachable. We do not follow redirects.
Events
| Event | When |
|---|---|
order.created |
An order is placed: online, at the till, at a kiosk, or by you |
order.updated |
Anything about it changed, payment included |
order.cancelled |
It was cancelled |
order.refunded |
Money was returned |
order.fulfillment_updated |
The kitchen status moved: by the kitchen screen, the till, or you |
food_location.updated |
A location changed: orders paused or resumed (by the kitchen screen, the till, the admin, you, or a timed pause running out), preparation time, opening hours, name, address, phone, or active |
food_location.created |
The restaurant added a location |
Order events need read_orders; customer details in them need read_customers. Location events need
read_food_menu. A location event carries data.food_location, the location exactly as
GET /food/locations shows it, or null once it is no longer active:
{
"id": "<event id>",
"type": "food_location.updated",
"timestamp": "2026-10-10T16:00:00.000Z",
"data": { "sequence": 48230, "changed": ["accepting_orders", "paused_until"], "food_location": { "id": "<uuid>", "accepting_orders": true, "paused_until": null, … } }
}A timed pause is announced within about a minute of its paused_until.
The delivery
POST <target_url>
Content-Type: application/json
webhook-id: <event id>
webhook-timestamp: <unix seconds>
webhook-signature: v1,<base64 signature>{
"id": "<event id>",
"type": "order.fulfillment_updated",
"timestamp": "2026-10-10T15:20:40.000Z",
"data": {
"sequence": 48211,
"changed": ["fulfillment.status", "extensions.food.kitchen_status"],
"order": { … the order, exactly as in §5 … }
}
}data.orderis the order's current state when we send, not a snapshot from when the event happened. If events reach you out of order, keep the one with the highestdata.sequence.data.changedlists what changed, using the paths of the order shape.data.orderisnullif the order can no longer be read.
Verifying the signature
We follow Standard Webhooks. Use their library for your language, or do this:
- Take the bytes of the secret after
whsec_, base64-decoded. That is the HMAC key. - Compute
base64( HMAC-SHA256( key, "<webhook-id>.<webhook-timestamp>.<raw body>" ) ). webhook-signatureholds one or more space-separatedv1,<signature>values. Accept the request if any of them matches, comparing in constant time. There are two during a rotation.- Reject a
webhook-timestampmore than 5 minutes from your clock.
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(secret: string, headers: Headers, rawBody: string): boolean {
const id = headers.get('webhook-id') ?? ''
const ts = headers.get('webhook-timestamp') ?? ''
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
const expected = Buffer.from(createHmac('sha256', key).update(`${id}.${ts}.${rawBody}`).digest('base64'))
return (headers.get('webhook-signature') ?? '').split(' ').some((part) => {
const sig = Buffer.from(part.replace(/^v1,/, ''))
return sig.length === expected.length && timingSafeEqual(sig, expected)
})
}Verify against the raw body, before any JSON parsing.
Answering, retries and duplicates
- Answer
2xxwithin 10 seconds. Do the work afterwards, from a queue on your side. - Anything else, or a timeout, is retried: 8 attempts in all, the wait doubling from 30 seconds, so about an hour from first to last. A redirect is a failure and is not retried.
- After 20 failures in a row, with no success in the last 24 hours, deliveries pause for 24 hours.
GET /webhooksshowsdelivery.paused_untiland the last error. - The same event can arrive more than once. Deduplicate on
webhook-id.
8. Catching up: the event feed
GET /events
Every event you could have received in the last 14 days, in order: the types your scopes allow
(read_orders for order events). These are the same envelopes,
with the same ids, that webhooks deliver. Use it to recover after an outage longer than the
retries (about an hour), after a fresh install, or as your only channel if you would rather poll.
| Query | |
|---|---|
cursor |
next_cursor from the previous page. Leave it out to start at the beginning of the 14 days |
limit |
1–100, default 50 |
types |
A comma-separated list of event types. Default: every type your scopes allow |
include |
food, as on /orders |
{ "events": [ { "id": "…", "type": "order.created", "timestamp": "…", "data": { … } } ], "next_cursor": "…", "has_more": false }- Save
next_cursorafter you have processed a page, and resume from it. - The newest ~30 seconds are held back until they are settled, so a page never skips an event that commits late.
410 CURSOR_EXPIRED: your cursor is older than 14 days. Re-read the orders you care about withGET /orders, then start the feed again without a cursor.
9. One phone order, end to end
- Before the call:
GET /food/locations. Checkaccepting_ordersandopen_now(or the hours at the requested pickup time). If your system decides the kitchen is full, pause it withPATCH /food/locations/:id, and resume it the same way. GET /food/menu?location=…(cache it untilnext_price_change_at).- Place it:
POST /orderswith a freshIdempotency-Keyandexpected_total. On409 PRICE_CHANGED,OUT_OF_STOCKorLOCATION_PAUSED, tell the caller. - The order appears on the kitchen screen. You receive
order.created. - The kitchen accepts it. If your dashboard is where staff work,
POST /orders/:id/fulfillmentwithin_progressandprep_minutes. If the restaurant's own screen is used, you receiveorder.fulfillment_updated. ready, thenfulfilledwhen it is picked up. Each step comes back to you asorder.fulfillment_updated.- Payment happens at the till. You receive
order.updatedwithfinancial_status: "paid". - The caller cancels:
POST /orders/:id/cancel, as long as the order is unpaid.
10. Before going live
On the Teststore, show us:
- Install callbacks are verified, and the token is stored per store.
- An order placed through
POST /ordersappears on the kitchen screen. - A retry with the same
Idempotency-Keyreturns the same order. PRICE_CHANGEDandLOCATION_PAUSEDare handled with the caller.- A kitchen status change made on our screen reaches your webhook, with the signature verified.
- A status change from your side shows on our screen.
- A cancellation through the API removes the order from the kitchen.
- A pause from your system refuses new orders, and a resume lets them through again.
- Your endpoint deduplicates on
webhook-id, and you can resume from the feed.
The restaurant then installs your app, and you receive its token.
We also need a data processing agreement in place before you receive real customers' names and phone numbers.
