API v1 · Orders
FoodMato POS API: integration guide
Receive FoodMato orders in your POS and send their status back. One integration works for every FoodMato store linked to your account.
01How it works
200, then confirm it with /acknowledge./accept, /ready, /delivered… FoodMato informs the customer.Changes made on the FoodMato side reach you as events: order.cancelled for cancellations, and order.status_changed for everything else (for example a FoodMato driver picking up the order). Every order is also available through the API, so if a webhook fails, or if you cannot receive webhooks at all, you can poll for orders instead.
02What you receive
FoodMato sets up your account and gives you:
| Item | Example | Use |
|---|---|---|
| Client ID | fmci_test_0b64… | Identifies your API client when you request an access token. |
| Client secret | fmcs_82be… | Sent only to the token URL to get an access token. Shown once: store it securely on your server. |
| Webhook secret | fmws_8c1e… | Checking the signature of the events FoodMato sends you. |
| Store IDs | 6f1c2a9e-… per restaurant | The FoodMato store ID of each linked restaurant. Save it in your system for that restaurant: it comes with every order and event as store.id, and you use it in the API. GET /stores lists them. |
FoodMato needs from you: the HTTPS webhook URL for events (optional if you poll), a technical contact e-mail, the restaurants to link, and (recommended) the public IP addresses of your servers, so FoodMato can allow your client only from them. Your own ID per restaurant is optional: if you give one, it is returned as store.external_id next to store.id.
Environments
| Environment | Base URL | Client IDs |
|---|---|---|
| Test | https://api-staging.foodmato.com/pos/v1 | fmci_test_… |
| Live | https://api.foodmato.com/pos/v1 | fmci_live_… |
The token URL of each environment is the base URL followed by /oauth/token.
03Authentication
The API uses OAuth 2.0 client credentials (RFC 6749, section 4.4), so any OAuth library can handle the login. Exchange your client ID and secret for an access token, then send the token on every request. A token only sees the stores linked to your account and their orders.
1. Get an access token: send the client ID and secret as HTTP Basic auth (or as client_id / client_secret in the body).
curl -X POST https://api-staging.foodmato.com/pos/v1/oauth/token \
-u "$FOODMATO_CLIENT_ID:$FOODMATO_CLIENT_SECRET" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials"
{
"access_token": "fmat_…",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "stores:read orders:read orders:write"
}
2. Call the API with the token:
curl https://api-staging.foodmato.com/pos/v1/stores \
-H "Authorization: Bearer $FOODMATO_TOKEN"
- Reuse the token until it expires (
expires_in, 1 hour), then request a new one. Do not request a token for every call. There is no refresh token: the client credentials take its place. - On
401 invalid_token, request a new token once and repeat the call. - A client holds at most 5 valid tokens; requesting a sixth deletes the oldest. Servers that run separately should each get their own client from FoodMato.
scopeis optional. Without it the token gets every scope of your client; with it (for examplescope=orders:read) only those.- FoodMato can limit your client to your server IPs and can revoke it at any time; a revoked client's tokens stop working at once.
Scopes
| Scope | Allows |
|---|---|
stores:read | GET /stores, GET /stores/{id} |
orders:read | GET /orders, GET /orders/{id} |
orders:write | POST /orders/{id}/acknowledge and every status route |
The token URL answers errors in the OAuth format that OAuth libraries expect: { "error": "invalid_client", "error_description": "…" }. All other routes use the format below.
Response format
| Response | Shape |
|---|---|
| One resource | The object itself: an order or a store |
| List | { "data": [...], "pagination": { "limit", "has_more", "next_cursor" } } |
| Error | HTTP status + { "error": { "code", "message", "details" } } (see Errors) |
Every response has an X-Request-Id header. Quote it when you contact FoodMato about a request. Times are ISO 8601 with time zone; opening times are local times of the store (timezone).
{
"data": [
{
"id": "6f1c2a9e-…",
"external_id": "KH-STORE-12",
"name": "Pizza Roma",
"status": { "is_active": true, "is_open": true, "closed_until": null },
"contact_details": { "phone": "+4930123456", "email": "info@pizzaroma.example" },
"address": {
"street": "Torstrasse 1", "postal_code": "10119", "city": "Berlin", "state": null,
"country": "Germany", "country_code": "DE", "latitude": 52.529, "longitude": 13.401
},
"timezone": "Europe/Berlin",
"currency": "EUR",
"order_types": ["delivery", "pickup"],
"opening_times": [
{ "opening_day": "MONDAY", "opening_time": "11:00", "closing_day": "MONDAY", "closing_time": "22:00" },
{ "opening_day": "FRIDAY", "opening_time": "18:00", "closing_day": "SATURDAY", "closing_time": "02:00" }
],
"delivery_times": [
{ "opening_day": "MONDAY", "opening_time": "11:30", "closing_day": "MONDAY", "closing_time": "21:30" }
],
"order_settings": {
"prep_time_minutes": 15, "pickup_time_minutes": 30, "auto_accept": false, "delivered_by": "store",
"scheduled_orders": { "enabled": true, "max_days_ahead": 5 }
},
"is_test_mode": true
}
],
"pagination": { "limit": 1, "has_more": false, "next_cursor": null }
}
| Store field | Notes |
|---|---|
status.is_active | The store is active on FoodMato |
status.is_open | The store takes orders right now: active, switched on in FoodMato, not temporarily closed and inside its opening times |
status.closed_until | End of a temporary closure, or null |
opening_times | One entry per slot. A slot past midnight closes on the next day; 24:00 means until midnight. Closed days have no entry |
delivery_times | When the store delivers, in the same format |
order_settings.auto_accept | FoodMato accepts this store's orders automatically; you still receive them |
04Receiving orders (webhooks)
FoodMato sends a POST with a JSON body to your webhook URL for each event:
| Event | When | data |
|---|---|---|
order.created | The order is paid, or placed with cash on delivery | { "order": Order } |
order.cancelled | The order was cancelled or rejected in FoodMato (not sent when your POS did it) | { "order": { id, order_number, store, status, reason } } |
order.status_changed | The status changed in FoodMato, not through your POS: accepted in the FoodMato partner app, automatically ready, picked up or delivered by a FoodMato driver, completed automatically | { "order": { id, order_number, store, status, previous_status, estimated_pickup_at, changed_at } } |
healthcheck | FoodMato tests the connection | { "store": { id, external_id } } |
Content-Type: application/json
X-FoodMato-Event: order.created
X-FoodMato-Delivery-Id: 0c6b8f0e-5d0e-4b8f-9a55-2b8f8f0d7c11
X-FoodMato-Signature: t=1793620800,v1=5f2b0c…
{
"id": "0c6b8f0e-5d0e-4b8f-9a55-2b8f8f0d7c11",
"event": "order.created",
"api_version": "v1",
"created_at": "2026-11-02T12:00:00.000Z",
"data": { "order": { … } }
}
Verify the signature
X-FoodMato-Signature is t=<unix seconds>,v1=<signature>. The signature is the hex HMAC-SHA256 of <t>.<raw body>, keyed with your webhook secret. Compute it on the raw body exactly as received, before parsing the JSON. Reject the request if the signature does not match or if t is more than 5 minutes away from your clock.
const crypto = require("crypto");
function verifyFoodMatoSignature(rawBody, header, secret) {
const parts = Object.fromEntries((header || "").split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || !parts.v1 || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return expected.length === parts.v1.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
function verifyFoodMatoSignature(string $rawBody, string $header, string $secret): bool {
parse_str(str_replace(',', '&', $header), $p);
if (empty($p['t']) || empty($p['v1']) || abs(time() - (int) $p['t']) > 300) return false;
$expected = hash_hmac('sha256', $p['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, $p['v1']);
}
Reply and retries
- Reply with any
2xxwithin 10 seconds. Save the order first and process it afterwards. - Optionally reply with
{ "pos_order_id": "KH-104233" }; FoodMato stores it and returns it aspos_order_id. - On a timeout, a network error,
408,429or5xx, FoodMato retries twice (after 1 and 3 seconds). Other4xxreplies are not retried. - A retry has the same
X-FoodMato-Delivery-Idand a new signature. Use the delivery ID to ignore duplicates. - Events can arrive out of order: one change in FoodMato can cause two events within milliseconds (for example
acceptedandpreparing). For status events, apply only the event with the latestchanged_at, and never move an order back to an earlier status. When in doubt, GET the order.
After the retries, the webhook is not sent again. The order is still available through the API. Poll as described in the next section, at least every few minutes, even when you use webhooks.
05Polling
Use polling as a safety net next to webhooks, or on its own if your POS cannot receive webhooks.
- Every 30–60 seconds, call
GET /orders?acknowledged=false. Orders come oldest first. - Save each order, then call
POST /orders/{id}/acknowledge. It no longer appears in the next poll. - To pick up changes made in FoodMato, such as cancellations, call
GET /orders?updated_since=<time of your last poll>. - When
pagination.has_moreistrue, call again withcursor=<pagination.next_cursor>for the next page.
curl "https://api-staging.foodmato.com/pos/v1/orders?acknowledged=false&limit=50" \
-H "Authorization: Bearer $FOODMATO_TOKEN"
curl -X POST https://api-staging.foodmato.com/pos/v1/orders/{id}/acknowledge \
-H "Authorization: Bearer $FOODMATO_TOKEN" -H "Content-Type: application/json" \
-d '{ "pos_order_id": "KH-104233" }'
06Order status
Each step has its own route: POST /orders/{id}/<step>. FoodMato updates the order and notifies the customer. Every route takes an optional reason, shown in the order history.
The customer sees the reason of /reject and /cancel in the FoodMato apps and e-mails. Write it for the customer (for example "Item out of stock"), in plain words, without internal codes. Without a reason the customer sees "Rejected by the restaurant" or "Cancelled by the restaurant".
curl -X POST https://api-staging.foodmato.com/pos/v1/orders/{id}/accept \
-H "Authorization: Bearer $FOODMATO_TOKEN" -H "Content-Type: application/json" \
-d '{ "prep_time_minutes": 20 }'
curl -X POST https://api-staging.foodmato.com/pos/v1/orders/{id}/ready \
-H "Authorization: Bearer $FOODMATO_TOKEN"
| Route | Order status after | Meaning |
|---|---|---|
/accept | accepted | The store takes the order. Add prep_time_minutes (1–240) to set when it will be ready. |
/reject | rejected | The store does not take the order. Only while it is pending. Add a reason; the customer sees it. |
/preparing | preparing | The kitchen started. |
/ready | ready | Ready for the customer or the driver. |
/picked-up | picked_up | Handed over (pickup) or collected by the store's own driver. |
/out-for-delivery | out_for_delivery | The store's own driver is on the way. Delivery orders only. |
/delivered | delivered | The customer has the order. |
/cancel | cancelled | The order cannot be completed after it was accepted. Add a reason; the customer sees it. FoodMato handles any refund. |
Typical flows
| Order | Routes your POS calls |
|---|---|
| Pickup | accept→preparing→ready→picked-up |
Delivery, delivered_by: "foodmato" | accept→preparing→readyorder.status_changed. |
Delivery, delivered_by: "store" | accept→preparing→ready→out-for-delivery→delivered |
Rules
- Accept or reject quickly. A
pendingorder that gets no answer within a few minutes (5 by default) is rejected automatically. Scheduled orders work differently, see below. - Skipping is allowed. Calling
/readyright after/acceptalso recordspreparing. - Late updates are harmless. FoodMato also moves orders forward on its own (for example to
readywhen the preparation time has passed). Sending the current status or an earlier step changes nothing and returns200with the current order. - Cancelled orders are closed. Any update to an order that is
cancelled,rejectedorfailedreturns409.
Scheduled orders
A customer can order for later. Such an order has is_scheduled: true, delivery_type: "scheduled", scheduled_for and prep_start_at.
- It arrives when it is placed, not at the scheduled time. Show it as a future order and do not send it to the kitchen yet.
- Accept or reject it soon, so the customer knows. It is not rejected after 5 minutes: FoodMato only starts an answer timeout (if one is set for the country) once the kitchen time has come.
- Start the kitchen at
prep_start_at. FoodMato does not send a separate event at that time. prep_time_minuteson/acceptis ignored, andestimated_pickup_atstays empty: the time comes fromscheduled_for.- If the customer cancels before the time, you receive
order.cancelledas usual.
07Endpoints
All paths are relative to the base URL. Every status route returns the order after the change. Every route except /oauth/token needs Authorization: Bearer <token> and the scope listed in Scopes.
| Method | Path | Purpose |
|---|---|---|
| POST | /oauth/token | Access token (client credentials). Body: grant_type=client_credentials, optional scope |
| GET | /stores | Stores linked to your account (list) |
| GET | /stores/{id} | One store, by FoodMato store ID (or your own store ID, if you gave one) |
| GET | /orders | Orders (list). Filters: store_id, external_store_id, status (comma-separated), acknowledged, updated_since, limit (1–100, default 50), cursor |
| GET | /orders/{id} | One order |
| POST | /orders/{id}/acknowledge | Confirm receipt. Body (optional): { "pos_order_id": "…" } |
| POST | /orders/{id}/accept | Accept. Body (optional): { "prep_time_minutes", "reason" } |
| POST | /orders/{id}/reject | Reject. Body (optional): { "reason" } |
| POST | /orders/{id}/preparing | Kitchen started |
| POST | /orders/{id}/ready | Ready for pickup |
| POST | /orders/{id}/picked-up | Handed over or taken by the store's driver |
| POST | /orders/{id}/out-for-delivery | Store's own driver on the way |
| POST | /orders/{id}/delivered | Delivered |
| POST | /orders/{id}/cancel | Cancel. Body (optional): { "reason" } |
08The order object
The same object is sent in order.created and returned by the API. Amounts are decimals in totals.currency. An item's unit_price already includes its modifiers; modifier prices are listed for display. plu is the product code the store set in FoodMato, or null when none is set.
{
"api_version": "v1",
"id": "8d2f4c3a-1b7e-4f0a-9c55-0e2b7d9a1f63",
"order_number": "FM-240193",
"is_test": false,
"store": { "id": "6f1c2a9e-…", "external_id": "KH-STORE-12" },
"status": "pending",
"status_reason": null,
"type": "delivery",
"delivery_type": "standard",
"delivered_by": "store",
"is_scheduled": false,
"scheduled_for": null,
"prep_start_at": null,
"placed_at": "2026-11-02T12:00:00.000Z",
"estimated_pickup_at": null,
"preparation_time_minutes": null,
"updated_at": "2026-11-02T12:00:01.000Z",
"acknowledged_at": null,
"pos_order_id": null,
"requires_age_verification": false,
"notes": "Please no onions",
"customer": { "first_name": "Max", "last_name": "Mustermann", "phone": "+4915123456789", "email": "max@example.com" },
"delivery_address": {
"street": "Musterstrasse 12", "house_number_floor": "2", "apartment_suite_floor": null,
"building_name": null, "business_name": null, "postal_code": "10115", "city": "Berlin",
"country": "Germany", "door_code": null, "name_on_intercom": "Mustermann", "landmark": null,
"instructions": "Please ring twice", "latitude": 52.532, "longitude": 13.384
},
"items": [
{
"id": "c1a0…", "product_id": "7b3e…", "plu": "PIZ-001", "name": "Pizza Margherita",
"category": "Pizza", "quantity": 2, "unit_price": 10.5, "total_price": 21.0,
"discount": 0, "tax_rate": 7, "notes": null,
"modifiers": [ { "id": "e4d2…", "plu": "MOD-CHEESE", "name": "Extra Cheese", "quantity": 1, "unit_price": 1.5 } ]
}
],
"payment": { "method": "card", "status": "paid", "is_paid": true, "amount_due": 0 },
"totals": {
"currency": "EUR", "subtotal": 21.0, "delivery_fee": 2.5, "packaging_fee": 0, "service_fee": 0,
"small_order_fee": 0, "priority_fee": 0, "tip": 1.0, "discount": 0, "tax": 1.54, "total": 24.5
},
"vat_breakdown": null
}
| Field | Notes |
|---|---|
is_test | true only for test orders FoodMato sends on request while you build the integration (order number TEST-…). They exist only in the event: show them, but do not prepare them. The status routes answer 404 for them |
type | delivery, pickup, dine_in or counter |
delivered_by | Delivery orders only: store = your own driver, foodmato = a FoodMato driver collects it |
payment.is_paid | false = collect payment.amount_due on handover (cash or card at the door) |
requires_age_verification | The order contains age-restricted items: check the customer's ID on handover |
totals.discount | Total discount as a positive number; already taken off totals.total |
estimated_pickup_at | Set once the order is accepted (orders for now; not for scheduled orders) |
scheduled_for, prep_start_at | Scheduled orders only: when the customer wants the order, and when the kitchen should start so it is ready on time. Both null for other orders |
09Errors
Errors use the HTTP status and one JSON shape. Branch on error.code, which does not change; message is for people and may change.
{
"error": {
"code": "invalid_status_transition",
"message": "Order is already cancelled",
"details": { "current_status": "cancelled", "requested_status": "ready" }
}
}
| HTTP | error.code | Meaning | What to do |
|---|---|---|---|
400 | invalid_parameter | A query or body field is wrong; details.param names it | Fix the request |
400 | invalid_json | The body is not a JSON object | Fix the request |
401 | invalid_token | Access token missing, wrong or expired, or the client was revoked | Request a new token once and repeat the call; if that fails, check the client and the environment |
403 | insufficient_scope | The token lacks the scope of the route; details.required_scope names it | Request a token with that scope, or ask FoodMato to add it to your client |
403 | ip_not_allowed | The request comes from an IP your client is not allowed to use | Call from your registered servers, or send FoodMato the new IP |
404 | order_not_found, store_not_found | Not found, or not linked to your account | Check the ID |
404 | route_not_found, unsupported_api_version | Unknown path or version | Check the URL |
409 | invalid_status_transition | The step is not possible, e.g. the order is already cancelled | Show details.current_status in your POS |
422 | status_update_failed | The status could not be saved | Retry once; contact FoodMato if it continues |
500 | internal_error | FoodMato error | Retry with backoff; quote the X-Request-Id |
Token URL errors
| HTTP | error | Meaning |
|---|---|---|
400 | invalid_request | Client ID or secret missing |
400 | unsupported_grant_type | grant_type is not client_credentials |
400 | invalid_scope | A requested scope is not allowed for your client |
401 | invalid_client | Unknown client ID, wrong secret, or the client was revoked |
403 | access_denied | The request comes from an IP your client is not allowed to use |
10Testing and go-live
Build and test against the test environment with your test client (fmci_test_…). Before going live, check that your POS:
- Gets an access token, reuses it until it expires, and requests a new one after
401 invalid_token. - Rejects an event with a wrong signature, and accepts the
healthcheckevent. - Shows a new order within seconds and acknowledges it.
- Ignores a second delivery of the same event.
- Accepts an order with a preparation time, and rejects one with a reason.
- Runs the full flow for a pickup order and for a delivery order.
- Shows an order as cancelled after an
order.cancelledevent. - Updates the order after an
order.status_changedevent (for example a FoodMato driver delivered it). - Still gets an order through polling when your webhook is switched off.
- Shows the amount to collect for an order with
payment.is_paid: false.
When these pass, FoodMato issues your live client (fmci_live_…) and links your live stores.