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.

Version v1 Scope orders (menu sync is not part of v1)

01How it works

FoodMato sends the orderWhen a customer pays, FoodMato posts the order to your webhook URL, signed with your webhook secret.
Your POS shows itSave the order, reply 200, then confirm it with /acknowledge.
Your POS updates the statusEach step has its own route: /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:

ItemExampleUse
Client IDfmci_test_0b64…Identifies your API client when you request an access token.
Client secretfmcs_82be…Sent only to the token URL to get an access token. Shown once: store it securely on your server.
Webhook secretfmws_8c1e…Checking the signature of the events FoodMato sends you.
Store IDs6f1c2a9e-… per restaurantThe 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

EnvironmentBase URLClient IDs
Testhttps://api-staging.foodmato.com/pos/v1fmci_test_…
Livehttps://api.foodmato.com/pos/v1fmci_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
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"
200 response
{
  "access_token": "fmat_…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "stores:read orders:read orders:write"
}

2. Call the API with the token:

curl
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.
  • scope is optional. Without it the token gets every scope of your client; with it (for example scope=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

ScopeAllows
stores:readGET /stores, GET /stores/{id}
orders:readGET /orders, GET /orders/{id}
orders:writePOST /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

ResponseShape
One resourceThe object itself: an order or a store
List{ "data": [...], "pagination": { "limit", "has_more", "next_cursor" } }
ErrorHTTP 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).

GET /stores (example)
{
  "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 fieldNotes
status.is_activeThe store is active on FoodMato
status.is_openThe store takes orders right now: active, switched on in FoodMato, not temporarily closed and inside its opening times
status.closed_untilEnd of a temporary closure, or null
opening_timesOne entry per slot. A slot past midnight closes on the next day; 24:00 means until midnight. Closed days have no entry
delivery_timesWhen the store delivers, in the same format
order_settings.auto_acceptFoodMato 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:

EventWhendata
order.createdThe order is paid, or placed with cash on delivery{ "order": Order }
order.cancelledThe order was cancelled or rejected in FoodMato (not sent when your POS did it){ "order": { id, order_number, store, status, reason } }
order.status_changedThe 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 } }
healthcheckFoodMato tests the connection{ "store": { id, external_id } }
POST {your webhook URL}
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.

Node.js
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));
}
PHP
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 2xx within 10 seconds. Save the order first and process it afterwards.
  • Optionally reply with { "pos_order_id": "KH-104233" }; FoodMato stores it and returns it as pos_order_id.
  • On a timeout, a network error, 408, 429 or 5xx, FoodMato retries twice (after 1 and 3 seconds). Other 4xx replies are not retried.
  • A retry has the same X-FoodMato-Delivery-Id and 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 accepted and preparing). For status events, apply only the event with the latest changed_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.

  1. Every 30–60 seconds, call GET /orders?acknowledged=false. Orders come oldest first.
  2. Save each order, then call POST /orders/{id}/acknowledge. It no longer appears in the next poll.
  3. To pick up changes made in FoodMato, such as cancellations, call GET /orders?updated_since=<time of your last poll>.
  4. When pagination.has_more is true, call again with cursor=<pagination.next_cursor> for the next page.
curl
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
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"
RouteOrder status afterMeaning
/acceptacceptedThe store takes the order. Add prep_time_minutes (1–240) to set when it will be ready.
/rejectrejectedThe store does not take the order. Only while it is pending. Add a reason; the customer sees it.
/preparingpreparingThe kitchen started.
/readyreadyReady for the customer or the driver.
/picked-uppicked_upHanded over (pickup) or collected by the store's own driver.
/out-for-deliveryout_for_deliveryThe store's own driver is on the way. Delivery orders only.
/delivereddeliveredThe customer has the order.
/cancelcancelledThe order cannot be completed after it was accepted. Add a reason; the customer sees it. FoodMato handles any refund.

Typical flows

OrderRoutes your POS calls
Pickup
accept→preparing→ready→picked-up
Delivery, delivered_by: "foodmato"
accept→preparing→ready
The FoodMato driver reports pickup and delivery; you receive them as order.status_changed.
Delivery, delivered_by: "store"
accept→preparing→ready→out-for-delivery→delivered

Rules

  • Accept or reject quickly. A pending order that gets no answer within a few minutes (5 by default) is rejected automatically. Scheduled orders work differently, see below.
  • Skipping is allowed. Calling /ready right after /accept also records preparing.
  • Late updates are harmless. FoodMato also moves orders forward on its own (for example to ready when the preparation time has passed). Sending the current status or an earlier step changes nothing and returns 200 with the current order.
  • Cancelled orders are closed. Any update to an order that is cancelled, rejected or failed returns 409.

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_minutes on /accept is ignored, and estimated_pickup_at stays empty: the time comes from scheduled_for.
  • If the customer cancels before the time, you receive order.cancelled as 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.

MethodPathPurpose
POST/oauth/tokenAccess token (client credentials). Body: grant_type=client_credentials, optional scope
GET/storesStores linked to your account (list)
GET/stores/{id}One store, by FoodMato store ID (or your own store ID, if you gave one)
GET/ordersOrders (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}/acknowledgeConfirm receipt. Body (optional): { "pos_order_id": "…" }
POST/orders/{id}/acceptAccept. Body (optional): { "prep_time_minutes", "reason" }
POST/orders/{id}/rejectReject. Body (optional): { "reason" }
POST/orders/{id}/preparingKitchen started
POST/orders/{id}/readyReady for pickup
POST/orders/{id}/picked-upHanded over or taken by the store's driver
POST/orders/{id}/out-for-deliveryStore's own driver on the way
POST/orders/{id}/deliveredDelivered
POST/orders/{id}/cancelCancel. 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.

Order (example)
{
  "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
}
FieldNotes
is_testtrue 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
typedelivery, pickup, dine_in or counter
delivered_byDelivery orders only: store = your own driver, foodmato = a FoodMato driver collects it
payment.is_paidfalse = collect payment.amount_due on handover (cash or card at the door)
requires_age_verificationThe order contains age-restricted items: check the customer's ID on handover
totals.discountTotal discount as a positive number; already taken off totals.total
estimated_pickup_atSet once the order is accepted (orders for now; not for scheduled orders)
scheduled_for, prep_start_atScheduled 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.

409 response
{
  "error": {
    "code": "invalid_status_transition",
    "message": "Order is already cancelled",
    "details": { "current_status": "cancelled", "requested_status": "ready" }
  }
}
HTTPerror.codeMeaningWhat to do
400invalid_parameterA query or body field is wrong; details.param names itFix the request
400invalid_jsonThe body is not a JSON objectFix the request
401invalid_tokenAccess token missing, wrong or expired, or the client was revokedRequest a new token once and repeat the call; if that fails, check the client and the environment
403insufficient_scopeThe token lacks the scope of the route; details.required_scope names itRequest a token with that scope, or ask FoodMato to add it to your client
403ip_not_allowedThe request comes from an IP your client is not allowed to useCall from your registered servers, or send FoodMato the new IP
404order_not_found, store_not_foundNot found, or not linked to your accountCheck the ID
404route_not_found, unsupported_api_versionUnknown path or versionCheck the URL
409invalid_status_transitionThe step is not possible, e.g. the order is already cancelledShow details.current_status in your POS
422status_update_failedThe status could not be savedRetry once; contact FoodMato if it continues
500internal_errorFoodMato errorRetry with backoff; quote the X-Request-Id

Token URL errors

HTTPerrorMeaning
400invalid_requestClient ID or secret missing
400unsupported_grant_typegrant_type is not client_credentials
400invalid_scopeA requested scope is not allowed for your client
401invalid_clientUnknown client ID, wrong secret, or the client was revoked
403access_deniedThe 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:

  1. Gets an access token, reuses it until it expires, and requests a new one after 401 invalid_token.
  2. Rejects an event with a wrong signature, and accepts the healthcheck event.
  3. Shows a new order within seconds and acknowledges it.
  4. Ignores a second delivery of the same event.
  5. Accepts an order with a preparation time, and rejects one with a reason.
  6. Runs the full flow for a pickup order and for a delivery order.
  7. Shows an order as cancelled after an order.cancelled event.
  8. Updates the order after an order.status_changed event (for example a FoodMato driver delivered it).
  9. Still gets an order through polling when your webhook is switched off.
  10. 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.