# Unipage Developers > Build on Unipage: the Storefront API for custom webshops, and the Core API. Index: https://developer.unipage.be/llms.txt # Unipage Developers > Build your own webshop, kiosk or integration on top of Unipage. Source: https://developer.unipage.be/ · Markdown: https://developer.unipage.be/index.md Unipage runs ordering, POS and back office for restaurants. These docs are for developers who build on top of it: your own webshop or app, or an integration with the restaurant's accounting, ERP or kitchen systems. Unipage stays the source of truth for the catalog, opening hours, order slots, prices, orders and sales. - **Storefront API** — Build a fully custom webshop or ordering app for a Unipage restaurant. Read the menu and schedule from the browser, place orders from your server. - **Core API** — The restaurant's own back-office API, with a `upg_capi_...` key: till sales, orders, customers, the catalog and gift cards. ## Which API do I need? | You are building | Use | |---|---| | Your own webshop or ordering app for a restaurant | the [Storefront API](/storefront/) | | An accounting, ERP, BI or CRM integration that reads sales, orders or customers | the Core API [endpoints](/core/endpoints/) | | Your own POS or kitchen system that receives the restaurant's online orders | the [POS order integration](/core/pos-integration/) | | Something that reacts the moment a sale or an order happens | webhooks: [`pos.sale`](/core/webhooks/) for till sales, [order events](/storefront/webhooks/) for your storefront's orders | ## Start here ### Storefront API 1. **Request your keys** Ask Unipage for a public and a secret storefront key, and list every origin your shop is served from. See [Keys and CORS](/storefront/keys/). 2. **Make your first call** ```sh curl https://api.unipage.eu/storefront/v1/store \ -H "X-Unipage-Key: upg_sf_pk_..." ``` 3. **Build the checkout** Follow the [quickstart](/storefront/quickstart/) from menu to paid order, with the [API reference](/storefront/api/) next to you. ### Core API 1. **Get a key from the restaurant** The restaurant creates a Core API key (`upg_capi_...`) in its Unipage settings and gives it to you, with its business id. See [Authentication](/core/authentication/). 2. **Make your first call** ```sh curl https://api.unipage.eu/x94d3/pos-tickets-by-date/20261028 \ -H "X-API-Key: upg_capi_..." ``` 3. **Read, or get pushed** Read sales and orders with the [endpoints](/core/endpoints/), or have every new sale pushed to you with a [webhook](/core/webhooks/). Keep to the [limits](/core/errors-and-limits/). ## For agents Every page is also available as Markdown: add `index.md` to its URL, or use **Copy as Markdown** at the top of the page. [`/llms.txt`](/llms.txt) indexes the whole site, and [`/llms-full.txt`](/llms-full.txt) holds all of it in one file. # Core API > The restaurant's own back-office API, for accounting, ERP, CRM and reporting integrations. Source: https://developer.unipage.be/core/ · Markdown: https://developer.unipage.be/core/index.md The Core API gives a restaurant's own systems access to its Unipage data: sales from the till, webshop and kiosk orders, customers, the catalog and gift cards. Typical users are an accounting or ERP integration, a loyalty or CRM system, or a reporting dashboard. It is the same API the Unipage till (Unipos PLUS) uses to sync, so what you read is what the restaurant sees. ## How it works - **The restaurant creates the key.** In its Unipage management settings (Instellingen > Api > API Keys) the restaurant creates a Core API key (`upg_capi_...`) and hands it to you. It is shown once. - **One key, one restaurant.** A key belongs to a single business (location) and has full access to it. A group with several locations needs one key per location. Keep the key on your server: never ship it to a browser or an app. - **Base URL:** `https://api.unipage.eu/{business id}/...`. The business id is the short id shown as "ID" on the same settings page (for example `x94d3`). - **Poll or get pushed.** Read sales with the endpoints, or let Unipage push every new sale to you with a [webhook](/core/webhooks/). ```sh curl https://api.unipage.eu/x94d3/pos-tickets-by-date/20261028 \ -H "X-API-Key: $UNIPAGE_CORE_API_KEY" ``` - **Authentication** — The key, where to send it, and revoking it. - **Endpoints** — Sales, orders, customers, catalog and gift cards. - **POS order integration** — Pull online orders into your own POS or kitchen system. - **Webhooks** — Get every new sale pushed to your system, signed. - **Errors and limits** — Rate limits, error bodies and how a client should behave. > **Note** > > Building a custom webshop or ordering app instead of a back-office > integration? Use the [Storefront API](/storefront/). A Core API key is also > accepted by the Storefront API wherever the secret key is, but give a > storefront [its own keys](/storefront/keys/). # Authentication > Core API keys, where to send them, and what happens when a key is revoked. Source: https://developer.unipage.be/core/authentication/ · Markdown: https://developer.unipage.be/core/authentication/index.md Every Core API call carries the restaurant's Core API key. ## The key A Core API key looks like this: ``` upg_capi__<8 characters>. ``` - The part before the dot identifies the key. The part after it is the secret; Unipage only stores a hash of it, so a lost key cannot be shown again. The restaurant creates a new one instead. - The key belongs to one restaurant (one location). Use it only on that location's URL: `https://api.unipage.eu/{business id}/...`. On another location's URL it is refused with a 401. - It has full access to that location. Keep it on your server, in a secret store or environment variable, never in a browser bundle, an app or a repository. ## Sending the key Send it in the `X-API-Key` header: ### curl ```sh curl https://api.unipage.eu/x94d3/customers \ -H "X-API-Key: $UNIPAGE_CORE_API_KEY" ``` ### Node.js ```js const res = await fetch("https://api.unipage.eu/x94d3/customers", { headers: { "X-API-Key": process.env.UNIPAGE_CORE_API_KEY }, }); const { data } = await res.json(); ``` ### PHP ```php $response = Http::withHeaders([ 'X-API-Key' => env('UNIPAGE_CORE_API_KEY'), ])->get('https://api.unipage.eu/x94d3/customers'); ``` > **Caution** > > The query parameter `?api_key=` is also accepted, but it puts the key in > every log line between you and Unipage (proxies, load balancers, your own > access logs). Use the header. ## Revoking a key The restaurant deletes the key on the same settings card. From that moment every call with it is refused with a 401. A key can also be switched off temporarily, which answers 403 until it is switched on again. See [Errors and limits](/core/errors-and-limits/) for the exact responses. # Endpoints > Sales (till receipts), orders, customers, catalog and gift cards, and the keyless public endpoints. Source: https://developer.unipage.be/core/endpoints/ · Markdown: https://developer.unipage.be/core/endpoints/index.md All paths are relative to `https://api.unipage.eu/{business id}` and need the [Core API key](/core/authentication/) in the `X-API-Key` header. | Method and path | Returns | |---|---| | `GET /pos-tickets-by-date/{Ymd}` | the till's sales receipts of one day | | `GET /orders/{Y-m-d}` | webshop, app, kiosk and delivery-platform orders of one day | | `GET /customers` | every customer | | `GET /customers/{id}` | one customer | | `POST /customers` | create a customer | | `POST /customers/{id}` | update a customer | | `GET /linked-businesses` | the other locations of the same group | | `GET /generic/categories`, `GET /generic/products` | the catalog | | `GET /giftcards`, `GET /giftcards/check-code` | gift cards | ## Sales: `GET /pos-tickets-by-date/{Ymd}` The VAT receipts the till made on one day, for example `/pos-tickets-by-date/20261028`. The day runs from midnight to midnight in the restaurant's timezone, by consumption date. Refund receipts are included (`is_refund: true`). | Query parameter | Effect | |---|---| | `synced_until` | Unix seconds: only receipts changed after this moment. Use it to fetch a day again cheaply. | | `exclude_terminal` | leave out the receipts of one till (by terminal name) | ```json { "tickets": [ { "id": "k3m9a", "business_id": "x94d3", "order_id": null, "customer_id": "p8q2r", "ticket_id": "...", "type": "VAT", "is_refund": false, "operator_name": "Eva", "terminal_name": "Kassa 1", "consumption_date": "2026-10-28 12:41:07", "ticket_date": "2026-10-28 12:41:09", "ticket_lines": [ { "product": "Broodje kaas", "category_reference": "Broodjes", "amount": 2, "unit_price_with_vat": 4.5, "vat_rate": 6, "total_price_without_vat": 8.49, "total_vat": 0.51, "total_price_with_vat": 9 } ], "payment_lines": [ { "payment_method": "CASH", "payment_name": "Cash", "amount": 9, "rounding": 0 } ] } ] } ``` ### The receipt fields | Key | Meaning | |---|---| | `id`, `business_id` | ids | | `order_id`, `customer_id` | the webshop order this receipt settles, and the customer it was booked on, or `null` | | `ticket_id`, `ticket_source`, `pos_order_id`, `order_number` | the till's own identifiers | | `type` | `VAT` for a sales receipt | | `is_refund` | `true` for a refund receipt | | `operator_name`, `terminal_name` | who made the sale, on which till | | `consumption_date`, `ticket_date` | when it was consumed, when the receipt was made | | `vat_type_reference`, `fdm_response`, `did_award_loyalty_points` | fiscal and loyalty details as the till sent them | | `ticket_lines[]` | `id`, `business_id`, `type`, `product`, `category_reference`, `notes`, `price_type`, `unit_price_with_vat`, `amount`, `vat_rate`, `total_price_without_vat`, `total_vat`, `total_price_with_vat`, `course` | | `discount_lines[]` | only in the [`pos.sale` webhook](/core/webhooks/): `id`, `ticket_line_id`, `name`, `type`, `vat_rate`, `total_price_without_vat`, `total_vat`, `total_price_with_vat` | | `payment_lines[]` | `id`, `payment_method`, `payment_name`, `amount`, `rounding` | > **Note** > > Discount amounts are signed: negative on a sale, positive when a discounted > line is reversed. Add them up as they are; do not take absolute values, or > reversals will no longer cancel out. This shape is a contract: keys are added over time, never renamed or removed. ## Orders: `GET /orders/{Y-m-d}` The orders created on one day, for example `/orders/2026-10-28`, with a successful status: webshop, app, kiosk and delivery-platform (Uber Eats, Deliveroo) orders. Returned as an array. > **Caution** > > Orders are returned for **every location of the group** (the locations > sharing one catalog), not only the location in the URL. Receipts are per > location. Use `business_id` on each order if you need one location. The main keys: `id`, `created_at` and `updated_at` (Unix seconds), `order_number_for_company`, `status`, `order_type`, `order_source`, `payment_method`, `is_paid`, `total_price`, `delivery_cost`, `service_cost`, `coupon_reduction`, `coupon_string`, `wallet_amount`, customer fields (`user_firstname`, `user_lastname`, `user_email`, `user_phone`, `user_id`), delivery fields, `takeout_delivery_timestamp`, `order_notes`, platform fields (`display_id`, `delivery_type`, `dropoff_notes`, `unmatched_items`), and `orderItems[]` with their options. ## Customers ### `GET /customers` Every customer of the restaurant's organization, as `{"data": [...]}`. Deleted customers are included, with `deleted_at` set, so you can remove them on your side. | Query parameter | Effect | |---|---| | `updated_after` | Unix seconds: only customers changed at or after this moment | The list is cached for 15 seconds. Sync with `updated_after` instead of downloading every customer each time. ### `GET /customers/{id}` One customer, as `{"data": {...}}`. The customer fields: `id`, `user_id`, `firstname`, `lastname`, `full_name`, `email`, `phone`, address fields, `company_name`, `vat_number`, `notes`, `nfc_uid`, `profile_picture_url`, `last_ordered_at`, `did_give_marketing_consent`, `created_at`, `updated_at`, `memberships`, `wallet`, `loyalty_wallet`, `metadata`, `deleted_at`. ### `POST /customers` and `POST /customers/{id}` Create or update a customer. Answers `{"success": true, "customer": {...}}`. | Field | Rule | |---|---| | `email` | required on create, unique in the organization | | `nfc_uid` | optional, unique in the organization (a loyalty card or badge) | | `firstname`, `lastname`, `company_name`, `vat_number`, `phone` | optional, at most 255 characters | | `address_1`, `address_number`, `address_number_addition`, `address_postcode`, `address_city`, `address_country` | optional | | `did_give_marketing_consent` | optional boolean, default `false` | | `profile_picture_base64` | optional, a base64 PNG | ```sh curl -X POST https://api.unipage.eu/x94d3/customers \ -H "X-API-Key: $UNIPAGE_CORE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"email": "eva@example.com", "firstname": "Eva", "lastname": "Peeters"}' ``` ## Other locations: `GET /linked-businesses` `{"businesses": [{"id": "x94d3", "reference": "..."}]}`: every location of the group, this one included. Each needs its own key. ## Catalog `GET /generic/categories` and `GET /generic/products` return the catalog as the till receives it, with prices, options and translations. `GET /generic/products` accepts `updated_after` (Unix seconds) to fetch only the products changed since your last sync, and `category_id` to limit it to one category. ## Gift cards | Endpoint | Returns | |---|---| | `GET /giftcards` | `{"giftcards": [...]}`: the most recent valid gift cards. `search` filters, `limit` sets how many (default 6, at most 50). | | `GET /giftcards/check-code?code=...` | `{"available": true}` when no gift card uses that code yet | A gift card has `id`, `code`, `name`, `amount`, `balance`, `formatted_balance`, `is_valid`, `recipient_name`, `expires_at`, `redeemed_at` and `created_at`. ## Without a key: `/public/{domain}` What the public webshop already shows is also available without a key, by the shop's domain (for example `myshop.unipage.eu`): | Path | Returns | |---|---| | `GET /public/{domain}` | name, address, phone, e-mail and opening hours | | `GET /public/{domain}/menu` | the webshop menu (the `Accept-Language` header, nl, fr, en or de, sets the language) | | `GET /public/{domain}/categories`, `GET /public/{domain}/products` | categories and products with prices and translations | For a custom ordering experience, use the [Storefront API](/storefront/) instead: it also quotes carts and places orders. # Errors and limits > Rate limits, the error bodies of the key check, and how a well-behaved client polls. Source: https://developer.unipage.be/core/errors-and-limits/ · Markdown: https://developer.unipage.be/core/errors-and-limits/index.md ## Rate limits Per Core API key: | Limit | Over it | |---|---| | 60 calls per minute | `429` with `{"error": "rate_limited_minute", "retry_after": }` | | 1 call per second | `429` with `{"error": "rate_limited", "retry_after": 1}` | The wait is in the response body (`retry_after`, in seconds); there is no `Retry-After` header. Wait at least that long before the next call. ## Errors from the key check | Status | Body | Meaning | |---|---|---| | `401` | the standard unauthorized response, no `error` key | No key was sent, or a Storefront key (`upg_sf_...`) was used: those never work here. | | `401` | `{"error": "Malformed API key"}` | The key has no secret part (no dot). | | `401` | `{"error": "Invalid API key"}` | Unknown key, wrong secret, or a key of another location. | | `403` | `{"error": "API key disabled"}` | The restaurant switched the key off. | | `429` | see above | Too many calls. | Errors from an endpoint itself are Laravel's standard JSON: a `422` with `{"message": "...", "errors": {"field": ["..."]}}` for invalid input, a `404` for an unknown id. ## Behaving well Your client runs unattended, often on a schedule. A few rules keep it (and the restaurant's till, which uses the same API) healthy: - **Never retry a 4xx in a tight loop.** A 401, 403, 404 or 422 will answer the same on the next try. Log it, alert a human, and stop. - **Back off on 429** for `retry_after` seconds, and spread your calls: one call per second is the hard ceiling. - **Sync incrementally.** Use the cursors the endpoints offer (`synced_until` on sales, `updated_after` on customers and products) instead of downloading everything again. - **Prefer webhooks for new sales.** A [`pos.sale` webhook](/core/webhooks/) pushes every new receipt the moment the till syncs it, so you only poll to catch up. - **Ignore fields you do not know.** Responses gain new keys over time; existing keys are never renamed or removed. ## Ids and times - Ids in responses are short hash ids (`x94d3`), not database numbers. Use them as opaque strings. - `created_at` and `updated_at` style fields are Unix timestamps in seconds, unless the endpoint says otherwise. Ticket dates are date-time strings as the till recorded them. # POS order integration > Pull webshop, kiosk and QR orders into your own POS or kitchen system, acknowledge them and report kitchen progress. Source: https://developer.unipage.be/core/pos-integration/ · Markdown: https://developer.unipage.be/core/pos-integration/index.md A third-party POS or kitchen system can take the restaurant's online orders (webshop, app, QR table orders, kiosk and accepted delivery-platform orders) from an order queue, mark them as received and report back when the kitchen starts, finishes or runs late. This is the same queue the Unipage till (Unipos PLUS) reads. All calls use the restaurant's [Core API key](/core/authentication/) in the `X-API-Key` header. Paths are relative to `https://api.unipage.eu/{business id}/generic_polling`. > **Caution** > > **One system pulls the orders.** The queue serves an order to whoever asks > until someone acknowledges it, so two systems that pull for the same > restaurant both receive (and print) every order. Ask Unipage to switch the > restaurant to the polling integration, and make sure no Unipage till of the > same restaurant group pulls webshop orders as well. ## How the queue works 1. **Step 1** **Poll.** `GET /orders` returns every order that is waiting for the POS. 2. **Step 2** **Acknowledge.** Once an order is stored on your side, `POST /orders/{id}` with `{"status": "SYNCED"}`. From then on the poll no longer returns it. 3. **Step 3** **Report progress (optional).** Tell Unipage when the kitchen starts or finishes, or when an order runs late. - **Served until acknowledged.** Every poll returns every waiting order again, without a time window or a limit, until it is acknowledged. Do not rely on seeing an order only once: deduplicate on the order `id`. - **An acknowledged order does not come back by itself.** Only Unipage support can put an order back in the queue (for example after a POS was replaced). - **Group-wide.** For a restaurant group whose locations share one catalog, the poll returns the waiting orders of every location of the group. The `business_id` field in an order is not the location id: do not build on it. ## `GET /orders` The orders waiting for the POS, as a plain JSON array (no `data` wrapper). An order is in it while it: - is placed and not finished: `status` is `PLACED`, `CHEF_STARTED` or `CHEF_COMPLETED` (an unpaid online payment, a declined or a cancelled order never appears), - is a real order (top-ups and other payment-only orders are left out), - has not been acknowledged yet. Delivery-platform orders (Uber Eats, Deliveroo) only appear after the restaurant has accepted them. ```sh curl https://api.unipage.eu/x94d3/generic_polling/orders \ -H "X-API-Key: $UNIPAGE_CORE_API_KEY" ``` ### Polling rules - **Poll every 10 to 30 seconds**, not faster. The poll has its own limit of 120 calls per minute per restaurant and IP on top of the [key limits](/core/errors-and-limits/); a `429` costs you one cycle and the orders stay in the queue. - **Acknowledge right after storing.** Until you post `SYNCED`, the order is in every response. - **Never retry a `4xx` unchanged in a loop.** See [Errors and limits](/core/errors-and-limits/). ## `GET /orders/{id}` One order, by its `id`, in the same shape as the poll, plus the `payment` block. An order of another restaurant group answers `404`. ## The order fields The fields an integration needs most: | Field | Meaning | |---|---| | `id` | the order id (short hash id); use it for every call on the order | | `status` | `PLACED`, `CHEF_STARTED` or `CHEF_COMPLETED` | | `order_type`, `order_source` | how it is fulfilled (takeout, delivery, ...) and which channel placed it | | `is_paid`, `payment_method` | whether it is paid online already, or still has to be paid at pickup or delivery | | `order_number_for_company` | the short order number shown to the customer | | `takeout_delivery_timestamp` | when the customer expects the order (Unix seconds) | | `delay_time_in_minutes` | the delay reported so far | | `total_price`, `delivery_cost`, `service_cost` | amounts including VAT | | `coupon_reduction`, `coupon_string` | the coupon discount and its code; a code starting with `LOYALTY:` is a loyalty reward | | `wallet_amount` | the part paid with the customer's wallet | | `order_notes` | the customer's note on the order | | `user_firstname`, `user_lastname`, `user_email`, `user_phone` | the customer | | `delivery_*` | the delivery address (delivery orders) | | `table_hashid` | the QR table the order was placed at, if any | | `is_kiosk_order`, `is_digital_menu_order` | placed at a kiosk, or from a QR menu at the table | | `display_id`, `delivery_type`, `dropoff_notes` | delivery-platform details (Uber Eats, Deliveroo) | | `created_at`, `updated_at` | Unix seconds | | `orderItems[]` | the lines, see below | Each line in `orderItems` carries `item_id` and the full `item` (the product as in the [catalog](#the-catalog)), `reference` (the product name), `amount`, `item_price`, `total_price`, `vat_rate`, `notes` (the customer's remark on the line), `ordered_for` (in a group order: the person the line is for), `orderItemAddons` (the chosen options per option group) and `discount_lines`. - **Options.** An option of a quantity group has `type: "AMOUNT"` and an integer `value`; every other option has `type: "BOOLEAN"` and a boolean `value`. - **Discounts are lines.** `discount_lines` (on lines and on options) carry the discount as Unipage calculated it (`source`, `amount`, `vat_rate` and the totals). Ring them as they are; do not recalculate them. Ignore fields you do not know: fields are added over time. ## Acknowledge: `POST /orders/{id}` ```sh curl -X POST https://api.unipage.eu/x94d3/generic_polling/orders/k2f9a \ -H "X-API-Key: $UNIPAGE_CORE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"status": "SYNCED"}' ``` | Body field | Meaning | |---|---| | `status` | `SYNCED` (stored, take it out of the queue) or `SYNCING` (you are still processing it). Any other value is a `422`. | The answer is `{"success": true}`. ## Kitchen progress: `POST /orders/{id}/fulfilment` Moves the order through the kitchen statuses the restaurant also sees in Unipage: ```json { "order": { "status": "CHEF_STARTED" } } ``` Send `CHEF_STARTED` when the kitchen starts and `CHEF_COMPLETED` when the order is ready. Only send these statuses: the value is stored as sent. When the status becomes `CHEF_STARTED`, Unipage tells the customer the kitchen has started (mail, and SMS where the restaurant has it), if the restaurant has customer communication switched on. The answer is `{"success": true}`. ## Running late: `POST /orders/{id}/delay` ```json { "minutes": 15 } ``` `minutes` is a whole number from 1 to 480 (otherwise `422`). Unipage stores the delay on the order and mails the customer that the order is late. The restaurant must have order delays enabled, otherwise the answer is `403`. ## The catalog To map order lines to your own articles, read the products of the restaurant. These paths are relative to the same base URL: | Call | Returns | |---|---| | `GET /products` | the restaurant's products with category, prices, translations and tags. Optional `updated_after` (Unix seconds: only products changed since then) and `category_id`. | | `GET /products/{id}` | one product with its option groups and options | | `GET /products/info` | `{"products_last_updated_at_timestamp": ...}`: when a product last changed, a cheap check before you fetch | | `GET /categories` | the categories, optional `updated_after` | `updated_after` overlaps one second with the previous answer, so a product changed in the same second as your last sync is never missed: expect to see a product twice and upsert on its `id`. # Webhooks > Get every new till sale pushed to your system as a signed HTTP POST, and verify the signature. Source: https://developer.unipage.be/core/webhooks/ · Markdown: https://developer.unipage.be/core/webhooks/index.md Instead of polling for sales, the restaurant can have Unipage POST every new receipt to your system the moment the till syncs it. ## Setting one up The restaurant adds the webhook in its management settings (Instellingen > Api > Webhooks > "Webhook toevoegen"): | Setting | Notes | |---|---| | URL | `https://` only, publicly reachable, no username or password in the URL | | Authentication | none, a Bearer token, Basic (username and password), or an API key in a header of your choice | | Extra headers | up to 20, sent with every request | | Events | today: `pos.sale` | On save, the **signing secret** (`whsec_...`) is shown once. The restaurant hands it to you together with the URL. A restaurant can have up to 10 webhooks. "Verstuur test" sends a `ping` event right away, so you can check your endpoint. ## Events | Event | When | `data` | |---|---|---| | `pos.sale` | a new VAT receipt arrives from the till, refunds included | the [receipt](/core/endpoints/#the-receipt-fields), with `ticket_lines`, `discount_lines` and `payment_lines` | | `ping` | the restaurant clicks "Verstuur test" | `{"message": "Test event from Unipage"}` | A receipt the till sends again does not fire a second `pos.sale`. Training mode receipts are never sent. ## The request ```http POST /your/endpoint HTTP/1.1 Content-Type: application/json User-Agent: Unipage-Webhooks/1.0 X-Unipage-Event: pos.sale X-Unipage-Timestamp: 1761655267 X-Unipage-Signature: sha256=5d41402abc4b2a76b9719d911017c592... ``` ```json { "id": "9b2f6c1e-4a7d-4f0e-9a51-2c3d8e7f1a20", "event": "pos.sale", "created_at": "2026-10-28T12:41:09+00:00", "business_id": "x94d3", "data": { "id": "k3m9a", "type": "VAT", "ticket_lines": [], "payment_lines": [], "discount_lines": [] } } ``` When the webhook has a Bearer token or Basic credentials configured, they come in the `Authorization` header; an API key comes in the header the restaurant chose. Answer with any `2xx` within 5 seconds. Do the real work after you answer, for example on your own queue. ## Verifying the signature Every webhook request carries three headers: | Header | Value | |---|---| | `X-Unipage-Event` | the event name | | `X-Unipage-Timestamp` | when the request was signed, in Unix seconds | | `X-Unipage-Signature` | `sha256=` plus the hex HMAC-SHA256 of `.`, keyed with your signing secret (`whsec_...`) | The `User-Agent` is `Unipage-Webhooks/1.0`. To verify a request: 1. Read the **raw** request body, exactly the bytes you received, before any JSON parsing. A re-serialised body does not match. 2. Compute the signature over `.` and compare it with `X-Unipage-Signature` in constant time. 3. Refuse a timestamp more than 5 minutes from your clock, so a captured request cannot be replayed. 4. Answer `401` when the check fails. Otherwise answer `2xx` at once and do the work afterwards. ### Node.js ```js import { createHmac, timingSafeEqual } from "node:crypto"; // headers: lower-cased header names, rawBody: the body as a string. export function verify(secret, headers, rawBody) { const timestamp = String(headers["x-unipage-timestamp"] || ""); const signature = String(headers["x-unipage-signature"] || ""); if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) { return false; } const expected = Buffer.from( "sha256=" + createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex"), ); const given = Buffer.from(signature); return given.length === expected.length && timingSafeEqual(given, expected); } ``` With Express, keep the body raw on the webhook route: ```js app.post("/unipage/webhook", express.raw({ type: "application/json" }), (req, res) => { const rawBody = req.body.toString("utf8"); if (!verify(process.env.UNIPAGE_WEBHOOK_SECRET, req.headers, rawBody)) { return res.sendStatus(401); } res.sendStatus(200); const event = JSON.parse(rawBody); // Drop event.id if you handled it before, then do the work. }); ``` ### PHP ```php function verify(string $secret, string $timestamp, string $signature, string $rawBody): bool { if (! ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) { return false; } $expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); return hash_equals($expected, $signature); } $rawBody = file_get_contents('php://input'); $ok = verify( getenv('UNIPAGE_WEBHOOK_SECRET'), $_SERVER['HTTP_X_UNIPAGE_TIMESTAMP'] ?? '', $_SERVER['HTTP_X_UNIPAGE_SIGNATURE'] ?? '', $rawBody, ); if (! $ok) { http_response_code(401); exit; } $event = json_decode($rawBody, true); // Drop $event['id'] if you handled it before, then do the work. http_response_code(200); ``` ## Deliveries, failures and resending - **No automatic retries.** A delivery that fails (no answer within 5 seconds, or a status outside `2xx`) is logged and not tried again by itself. Fetch the day with [`GET /pos-tickets-by-date`](/core/endpoints/) to catch up after an outage of your endpoint. - **Resending by hand.** The restaurant sees every attempt of the last 30 days under "Leveringen", with the exact payload and your answer, and can send one again. A resend carries the **same event `id`**, so deduplicate on it: a resent sale must not be booked twice. - **Switched off after 50 failures in a row.** The webhook is then disabled until the restaurant switches it on again, which resets the counter. - **No redirects.** A `3xx` answer counts as a failure: point the webhook at the final URL. - **Public addresses only.** A URL whose host resolves to a private, local or reserved address is refused. ## Rotating the secret "Nieuw signing secret" replaces the secret at once and shows the new one once. Deliveries from that moment are signed with the new secret, so update your endpoint at the same time. # Storefront API > Build your own webshop on top of a Unipage restaurant. Source: https://developer.unipage.be/storefront/ · Markdown: https://developer.unipage.be/storefront/index.md The Storefront API lets you build your own webshop on top of a Unipage restaurant: your own UI, with Unipage as the source of truth for the catalog, opening hours, order slots, prices and orders. ```txt title="Base URL" https://api.unipage.eu/storefront/v1 ``` The restaurant is resolved from your key: there is no restaurant id in the URL. See [Keys and CORS](/storefront/keys/). ## Routes | Route | Key | | |---|---|---| | `GET /store` | public | [Get the store](/storefront/api/store/retrieve/) | | `GET /menu?orderType=&at=` | public | [Get the menu](/storefront/api/menu/retrieve/) | | `GET /products/{id}?orderType=&at=` | public | [Get a product](/storefront/api/products/retrieve/) | | `GET /schedule?orderType=&days=&products=` | public | [Get the schedule](/storefront/api/schedule/retrieve/) | | `POST /cart/quote` | public | [Quote a cart](/storefront/api/cart/quote/) | | `POST /orders` | secret | [Create an order](/storefront/api/orders/create/) | | `GET /orders/{id}` | secret | [Get an order](/storefront/api/orders/retrieve/) | Public key routes are called from the browser. Secret key routes are called from your server. Unipage can also [push your orders](/storefront/webhooks/) to your server as they are paid and prepared. > **One source of truth** > > The schedule and the quote are computed by the same code that validates and > prices an order: the slots you show are the slots an order accepts, and the > quote is the amount the order charges. ## Conventions - Money is a number in the restaurant's currency (EUR): `4.5`. - `at` is a UTC unix timestamp in seconds; `date` is `YYYY-MM-DD` and `time` is `HH:MM`, both in the restaurant's timezone. - All ids are opaque strings. Do not parse them. ## Next steps - **Quickstart** — The smallest working storefront: menu, quote and order, with code. - **Checkout flow** — From menu to paid order, in the order a storefront makes the calls. - **Payments** — Let the restaurant's provider collect the money, the restaurant itself, or you. - **Order webhooks** — Signed events when an order is placed, paid and prepared. - **Customer messages** — Let Unipage message the customer, or send your own. - **API reference** — Every route, parameter and field, with request samples. # Storefront API > The Storefront API lets you build your own webshop on top of a Unipage restaurant: your own UI, with Unipage as the source of truth for the catalog, opening hours, order slots, prices and orders. The restaurant is resolved from your key: there is no restaurant id in the URL. Read routes and the quote take the **public key** from the browser; placing and reading orders take the **secret key** from your server. See [Keys and CORS](/storefront/keys/). The schedule and the quote are computed by the same code that validates and prices an order: the slots you show are the slots an order accepts, and the quote is the amount the order charges. Money is a number in the restaurant's currency (EUR). `at` is a UTC unix timestamp in seconds; `date` (`YYYY-MM-DD`) and `time` (`HH:MM`) are in the restaurant's timezone. All ids are opaque strings: do not parse them. Unipage can also push your orders to you as they change. See [Order webhooks](/storefront/webhooks/). Source: https://developer.unipage.be/storefront/api/ · Markdown: https://developer.unipage.be/storefront/api/index.md The Storefront API lets you build your own webshop on top of a Unipage restaurant: your own UI, with Unipage as the source of truth for the catalog, opening hours, order slots, prices and orders. The restaurant is resolved from your key: there is no restaurant id in the URL. Read routes and the quote take the **public key** from the browser; placing and reading orders take the **secret key** from your server. See [Keys and CORS](/storefront/keys/). The schedule and the quote are computed by the same code that validates and prices an order: the slots you show are the slots an order accepts, and the quote is the amount the order charges. Money is a number in the restaurant's currency (EUR). `at` is a UTC unix timestamp in seconds; `date` (`YYYY-MM-DD`) and `time` (`HH:MM`) are in the restaurant's timezone. All ids are opaque strings: do not parse them. Unipage can also push your orders to you as they change. See [Order webhooks](/storefront/webhooks/). Version: 1 ## Servers - `https://api.unipage.eu/storefront/v1` ## Sections - [Store](/storefront/api/tags/store/) - [Menu](/storefront/api/tags/menu/) - [Schedule](/storefront/api/tags/schedule/) - [Cart](/storefront/api/tags/cart/) - [Orders](/storefront/api/tags/orders/) - [Webhooks](/storefront/api/tags/webhooks/) # Quote a cart > Prices a cart exactly as the order will, and tells you whether it can check out. Always 200 for a well-formed request: problems such as an invalid coupon or a missed minimum are reported inside the response (`coupon.valid`, `minimum`, `canCheckout`). The quote is a preview: Unipage recomputes every amount when the order is placed. Promotional offers and tips are not previewed. Source: https://developer.unipage.be/storefront/api/cart/quote/ · Markdown: https://developer.unipage.be/storefront/api/cart/quote/index.md Path: Storefront API › Cart `POST /cart/quote` Prices a cart exactly as the order will, and tells you whether it can check out. Always 200 for a well-formed request: problems such as an invalid coupon or a missed minimum are reported inside the response (`coupon.valid`, `minimum`, `canCheckout`). The quote is a preview: Unipage recomputes every amount when the order is placed. Promotional offers and tips are not previewed. ## Authentication Requires one of the following: - `publicKey`, apiKey, in header, header `X-Unipage-Key` - `secretKey`, http, header `Authorization` ## Request body _Required._ - `orderType` (`quoteCart.orderType`; string, required) - one of `"TAKEOUT"`, `"DELIVERY"` - `lines` (`quoteCart.lines`; array, required) - `productId` (`quoteCart.lines.productId`; string, required) - `quantity` (`quoteCart.lines.quantity`; integer, required) - min 1 - `notes` (`quoteCart.lines.notes`; string, optional) - maxLength 255 - `options` (`quoteCart.lines.options`; array, optional) - `groupId` (`quoteCart.lines.options.groupId`; string, required) - `optionId` (`quoteCart.lines.options.optionId`; string, required) - `quantity` (`quoteCart.lines.options.quantity`; integer, optional) — Only matters for `AMOUNT` groups. - default `1` - `delivery` (`quoteCart.delivery`; object, optional) — The delivery zone, for `DELIVERY`. - `zoneId` (`quoteCart.delivery.zoneId`; string, optional) - `coupon` (`quoteCart.coupon`; string, optional) - `slotAt` (`quoteCart.slotAt`; integer, optional) — Send it once the shopper picked a slot, so the prices and availability are the ones of that moment. `POST /orders` always uses its own `slotAt`. ## Example request ```json { "orderType": "TAKEOUT", "lines": [ { "productId": "p7d2x", "quantity": 1, "notes": "no salt", "options": [ { "groupId": "g5t1r", "optionId": "o9w3e", "quantity": 1 } ] } ], "delivery": { "zoneId": "z81qm" }, "coupon": "WELCOME10", "slotAt": 1792750500 } ``` ## Code samples ### cURL ```bash curl --request POST \ --url https://api.unipage.eu/storefront/v1/cart/quote \ --header 'Content-Type: application/json' \ --header 'X-Unipage-Key: ' \ --data ' { "orderType": "TAKEOUT", "lines": [ { "productId": "p7d2x", "quantity": 1, "notes": "no salt", "options": [ { "groupId": "g5t1r", "optionId": "o9w3e", "quantity": 1 } ] } ], "delivery": { "zoneId": "z81qm" }, "coupon": "WELCOME10", "slotAt": 1792750500 } ' ``` ### TypeScript ```typescript const url = 'https://api.unipage.eu/storefront/v1/cart/quote'; const options = { method: 'POST', headers: {'Content-Type': 'application/json', 'X-Unipage-Key': ''}, body: JSON.stringify({ orderType: 'TAKEOUT', lines: [ { productId: 'p7d2x', quantity: 1, notes: 'no salt', options: [{groupId: 'g5t1r', optionId: 'o9w3e', quantity: 1}] } ], delivery: {zoneId: 'z81qm'}, coupon: 'WELCOME10', slotAt: 1792750500 }) }; fetch(url, options) .then(res => res.json()) .then(json => console.log(json)) .catch(err => console.error(err)); ``` ### Python ```python import requests url = "https://api.unipage.eu/storefront/v1/cart/quote" payload = { "orderType": "TAKEOUT", "lines": [ { "productId": "p7d2x", "quantity": 1, "notes": "no salt", "options": [ { "groupId": "g5t1r", "optionId": "o9w3e", "quantity": 1 } ] } ], "delivery": { "zoneId": "z81qm" }, "coupon": "WELCOME10", "slotAt": 1792750500 } headers = { "Content-Type": "application/json", "X-Unipage-Key": "" } response = requests.post(url, json=payload, headers=headers) print(response.text) ``` ## Responses ### 200 The quote. #### Example ```json { "lines": [ { "index": 0, "productId": "p7d2x", "name": "Loaded fries", "quantity": 1, "unitPrice": 5, "optionsLabel": "Toppings: Feta", "total": 5, "errors": [ "string" ] } ], "subtotal": 5, "charges": [ { "name": "Packaging", "amount": 0.5 } ], "deliveryCost": 10, "serviceCost": 0, "couponDiscount": 0, "coupon": { "code": "WELCOME10", "valid": false, "message": "Coupon is not valid" }, "total": 15.5, "minimum": { "amount": 50, "missing": 45 }, "errors": [ "Minimum order for delivery is EUR 50.00" ], "canCheckout": false } ``` - `lines` (`quoteCart.response.200.lines`; array, optional) - `index` (`quoteCart.response.200.lines.index`; integer, optional) — Position of the line in the request. - `productId` (`quoteCart.response.200.lines.productId`; string, optional) - `name` (`quoteCart.response.200.lines.name`; string, optional) - `quantity` (`quoteCart.response.200.lines.quantity`; integer, optional) - `unitPrice` (`quoteCart.response.200.lines.unitPrice`; number, optional) - `optionsLabel` (`quoteCart.response.200.lines.optionsLabel`; string, optional) - `total` (`quoteCart.response.200.lines.total`; number, optional) - `errors` (`quoteCart.response.200.lines.errors`; array, optional) - `subtotal` (`quoteCart.response.200.subtotal`; number, optional) - `charges` (`quoteCart.response.200.charges`; array, optional) — Fixed charges the restaurant adds to every order of this type (a packaging or bag fee, for example). They are part of `total`; show them as separate lines. - `name` (`quoteCart.response.200.charges.name`; string, optional) - `amount` (`quoteCart.response.200.charges.amount`; number, optional) - `deliveryCost` (`quoteCart.response.200.deliveryCost`; number, optional) - `serviceCost` (`quoteCart.response.200.serviceCost`; number, optional) - `couponDiscount` (`quoteCart.response.200.couponDiscount`; number, optional) - `coupon` (`quoteCart.response.200.coupon`; object | null, optional) — `null` when none was sent. - `code` (`quoteCart.response.200.coupon.code`; string, optional) - `valid` (`quoteCart.response.200.coupon.valid`; boolean, optional) - `message` (`quoteCart.response.200.coupon.message`; string, optional) - `total` (`quoteCart.response.200.total`; number, optional) — `subtotal` + `charges` + `deliveryCost` + `serviceCost` - `couponDiscount`. - `minimum` (`quoteCart.response.200.minimum`; object | null, optional) — `null` when no minimum applies. - `amount` (`quoteCart.response.200.minimum.amount`; number, optional) - `missing` (`quoteCart.response.200.minimum.missing`; number, optional) - `errors` (`quoteCart.response.200.errors`; array, optional) — Every reason `canCheckout` is false that is not a line error. - `canCheckout` (`quoteCart.response.200.canCheckout`; boolean, optional) ### 401 A missing, unknown or revoked key (`UNAUTHENTICATED`). #### Example ```json { "error": { "code": "UNAUTHENTICATED", "message": "No valid key." } } ``` - `error` (`quoteCart.response.401.error`; object, required) - `code` (`quoteCart.response.401.error.code`; string, required) - one of `"UNAUTHENTICATED"`, `"FORBIDDEN"`, `"NOT_FOUND"`, `"METHOD_NOT_ALLOWED"`, `"VALIDATION"`, `"SLOT_UNAVAILABLE"`, `"DELIVERY_UNAVAILABLE"`, `"PRODUCT_UNAVAILABLE"`, `"COUPON_INVALID"`, `"MINIMUM_NOT_REACHED"`, `"RATE_LIMITED"`, `"INTERNAL"` - `message` (`quoteCart.response.401.error.message`; string, required) — Human readable. - `fields` (`quoteCart.response.401.error.fields`; map, optional) — Only present when the error belongs to specific request fields. ### 403 A public key on a secret route, or used from an origin that is not registered for it (`FORBIDDEN`). #### Example ```json { "error": { "code": "FORBIDDEN", "message": "Origin not registered for this key." } } ``` - `error` (`quoteCart.response.403.error`; object, required) - `code` (`quoteCart.response.403.error.code`; string, required) - one of `"UNAUTHENTICATED"`, `"FORBIDDEN"`, `"NOT_FOUND"`, `"METHOD_NOT_ALLOWED"`, `"VALIDATION"`, `"SLOT_UNAVAILABLE"`, `"DELIVERY_UNAVAILABLE"`, `"PRODUCT_UNAVAILABLE"`, `"COUPON_INVALID"`, `"MINIMUM_NOT_REACHED"`, `"RATE_LIMITED"`, `"INTERNAL"` - `message` (`quoteCart.response.403.error.message`; string, required) — Human readable. - `fields` (`quoteCart.response.403.error.fields`; map, optional) — Only present when the error belongs to specific request fields. ### 422 The request is not valid, see `fields` (`VALIDATION`). #### Example ```json { "error": { "code": "VALIDATION", "message": "The request is not valid.", "fields": { "products": "Unknown product." } } } ``` - `error` (`quoteCart.response.422.error`; object, required) - `code` (`quoteCart.response.422.error.code`; string, required) - one of `"UNAUTHENTICATED"`, `"FORBIDDEN"`, `"NOT_FOUND"`, `"METHOD_NOT_ALLOWED"`, `"VALIDATION"`, `"SLOT_UNAVAILABLE"`, `"DELIVERY_UNAVAILABLE"`, `"PRODUCT_UNAVAILABLE"`, `"COUPON_INVALID"`, `"MINIMUM_NOT_REACHED"`, `"RATE_LIMITED"`, `"INTERNAL"` - `message` (`quoteCart.response.422.error.message`; string, required) — Human readable. - `fields` (`quoteCart.response.422.error.fields`; map, optional) — Only present when the error belongs to specific request fields. ### 429 Too many requests (`RATE_LIMITED`). Wait `Retry-After` seconds. #### Example ```json { "error": { "code": "RATE_LIMITED", "message": "Too many requests." } } ``` - `error` (`quoteCart.response.429.error`; object, required) - `code` (`quoteCart.response.429.error.code`; string, required) - one of `"UNAUTHENTICATED"`, `"FORBIDDEN"`, `"NOT_FOUND"`, `"METHOD_NOT_ALLOWED"`, `"VALIDATION"`, `"SLOT_UNAVAILABLE"`, `"DELIVERY_UNAVAILABLE"`, `"PRODUCT_UNAVAILABLE"`, `"COUPON_INVALID"`, `"MINIMUM_NOT_REACHED"`, `"RATE_LIMITED"`, `"INTERNAL"` - `message` (`quoteCart.response.429.error.message`; string, required) — Human readable. - `fields` (`quoteCart.response.429.error.fields`; map, optional) — Only present when the error belongs to specific request fields. # Get the menu > Categories and products for an order type. A restaurant can run menu plans that change prices or show some products only at certain times. The menu, the product and the quote all follow the plan of the pickup or delivery moment, exactly as the order is charged. Categories are in display order; hidden and empty categories are left out. Products not offered for `orderType` are left out. Source: https://developer.unipage.be/storefront/api/menu/retrieve/ · Markdown: https://developer.unipage.be/storefront/api/menu/retrieve/index.md Path: Storefront API › Menu `GET /menu` Categories and products for an order type. A restaurant can run menu plans that change prices or show some products only at certain times. The menu, the product and the quote all follow the plan of the pickup or delivery moment, exactly as the order is charged. Categories are in display order; hidden and empty categories are left out. Products not offered for `orderType` are left out. ## Authentication Requires one of the following: - `publicKey`, apiKey, in header, header `X-Unipage-Key` - `secretKey`, http, header `Authorization` ## Query parameters - `orderType` (`getMenu.query.orderType`; string, required) — The order type to read for. - one of `"TAKEOUT"`, `"DELIVERY"` - `at` (`getMenu.query.at`; integer, optional) — Unix seconds of the chosen slot, default now. Prices and visibility follow the menu plan of that moment. ## Code samples ### cURL ```bash curl --request GET \ --url 'https://api.unipage.eu/storefront/v1/menu?orderType=TAKEOUT' \ --header 'X-Unipage-Key: ' ``` ### TypeScript ```typescript const url = 'https://api.unipage.eu/storefront/v1/menu?orderType=TAKEOUT'; const options = {method: 'GET', headers: {'X-Unipage-Key': ''}}; fetch(url, options) .then(res => res.json()) .then(json => console.log(json)) .catch(err => console.error(err)); ``` ### Python ```python import requests url = "https://api.unipage.eu/storefront/v1/menu?orderType=TAKEOUT" headers = {"X-Unipage-Key": ""} response = requests.get(url, headers=headers) print(response.text) ``` ## Responses ### 200 The menu. #### Example ```json { "categories": [ { "id": "c4n2w", "name": "Fries", "description": "string", "image": "string", "products": [ { "id": "p7d2x", "name": "Medium fries", "description": "string", "price": 4, "priceType": "PER_UNIT", "image": "string", "available": true, "soldOut": true, "hasOptions": true, "maxPerOrder": 0, "allergens": [ "gluten", "nuts" ], "allergenSources": { "gluten": [ "wheat" ], "nuts": [ "almonds" ] } } ] } ] } ``` - `categories` (`getMenu.response.200.categories`; array, optional) — In display order. Hidden and empty categories are left out. - `id` (`getMenu.response.200.categories.id`; string, optional) - `name` (`getMenu.response.200.categories.name`; string, optional) - `description` (`getMenu.response.200.categories.description`; string | null, optional) - `image` (`getMenu.response.200.categories.image`; string | null, optional) - `products` (`getMenu.response.200.categories.products`; array, optional) - `id` (`getMenu.response.200.categories.products.id`; string, optional) - `name` (`getMenu.response.200.categories.products.name`; string, optional) - `description` (`getMenu.response.200.categories.products.description`; string | null, optional) - `price` (`getMenu.response.200.categories.products.price`; number, optional) - `priceType` (`getMenu.response.200.categories.products.priceType`; string, optional) — Products priced by weight are not supported. - one of `"PER_UNIT"` - `image` (`getMenu.response.200.categories.products.image`; string | null, optional) — Image URL. - `available` (`getMenu.response.200.categories.products.available`; boolean, optional) — `false` means not orderable right now: show it greyed out. - `soldOut` (`getMenu.response.200.categories.products.soldOut`; boolean, optional) — `true` means out of stock. - `hasOptions` (`getMenu.response.200.categories.products.hasOptions`; boolean, optional) — Get the product to read its option groups. - `maxPerOrder` (`getMenu.response.200.categories.products.maxPerOrder`; integer | null, optional) - `allergens` (`getMenu.response.200.categories.products.allergens`; array, optional) - one of `"gluten"`, `"crustaceans"`, `"eggs"`, `"fish"`, `"peanuts"`, `"soybeans"`, `"milk"`, `"nuts"`, `"celery"`, `"mustard"`, `"sesame"`, `"sulphur_dioxide"`, `"lupine"`, `"molluscs"` - `allergenSources` (`getMenu.response.200.categories.products.allergenSources`; map>, optional) — The specific cereal (`wheat`, `spelt`, `khorasan`, `rye`, `barley`, `oats`) or nut (`almonds`, `hazelnuts`, `walnuts`, `cashews`, `pecans`, `brazil_nuts`, `pistachios`, `macadamia`) per allergen, when the restaurant recorded it. EU food-information rules ask you to show it before the order is placed: "gluten (wheat)". ### 401 A missing, unknown or revoked key (`UNAUTHENTICATED`). #### Example ```json { "error": { "code": "UNAUTHENTICATED", "message": "No valid key." } } ``` - `error` (`getMenu.response.401.error`; object, required) - `code` (`getMenu.response.401.error.code`; string, required) - one of `"UNAUTHENTICATED"`, `"FORBIDDEN"`, `"NOT_FOUND"`, `"METHOD_NOT_ALLOWED"`, `"VALIDATION"`, `"SLOT_UNAVAILABLE"`, `"DELIVERY_UNAVAILABLE"`, `"PRODUCT_UNAVAILABLE"`, `"COUPON_INVALID"`, `"MINIMUM_NOT_REACHED"`, `"RATE_LIMITED"`, `"INTERNAL"` - `message` (`getMenu.response.401.error.message`; string, required) — Human readable. - `fields` (`getMenu.response.401.error.fields`; map, optional) — Only present when the error belongs to specific request fields. ### 403 A public key on a secret route, or used from an origin that is not registered for it (`FORBIDDEN`). #### Example ```json { "error": { "code": "FORBIDDEN", "message": "Origin not registered for this key." } } ``` - `error` (`getMenu.response.403.error`; object, required) - `code` (`getMenu.response.403.error.code`; string, required) - one of `"UNAUTHENTICATED"`, `"FORBIDDEN"`, `"NOT_FOUND"`, `"METHOD_NOT_ALLOWED"`, `"VALIDATION"`, `"SLOT_UNAVAILABLE"`, `"DELIVERY_UNAVAILABLE"`, `"PRODUCT_UNAVAILABLE"`, `"COUPON_INVALID"`, `"MINIMUM_NOT_REACHED"`, `"RATE_LIMITED"`, `"INTERNAL"` - `message` (`getMenu.response.403.error.message`; string, required) — Human readable. - `fields` (`getMenu.response.403.error.fields`; map, optional) — Only present when the error belongs to specific request fields. ### 422 The request is not valid, see `fields` (`VALIDATION`). #### Example ```json { "error": { "code": "VALIDATION", "message": "The request is not valid.", "fields": { "products": "Unknown product." } } } ``` - `error` (`getMenu.response.422.error`; object, required) - `code` (`getMenu.response.422.error.code`; string, required) - one of `"UNAUTHENTICATED"`, `"FORBIDDEN"`, `"NOT_FOUND"`, `"METHOD_NOT_ALLOWED"`, `"VALIDATION"`, `"SLOT_UNAVAILABLE"`, `"DELIVERY_UNAVAILABLE"`, `"PRODUCT_UNAVAILABLE"`, `"COUPON_INVALID"`, `"MINIMUM_NOT_REACHED"`, `"RATE_LIMITED"`, `"INTERNAL"` - `message` (`getMenu.response.422.error.message`; string, required) — Human readable. - `fields` (`getMenu.response.422.error.fields`; map, optional) — Only present when the error belongs to specific request fields. ### 429 Too many requests (`RATE_LIMITED`). Wait `Retry-After` seconds. #### Example ```json { "error": { "code": "RATE_LIMITED", "message": "Too many requests." } } ``` - `error` (`getMenu.response.429.error`; object, required) - `code` (`getMenu.response.429.error.code`; string, required) - one of `"UNAUTHENTICATED"`, `"FORBIDDEN"`, `"NOT_FOUND"`, `"METHOD_NOT_ALLOWED"`, `"VALIDATION"`, `"SLOT_UNAVAILABLE"`, `"DELIVERY_UNAVAILABLE"`, `"PRODUCT_UNAVAILABLE"`, `"COUPON_INVALID"`, `"MINIMUM_NOT_REACHED"`, `"RATE_LIMITED"`, `"INTERNAL"` - `message` (`getMenu.response.429.error.message`; string, required) — Human readable. - `fields` (`getMenu.response.429.error.fields`; map, optional) — Only present when the error belongs to specific request fields. # Create an order > Unipage creates the customer, validates everything again (slot and capacity, products orderable and in stock, option rules, delivery zone and minimum, coupon) and places the order. An invalid coupon is a 422 `COUPON_INVALID`, never silently dropped. `paymentType` picks who collects the money (see [Payments](/storefront/payments/)): - `online`: Unipage, with the restaurant's payment provider. The order is `PENDING_PAYMENT` and `next.type` is `payment`: send the shopper's browser to `next.url`. After the payment they are sent to `returnUrl` with `?status=success` (or `?status=failed` for a failed or cancelled payment). Read the outcome from `GET /orders/{id}` (`isPaid`, `status`), never from the query string. - `offline`: the restaurant, at pickup or on delivery. The order is `PLACED` and unpaid. - `external`: you, with your own payment provider, before you place the order. The order is `PLACED` and paid at once. With `customerNotifications: false`, Unipage sends the customer no mail, SMS or push message for this order, now or later: you send your own. See [Customer messages](/storefront/customer-messages/). Source: https://developer.unipage.be/storefront/api/orders/create/ · Markdown: https://developer.unipage.be/storefront/api/orders/create/index.md Path: Storefront API › Orders `POST /orders` Unipage creates the customer, validates everything again (slot and capacity, products orderable and in stock, option rules, delivery zone and minimum, coupon) and places the order. An invalid coupon is a 422 `COUPON_INVALID`, never silently dropped. `paymentType` picks who collects the money (see [Payments](/storefront/payments/)): - `online`: Unipage, with the restaurant's payment provider. The order is `PENDING_PAYMENT` and `next.type` is `payment`: send the shopper's browser to `next.url`. After the payment they are sent to `returnUrl` with `?status=success` (or `?status=failed` for a failed or cancelled payment). Read the outcome from `GET /orders/{id}` (`isPaid`, `status`), never from the query string. - `offline`: the restaurant, at pickup or on delivery. The order is `PLACED` and unpaid. - `external`: you, with your own payment provider, before you place the order. The order is `PLACED` and paid at once. With `customerNotifications: false`, Unipage sends the customer no mail, SMS or push message for this order, now or later: you send your own. See [Customer messages](/storefront/customer-messages/). ## Authentication - `secretKey`, http, header `Authorization` ## Request body _Required._ - `orderType` (`createOrder.orderType`; string, required) - one of `"TAKEOUT"`, `"DELIVERY"` - `slotAt` (`createOrder.slotAt`; integer, required) — Unix seconds of the chosen slot, from the schedule. - `lines` (`createOrder.lines`; array, required) - `productId` (`createOrder.lines.productId`; string, required) - `quantity` (`createOrder.lines.quantity`; integer, required) - min 1 - `notes` (`createOrder.lines.notes`; string, optional) - maxLength 255 - `options` (`createOrder.lines.options`; array, optional) - `groupId` (`createOrder.lines.options.groupId`; string, required) - `optionId` (`createOrder.lines.options.optionId`; string, required) - `quantity` (`createOrder.lines.options.quantity`; integer, optional) — Only matters for `AMOUNT` groups. - default `1` - `customer` (`createOrder.customer`; object, required) — Unipage finds or creates the restaurant's customer by e-mail address. - `firstName` (`createOrder.customer.firstName`; string, required) - maxLength 255 - `lastName` (`createOrder.customer.lastName`; string, required) - maxLength 255 - `email` (`createOrder.customer.email`; string, required) - format `email`; maxLength 255 - `phone` (`createOrder.customer.phone`; string, required) — 6 to 20 digits, spaces and `+ ( ) . / -`. - `paymentType` (`createOrder.paymentType`; string, required) — `online` or `offline`: one of the restaurant's `payment.methods`. `external`: you took the payment yourself; always accepted. See [Payments](/storefront/payments/). - one of `"online"`, `"offline"`, `"external"` - `delivery` (`createOrder.delivery`; object, optional) — Required for `DELIVERY` (every field), ignored for `TAKEOUT`. - `zoneId` (`createOrder.delivery.zoneId`; string, optional) — A zone id from `GET /store`. - `street` (`createOrder.delivery.street`; string, optional) - maxLength 255 - `number` (`createOrder.delivery.number`; string, optional) - maxLength 50 - `postcode` (`createOrder.delivery.postcode`; string, optional) - maxLength 20 - `city` (`createOrder.delivery.city`; string, optional) - maxLength 255 - `payment` (`createOrder.payment`; object, optional) — Only for `external`: where you took the payment, kept on the order for reconciliation. - `provider` (`createOrder.payment.provider`; string, optional) - maxLength 50 - `reference` (`createOrder.payment.reference`; string, optional) - maxLength 255 - `customerNotifications` (`createOrder.customerNotifications`; boolean, optional) — `false`: Unipage sends the customer nothing for this order (no confirmation, status or review message, by mail, SMS or push), now or later. The restaurant's own notifications are not affected. See [Customer messages](/storefront/customer-messages/). - default `true` - `notes` (`createOrder.notes`; string, optional) — Only shown to the restaurant when `notesEnabled` is true. - maxLength 1000 - `coupon` (`createOrder.coupon`; string | null, optional) - `locale` (`createOrder.locale`; string, optional) — Default the restaurant language. Mails, SMS and the order `label` follow it. - one of `"nl"`, `"fr"`, `"en"`, `"de"`, `"es"` - `returnUrl` (`createOrder.returnUrl`; string, optional) — Where the shopper lands after an online payment, with `?status=success` or `?status=failed` added. `{orderId}` is replaced by the order id. It must be on one of the origins of your storefront's public key or on a local origin (`localhost`, `127.0.0.1`, `[::1]`), otherwise 422 `VALIDATION` (`fields.returnUrl`). Without it, the shopper lands on the restaurant's own Unipage thank-you page. - maxLength 2000 ## Example request ```json { "orderType": "TAKEOUT", "slotAt": 1792750500, "lines": [ { "productId": "p7d2x", "quantity": 1, "notes": "no salt", "options": [ { "groupId": "g5t1r", "optionId": "o9w3e", "quantity": 1 } ] } ], "customer": { "firstName": "Ann", "lastName": "Example", "email": "ann@example.com", "phone": "+32470000000" }, "delivery": { "zoneId": "z81qm", "street": "Main Street", "number": "1", "postcode": "1000", "city": "Brussels" }, "paymentType": "online", "payment": { "provider": "stripe", "reference": "pi_3Q8xYz" }, "customerNotifications": true, "notes": "Ring twice", "coupon": "string", "locale": "nl", "returnUrl": "https://shop.example.com/order/{orderId}" } ``` ## Code samples ### cURL ```bash curl --request POST \ --url https://api.unipage.eu/storefront/v1/orders \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data ' { "orderType": "TAKEOUT", "slotAt": 1792750500, "lines": [ { "productId": "p7d2x", "quantity": 1, "notes": "no salt", "options": [ { "groupId": "g5t1r", "optionId": "o9w3e", "quantity": 1 } ] } ], "customer": { "firstName": "Ann", "lastName": "Example", "email": "ann@example.com", "phone": "+32470000000" }, "delivery": { "zoneId": "z81qm", "street": "Main Street", "number": "1", "postcode": "1000", "city": "Brussels" }, "paymentType": "online", "payment": { "provider": "stripe", "reference": "pi_3Q8xYz" }, "customerNotifications": true, "notes": "Ring twice", "coupon": "string", "locale": "nl", "returnUrl": "https://shop.example.com/order/{orderId}" } ' ``` ### TypeScript ```typescript const url = 'https://api.unipage.eu/storefront/v1/orders'; const options = { method: 'POST', headers: {'Content-Type': 'application/json', Authorization: 'Bearer '}, body: JSON.stringify({ orderType: 'TAKEOUT', slotAt: 1792750500, lines: [ { productId: 'p7d2x', quantity: 1, notes: 'no salt', options: [{groupId: 'g5t1r', optionId: 'o9w3e', quantity: 1}] } ], customer: { firstName: 'Ann', lastName: 'Example', email: 'ann@example.com', phone: '+32470000000' }, delivery: { zoneId: 'z81qm', street: 'Main Street', number: '1', postcode: '1000', city: 'Brussels' }, paymentType: 'online', payment: {provider: 'stripe', reference: 'pi_3Q8xYz'}, customerNotifications: true, notes: 'Ring twice', coupon: 'string', locale: 'nl', returnUrl: 'https://shop.example.com/order/{orderId}' }) }; fetch(url, options) .then(res => res.json()) .then(json => console.log(json)) .catch(err => console.error(err)); ``` ### Python ```python import requests url = "https://api.unipage.eu/storefront/v1/orders" payload = { "orderType": "TAKEOUT", "slotAt": 1792750500, "lines": [ { "productId": "p7d2x", "quantity": 1, "notes": "no salt", "options": [ { "groupId": "g5t1r", "optionId": "o9w3e", "quantity": 1 } ] } ], "customer": { "firstName": "Ann", "lastName": "Example", "email": "ann@example.com", "phone": "+32470000000" }, "delivery": { "zoneId": "z81qm", "street": "Main Street", "number": "1", "postcode": "1000", "city": "Brussels" }, "paymentType": "online", "payment": { "provider": "stripe", "reference": "pi_3Q8xYz" }, "customerNotifications": True, "notes": "Ring twice", "coupon": "string", "locale": "nl", "returnUrl": "https://shop.example.com/order/{orderId}" } headers = { "Content-Type": "application/json", "Authorization": "Bearer " } response = requests.post(url, json=payload, headers=headers) print(response.text) ``` ## Responses ### 201 The order was placed. #### Example ```json { "order": { "id": "q4m8z", "status": "PLACED", "label": "Order received", "orderType": "TAKEOUT", "slotAt": 1792750500, "paymentType": "online", "isPaid": true, "customerNotifications": true, "lines": [ { "productId": "p7d2x", "name": "Medium fries", "quantity": 1, "optionsLabel": "", "total": 4 } ], "subtotal": 4, "deliveryCost": 0, "serviceCost": 0.5, "couponDiscount": 0, "total": 4.5, "currency": "EUR", "locale": "nl", "customer": { "firstName": "Ann", "lastName": "Example", "email": "ann@example.com" }, "delivery": { "street": "string", "number": "string", "postcode": "string", "city": "string", "zone": "string" }, "createdAt": 1792749000 }, "next": { "type": "payment", "url": "https://demo.unipage.eu/pay/mollie/x7k2p" } } ``` - `order` (`createOrder.response.201.order`; object, optional) - `id` (`createOrder.response.201.order.id`; string, optional) - `status` (`createOrder.response.201.order.status`; string, optional) — - `PENDING_PAYMENT`: an `online` order, until the payment provider confirms. - `PAYMENT_FAILED`: the online payment failed or was cancelled. - `PLACED`: the restaurant received the order (`offline`, `external`, or a confirmed payment). - `CHEF_STARTED`: the restaurant started preparing it. - `CHEF_COMPLETED`: ready for pickup or out for delivery. - `DECLINED`, `CANCELED`: the restaurant refused or cancelled it. - `PENDING_PLACEMENT`: being placed; you will rarely see it. New statuses may be added: treat an unknown one as "in progress". - one of `"PENDING_PLACEMENT"`, `"PENDING_PAYMENT"`, `"PAYMENT_FAILED"`, `"PLACED"`, `"CHEF_STARTED"`, `"CHEF_COMPLETED"`, `"DECLINED"`, `"CANCELED"` - `label` (`createOrder.response.201.order.label`; string, optional) — The status in the order's locale. - `orderType` (`createOrder.response.201.order.orderType`; string, optional) - one of `"TAKEOUT"`, `"DELIVERY"` - `slotAt` (`createOrder.response.201.order.slotAt`; integer, optional) - `paymentType` (`createOrder.response.201.order.paymentType`; string, optional) - one of `"online"`, `"offline"`, `"external"` - `isPaid` (`createOrder.response.201.order.isPaid`; boolean, optional) — Read the payment outcome from this, never from the return URL. - `customerNotifications` (`createOrder.response.201.order.customerNotifications`; boolean, optional) — `false` when Unipage sends the customer nothing for this order. - `lines` (`createOrder.response.201.order.lines`; array, optional) — Includes the restaurant's charges as lines with `productId: null`. - `productId` (`createOrder.response.201.order.lines.productId`; string | null, optional) - `name` (`createOrder.response.201.order.lines.name`; string, optional) - `quantity` (`createOrder.response.201.order.lines.quantity`; integer, optional) - `optionsLabel` (`createOrder.response.201.order.lines.optionsLabel`; string, optional) - `total` (`createOrder.response.201.order.lines.total`; number, optional) - `subtotal` (`createOrder.response.201.order.subtotal`; number, optional) - `deliveryCost` (`createOrder.response.201.order.deliveryCost`; number, optional) - `serviceCost` (`createOrder.response.201.order.serviceCost`; number, optional) - `couponDiscount` (`createOrder.response.201.order.couponDiscount`; number, optional) — Every reduction (coupon, promotional offer). - `total` (`createOrder.response.201.order.total`; number, optional) - `currency` (`createOrder.response.201.order.currency`; string, optional) - `locale` (`createOrder.response.201.order.locale`; string, optional) - `customer` (`createOrder.response.201.order.customer`; object, optional) - `firstName` (`createOrder.response.201.order.customer.firstName`; string, optional) - `lastName` (`createOrder.response.201.order.customer.lastName`; string, optional) - `email` (`createOrder.response.201.order.customer.email`; string, optional) - `delivery` (`createOrder.response.201.order.delivery`; object | null, optional) — For `DELIVERY` orders, else `null`. - `street` (`createOrder.response.201.order.delivery.street`; string, optional) - `number` (`createOrder.response.201.order.delivery.number`; string, optional) - `postcode` (`createOrder.response.201.order.delivery.postcode`; string, optional) - `city` (`createOrder.response.201.order.delivery.city`; string, optional) - `zone` (`createOrder.response.201.order.delivery.zone`; string, optional) - `createdAt` (`createOrder.response.201.order.createdAt`; integer, optional) — Unix seconds. - `next` (`createOrder.response.201.next`; object, optional) — What to do with the shopper next. - `type` (`createOrder.response.201.next.type`; string, optional) — `payment` (`online`): send the shopper's browser to `next.url`. `confirmation` (`offline`, `external`): the order is placed, nothing is left to pay through Unipage. - one of `"payment"`, `"confirmation"` - `url` (`createOrder.response.201.next.url`; string, optional) — Only for `payment`. The payment page on the restaurant's Unipage webshop domain. Treat it as opaque: send the browser to it as is. ### 401 A missing, unknown or revoked key (`UNAUTHENTICATED`). #### Example ```json { "error": { "code": "UNAUTHENTICATED", "message": "No valid key." } } ``` - `error` (`createOrder.response.401.error`; object, required) - `code` (`createOrder.response.401.error.code`; string, required) - one of `"UNAUTHENTICATED"`, `"FORBIDDEN"`, `"NOT_FOUND"`, `"METHOD_NOT_ALLOWED"`, `"VALIDATION"`, `"SLOT_UNAVAILABLE"`, `"DELIVERY_UNAVAILABLE"`, `"PRODUCT_UNAVAILABLE"`, `"COUPON_INVALID"`, `"MINIMUM_NOT_REACHED"`, `"RATE_LIMITED"`, `"INTERNAL"` - `message` (`createOrder.response.401.error.message`; string, required) — Human readable. - `fields` (`createOrder.response.401.error.fields`; map, optional) — Only present when the error belongs to specific request fields. ### 403 A public key on a secret route, or used from an origin that is not registered for it (`FORBIDDEN`). #### Example ```json { "error": { "code": "FORBIDDEN", "message": "Origin not registered for this key." } } ``` - `error` (`createOrder.response.403.error`; object, required) - `code` (`createOrder.response.403.error.code`; string, required) - one of `"UNAUTHENTICATED"`, `"FORBIDDEN"`, `"NOT_FOUND"`, `"METHOD_NOT_ALLOWED"`, `"VALIDATION"`, `"SLOT_UNAVAILABLE"`, `"DELIVERY_UNAVAILABLE"`, `"PRODUCT_UNAVAILABLE"`, `"COUPON_INVALID"`, `"MINIMUM_NOT_REACHED"`, `"RATE_LIMITED"`, `"INTERNAL"` - `message` (`createOrder.response.403.error.message`; string, required) — Human readable. - `fields` (`createOrder.response.403.error.fields`; map, optional) — Only present when the error belongs to specific request fields. ### 422 The order cannot be placed. `code` is one of `VALIDATION`, `SLOT_UNAVAILABLE`, `DELIVERY_UNAVAILABLE`, `PRODUCT_UNAVAILABLE`, `COUPON_INVALID` or `MINIMUM_NOT_REACHED`. #### Example ```json { "error": { "code": "SLOT_UNAVAILABLE", "message": "The chosen time slot is no longer available." } } ``` - `error` (`createOrder.response.422.error`; object, required) - `code` (`createOrder.response.422.error.code`; string, required) - one of `"UNAUTHENTICATED"`, `"FORBIDDEN"`, `"NOT_FOUND"`, `"METHOD_NOT_ALLOWED"`, `"VALIDATION"`, `"SLOT_UNAVAILABLE"`, `"DELIVERY_UNAVAILABLE"`, `"PRODUCT_UNAVAILABLE"`, `"COUPON_INVALID"`, `"MINIMUM_NOT_REACHED"`, `"RATE_LIMITED"`, `"INTERNAL"` - `message` (`createOrder.response.422.error.message`; string, required) — Human readable. - `fields` (`createOrder.response.422.error.fields`; map, optional) — Only present when the error belongs to specific request fields. ### 429 Too many requests (`RATE_LIMITED`). Wait `Retry-After` seconds. #### Example ```json { "error": { "code": "RATE_LIMITED", "message": "Too many requests." } } ``` - `error` (`createOrder.response.429.error`; object, required) - `code` (`createOrder.response.429.error.code`; string, required) - one of `"UNAUTHENTICATED"`, `"FORBIDDEN"`, `"NOT_FOUND"`, `"METHOD_NOT_ALLOWED"`, `"VALIDATION"`, `"SLOT_UNAVAILABLE"`, `"DELIVERY_UNAVAILABLE"`, `"PRODUCT_UNAVAILABLE"`, `"COUPON_INVALID"`, `"MINIMUM_NOT_REACHED"`, `"RATE_LIMITED"`, `"INTERNAL"` - `message` (`createOrder.response.429.error.message`; string, required) — Human readable. - `fields` (`createOrder.response.429.error.fields`; map, optional) — Only present when the error belongs to specific request fields. # Get an order > Reads an order back, with its status and payment state. Source: https://developer.unipage.be/storefront/api/orders/retrieve/ · Markdown: https://developer.unipage.be/storefront/api/orders/retrieve/index.md Path: Storefront API › Orders `GET /orders/{id}` Reads an order back, with its status and payment state. ## Authentication - `secretKey`, http, header `Authorization` ## Path parameters - `id` (`getOrder.path.id`; string, required) — The order id. ## Code samples ### cURL ```bash curl --request GET \ --url https://api.unipage.eu/storefront/v1/orders/q4m8z \ --header 'Authorization: Bearer ' ``` ### TypeScript ```typescript const url = 'https://api.unipage.eu/storefront/v1/orders/q4m8z'; const options = {method: 'GET', headers: {Authorization: 'Bearer '}}; fetch(url, options) .then(res => res.json()) .then(json => console.log(json)) .catch(err => console.error(err)); ``` ### Python ```python import requests url = "https://api.unipage.eu/storefront/v1/orders/q4m8z" headers = {"Authorization": "Bearer "} response = requests.get(url, headers=headers) print(response.text) ``` ## Responses ### 200 The order. #### Example ```json { "id": "q4m8z", "status": "PLACED", "label": "Order received", "orderType": "TAKEOUT", "slotAt": 1792750500, "paymentType": "online", "isPaid": true, "customerNotifications": true, "lines": [ { "productId": "p7d2x", "name": "Medium fries", "quantity": 1, "optionsLabel": "", "total": 4 } ], "subtotal": 4, "deliveryCost": 0, "serviceCost": 0.5, "couponDiscount": 0, "total": 4.5, "currency": "EUR", "locale": "nl", "customer": { "firstName": "Ann", "lastName": "Example", "email": "ann@example.com" }, "delivery": { "street": "string", "number": "string", "postcode": "string", "city": "string", "zone": "string" }, "createdAt": 1792749000 } ``` - `id` (`getOrder.response.200.id`; string, optional) - `status` (`getOrder.response.200.status`; string, optional) — - `PENDING_PAYMENT`: an `online` order, until the payment provider confirms. - `PAYMENT_FAILED`: the online payment failed or was cancelled. - `PLACED`: the restaurant received the order (`offline`, `external`, or a confirmed payment). - `CHEF_STARTED`: the restaurant started preparing it. - `CHEF_COMPLETED`: ready for pickup or out for delivery. - `DECLINED`, `CANCELED`: the restaurant refused or cancelled it. - `PENDING_PLACEMENT`: being placed; you will rarely see it. New statuses may be added: treat an unknown one as "in progress". - one of `"PENDING_PLACEMENT"`, `"PENDING_PAYMENT"`, `"PAYMENT_FAILED"`, `"PLACED"`, `"CHEF_STARTED"`, `"CHEF_COMPLETED"`, `"DECLINED"`, `"CANCELED"` - `label` (`getOrder.response.200.label`; string, optional) — The status in the order's locale. - `orderType` (`getOrder.response.200.orderType`; string, optional) - one of `"TAKEOUT"`, `"DELIVERY"` - `slotAt` (`getOrder.response.200.slotAt`; integer, optional) - `paymentType` (`getOrder.response.200.paymentType`; string, optional) - one of `"online"`, `"offline"`, `"external"` - `isPaid` (`getOrder.response.200.isPaid`; boolean, optional) — Read the payment outcome from this, never from the return URL. - `customerNotifications` (`getOrder.response.200.customerNotifications`; boolean, optional) — `false` when Unipage sends the customer nothing for this order. - `lines` (`getOrder.response.200.lines`; array, optional) — Includes the restaurant's charges as lines with `productId: null`. - `productId` (`getOrder.response.200.lines.productId`; string | null, optional) - `name` (`getOrder.response.200.lines.name`; string, optional) - `quantity` (`getOrder.response.200.lines.quantity`; integer, optional) - `optionsLabel` (`getOrder.response.200.lines.optionsLabel`; string, optional) - `total` (`getOrder.response.200.lines.total`; number, optional) - `subtotal` (`getOrder.response.200.subtotal`; number, optional) - `deliveryCost` (`getOrder.response.200.deliveryCost`; number, optional) - `serviceCost` (`getOrder.response.200.serviceCost`; number, optional) - `couponDiscount` (`getOrder.response.200.couponDiscount`; number, optional) — Every reduction (coupon, promotional offer). - `total` (`getOrder.response.200.total`; number, optional) - `currency` (`getOrder.response.200.currency`; string, optional) - `locale` (`getOrder.response.200.locale`; string, optional) - `customer` (`getOrder.response.200.customer`; object, optional) - `firstName` (`getOrder.response.200.customer.firstName`; string, optional) - `lastName` (`getOrder.response.200.customer.lastName`; string, optional) - `email` (`getOrder.response.200.customer.email`; string, optional) - `delivery` (`getOrder.response.200.delivery`; object | null, optional) — For `DELIVERY` orders, else `null`. - `street` (`getOrder.response.200.delivery.street`; string, optional) - `number` (`getOrder.response.200.delivery.number`; string, optional) - `postcode` (`getOrder.response.200.delivery.postcode`; string, optional) - `city` (`getOrder.response.200.delivery.city`; string, optional) - `zone` (`getOrder.response.200.delivery.zone`; string, optional) - `createdAt` (`getOrder.response.200.createdAt`; integer, optional) — Unix seconds. ### 401 A missing, unknown or revoked key (`UNAUTHENTICATED`). #### Example ```json { "error": { "code": "UNAUTHENTICATED", "message": "No valid key." } } ``` - `error` (`getOrder.response.401.error`; object, required) - `code` (`getOrder.response.401.error.code`; string, required) - one of `"UNAUTHENTICATED"`, `"FORBIDDEN"`, `"NOT_FOUND"`, `"METHOD_NOT_ALLOWED"`, `"VALIDATION"`, `"SLOT_UNAVAILABLE"`, `"DELIVERY_UNAVAILABLE"`, `"PRODUCT_UNAVAILABLE"`, `"COUPON_INVALID"`, `"MINIMUM_NOT_REACHED"`, `"RATE_LIMITED"`, `"INTERNAL"` - `message` (`getOrder.response.401.error.message`; string, required) — Human readable. - `fields` (`getOrder.response.401.error.fields`; map, optional) — Only present when the error belongs to specific request fields. ### 403 A public key on a secret route, or used from an origin that is not registered for it (`FORBIDDEN`). #### Example ```json { "error": { "code": "FORBIDDEN", "message": "Origin not registered for this key." } } ``` - `error` (`getOrder.response.403.error`; object, required) - `code` (`getOrder.response.403.error.code`; string, required) - one of `"UNAUTHENTICATED"`, `"FORBIDDEN"`, `"NOT_FOUND"`, `"METHOD_NOT_ALLOWED"`, `"VALIDATION"`, `"SLOT_UNAVAILABLE"`, `"DELIVERY_UNAVAILABLE"`, `"PRODUCT_UNAVAILABLE"`, `"COUPON_INVALID"`, `"MINIMUM_NOT_REACHED"`, `"RATE_LIMITED"`, `"INTERNAL"` - `message` (`getOrder.response.403.error.message`; string, required) — Human readable. - `fields` (`getOrder.response.403.error.fields`; map, optional) — Only present when the error belongs to specific request fields. ### 404 Unknown id, or an order of another restaurant. #### Example ```json { "error": { "code": "UNAUTHENTICATED", "message": "string", "fields": { "customer.email": "Invalid e-mail" } } } ``` - `error` (`getOrder.response.404.error`; object, required) - `code` (`getOrder.response.404.error.code`; string, required) - one of `"UNAUTHENTICATED"`, `"FORBIDDEN"`, `"NOT_FOUND"`, `"METHOD_NOT_ALLOWED"`, `"VALIDATION"`, `"SLOT_UNAVAILABLE"`, `"DELIVERY_UNAVAILABLE"`, `"PRODUCT_UNAVAILABLE"`, `"COUPON_INVALID"`, `"MINIMUM_NOT_REACHED"`, `"RATE_LIMITED"`, `"INTERNAL"` - `message` (`getOrder.response.404.error.message`; string, required) — Human readable. - `fields` (`getOrder.response.404.error.fields`; map, optional) — Only present when the error belongs to specific request fields. ### 429 Too many requests (`RATE_LIMITED`). Wait `Retry-After` seconds. #### Example ```json { "error": { "code": "RATE_LIMITED", "message": "Too many requests." } } ``` - `error` (`getOrder.response.429.error`; object, required) - `code` (`getOrder.response.429.error.code`; string, required) - one of `"UNAUTHENTICATED"`, `"FORBIDDEN"`, `"NOT_FOUND"`, `"METHOD_NOT_ALLOWED"`, `"VALIDATION"`, `"SLOT_UNAVAILABLE"`, `"DELIVERY_UNAVAILABLE"`, `"PRODUCT_UNAVAILABLE"`, `"COUPON_INVALID"`, `"MINIMUM_NOT_REACHED"`, `"RATE_LIMITED"`, `"INTERNAL"` - `message` (`getOrder.response.429.error.message`; string, required) — Human readable. - `fields` (`getOrder.response.429.error.fields`; map, optional) — Only present when the error belongs to specific request fields. # Get a product > A product with its option groups. Takes the same `orderType` and `at` as the menu; a product hidden at that moment answers `"available": false`. The quote and the order enforce the same option rules. Source: https://developer.unipage.be/storefront/api/products/retrieve/ · Markdown: https://developer.unipage.be/storefront/api/products/retrieve/index.md Path: Storefront API › Menu `GET /products/{id}` A product with its option groups. Takes the same `orderType` and `at` as the menu; a product hidden at that moment answers `"available": false`. The quote and the order enforce the same option rules. ## Authentication Requires one of the following: - `publicKey`, apiKey, in header, header `X-Unipage-Key` - `secretKey`, http, header `Authorization` ## Path parameters - `id` (`getProduct.path.id`; string, required) — The product id. ## Query parameters - `orderType` (`getProduct.query.orderType`; string, required) — The order type to read for. - one of `"TAKEOUT"`, `"DELIVERY"` - `at` (`getProduct.query.at`; integer, optional) — Unix seconds of the chosen slot, default now. Prices and visibility follow the menu plan of that moment. ## Code samples ### cURL ```bash curl --request GET \ --url 'https://api.unipage.eu/storefront/v1/products/p7d2x?orderType=TAKEOUT' \ --header 'X-Unipage-Key: ' ``` ### TypeScript ```typescript const url = 'https://api.unipage.eu/storefront/v1/products/p7d2x?orderType=TAKEOUT'; const options = {method: 'GET', headers: {'X-Unipage-Key': ''}}; fetch(url, options) .then(res => res.json()) .then(json => console.log(json)) .catch(err => console.error(err)); ``` ### Python ```python import requests url = "https://api.unipage.eu/storefront/v1/products/p7d2x?orderType=TAKEOUT" headers = {"X-Unipage-Key": ""} response = requests.get(url, headers=headers) print(response.text) ``` ## Responses ### 200 The product and its option groups. #### Example ```json { "id": "p7d2x", "name": "Medium fries", "description": "string", "price": 4, "priceType": "PER_UNIT", "image": "string", "available": true, "soldOut": true, "hasOptions": true, "maxPerOrder": 0, "allergens": [ "gluten", "nuts" ], "allergenSources": { "gluten": [ "wheat" ], "nuts": [ "almonds" ] }, "optionGroups": [ { "id": "g5t1r", "name": "Toppings", "type": "RADIO", "min": 1, "max": 3, "required": true, "options": [ { "id": "o9w3e", "name": "Feta", "price": 0, "available": true } ] } ] } ``` - `id` (`getProduct.response.200.id`; string, optional) - `name` (`getProduct.response.200.name`; string, optional) - `description` (`getProduct.response.200.description`; string | null, optional) - `price` (`getProduct.response.200.price`; number, optional) - `priceType` (`getProduct.response.200.priceType`; string, optional) — Products priced by weight are not supported. - one of `"PER_UNIT"` - `image` (`getProduct.response.200.image`; string | null, optional) — Image URL. - `available` (`getProduct.response.200.available`; boolean, optional) — `false` means not orderable right now: show it greyed out. - `soldOut` (`getProduct.response.200.soldOut`; boolean, optional) — `true` means out of stock. - `hasOptions` (`getProduct.response.200.hasOptions`; boolean, optional) — Get the product to read its option groups. - `maxPerOrder` (`getProduct.response.200.maxPerOrder`; integer | null, optional) - `allergens` (`getProduct.response.200.allergens`; array, optional) - one of `"gluten"`, `"crustaceans"`, `"eggs"`, `"fish"`, `"peanuts"`, `"soybeans"`, `"milk"`, `"nuts"`, `"celery"`, `"mustard"`, `"sesame"`, `"sulphur_dioxide"`, `"lupine"`, `"molluscs"` - `allergenSources` (`getProduct.response.200.allergenSources`; map>, optional) — The specific cereal (`wheat`, `spelt`, `khorasan`, `rye`, `barley`, `oats`) or nut (`almonds`, `hazelnuts`, `walnuts`, `cashews`, `pecans`, `brazil_nuts`, `pistachios`, `macadamia`) per allergen, when the restaurant recorded it. EU food-information rules ask you to show it before the order is placed: "gluten (wheat)". - `optionGroups` (`getProduct.response.200.optionGroups`; array, optional) - `id` (`getProduct.response.200.optionGroups.id`; string, optional) - `name` (`getProduct.response.200.optionGroups.name`; string, optional) - `type` (`getProduct.response.200.optionGroups.type`; string, optional) — - `RADIO`, `SELECT`: one option; required unless `required: false` (then zero or one). - `CHECKBOX`: between `min` and `max` options. - `AMOUNT`: a quantity per option; the sum is between `min` and `max`. - one of `"RADIO"`, `"SELECT"`, `"CHECKBOX"`, `"AMOUNT"` - `min` (`getProduct.response.200.optionGroups.min`; integer, optional) - `max` (`getProduct.response.200.optionGroups.max`; integer | null, optional) — `0` or `null` means no maximum. - `required` (`getProduct.response.200.optionGroups.required`; boolean, optional) - `options` (`getProduct.response.200.optionGroups.options`; array, optional) - `id` (`getProduct.response.200.optionGroups.options.id`; string, optional) - `name` (`getProduct.response.200.optionGroups.options.name`; string, optional) - `price` (`getProduct.response.200.optionGroups.options.price`; number, optional) - `available` (`getProduct.response.200.optionGroups.options.available`; boolean, optional) ### 401 A missing, unknown or revoked key (`UNAUTHENTICATED`). #### Example ```json { "error": { "code": "UNAUTHENTICATED", "message": "No valid key." } } ``` - `error` (`getProduct.response.401.error`; object, required) - `code` (`getProduct.response.401.error.code`; string, required) - one of `"UNAUTHENTICATED"`, `"FORBIDDEN"`, `"NOT_FOUND"`, `"METHOD_NOT_ALLOWED"`, `"VALIDATION"`, `"SLOT_UNAVAILABLE"`, `"DELIVERY_UNAVAILABLE"`, `"PRODUCT_UNAVAILABLE"`, `"COUPON_INVALID"`, `"MINIMUM_NOT_REACHED"`, `"RATE_LIMITED"`, `"INTERNAL"` - `message` (`getProduct.response.401.error.message`; string, required) — Human readable. - `fields` (`getProduct.response.401.error.fields`; map, optional) — Only present when the error belongs to specific request fields. ### 403 A public key on a secret route, or used from an origin that is not registered for it (`FORBIDDEN`). #### Example ```json { "error": { "code": "FORBIDDEN", "message": "Origin not registered for this key." } } ``` - `error` (`getProduct.response.403.error`; object, required) - `code` (`getProduct.response.403.error.code`; string, required) - one of `"UNAUTHENTICATED"`, `"FORBIDDEN"`, `"NOT_FOUND"`, `"METHOD_NOT_ALLOWED"`, `"VALIDATION"`, `"SLOT_UNAVAILABLE"`, `"DELIVERY_UNAVAILABLE"`, `"PRODUCT_UNAVAILABLE"`, `"COUPON_INVALID"`, `"MINIMUM_NOT_REACHED"`, `"RATE_LIMITED"`, `"INTERNAL"` - `message` (`getProduct.response.403.error.message`; string, required) — Human readable. - `fields` (`getProduct.response.403.error.fields`; map, optional) — Only present when the error belongs to specific request fields. ### 404 Unknown route, product or order (`NOT_FOUND`). #### Example ```json { "error": { "code": "NOT_FOUND", "message": "Not found." } } ``` - `error` (`getProduct.response.404.error`; object, required) - `code` (`getProduct.response.404.error.code`; string, required) - one of `"UNAUTHENTICATED"`, `"FORBIDDEN"`, `"NOT_FOUND"`, `"METHOD_NOT_ALLOWED"`, `"VALIDATION"`, `"SLOT_UNAVAILABLE"`, `"DELIVERY_UNAVAILABLE"`, `"PRODUCT_UNAVAILABLE"`, `"COUPON_INVALID"`, `"MINIMUM_NOT_REACHED"`, `"RATE_LIMITED"`, `"INTERNAL"` - `message` (`getProduct.response.404.error.message`; string, required) — Human readable. - `fields` (`getProduct.response.404.error.fields`; map, optional) — Only present when the error belongs to specific request fields. ### 429 Too many requests (`RATE_LIMITED`). Wait `Retry-After` seconds. #### Example ```json { "error": { "code": "RATE_LIMITED", "message": "Too many requests." } } ``` - `error` (`getProduct.response.429.error`; object, required) - `code` (`getProduct.response.429.error.code`; string, required) - one of `"UNAUTHENTICATED"`, `"FORBIDDEN"`, `"NOT_FOUND"`, `"METHOD_NOT_ALLOWED"`, `"VALIDATION"`, `"SLOT_UNAVAILABLE"`, `"DELIVERY_UNAVAILABLE"`, `"PRODUCT_UNAVAILABLE"`, `"COUPON_INVALID"`, `"MINIMUM_NOT_REACHED"`, `"RATE_LIMITED"`, `"INTERNAL"` - `message` (`getProduct.response.429.error.message`; string, required) — Human readable. - `fields` (`getProduct.response.429.error.fields`; map, optional) — Only present when the error belongs to specific request fields. # Get the schedule > Selectable pickup or delivery slots for the coming days. Past slots and slots too close to now are left out. Some products need a longer preparation time or must be ordered days in advance; with `products` the slots respect those rules, exactly as `POST /orders` will. Send it whenever the cart is not empty, and reload the schedule when the set of products changes. Source: https://developer.unipage.be/storefront/api/schedule/retrieve/ · Markdown: https://developer.unipage.be/storefront/api/schedule/retrieve/index.md Path: Storefront API › Schedule `GET /schedule` Selectable pickup or delivery slots for the coming days. Past slots and slots too close to now are left out. Some products need a longer preparation time or must be ordered days in advance; with `products` the slots respect those rules, exactly as `POST /orders` will. Send it whenever the cart is not empty, and reload the schedule when the set of products changes. ## Authentication Requires one of the following: - `publicKey`, apiKey, in header, header `X-Unipage-Key` - `secretKey`, http, header `Authorization` ## Query parameters - `orderType` (`getSchedule.query.orderType`; string, required) — The order type to read for. - one of `"TAKEOUT"`, `"DELIVERY"` - `days` (`getSchedule.query.days`; integer, optional) — Number of days, starting today in the restaurant's timezone. A value above 14 is read as 14. - min 1; max 14; default `7` - `products` (`getSchedule.query.products`; string, optional) — The comma-separated product ids in the cart, at most 100. An unknown id is a 422 `VALIDATION` (`fields.products`). ## Code samples ### cURL ```bash curl --request GET \ --url 'https://api.unipage.eu/storefront/v1/schedule?orderType=TAKEOUT' \ --header 'X-Unipage-Key: ' ``` ### TypeScript ```typescript const url = 'https://api.unipage.eu/storefront/v1/schedule?orderType=TAKEOUT'; const options = {method: 'GET', headers: {'X-Unipage-Key': ''}}; fetch(url, options) .then(res => res.json()) .then(json => console.log(json)) .catch(err => console.error(err)); ``` ### Python ```python import requests url = "https://api.unipage.eu/storefront/v1/schedule?orderType=TAKEOUT" headers = {"X-Unipage-Key": ""} response = requests.get(url, headers=headers) print(response.text) ``` ## Responses ### 200 The schedule. #### Example ```json { "timezone": "Europe/Brussels", "days": [ { "date": "2026-10-23", "weekday": "FRI", "isOpen": true, "slots": [ { "time": "12:15", "at": 1792750500, "available": true } ] } ] } ``` - `timezone` (`getSchedule.response.200.timezone`; string, optional) - `days` (`getSchedule.response.200.days`; array, optional) - `date` (`getSchedule.response.200.days.date`; string, optional) — `YYYY-MM-DD` in the restaurant's timezone. - `weekday` (`getSchedule.response.200.days.weekday`; string, optional) - `isOpen` (`getSchedule.response.200.days.isOpen`; boolean, optional) — `false` (with `slots: []`) for a day without a selectable slot. - `slots` (`getSchedule.response.200.days.slots`; array, optional) - `time` (`getSchedule.response.200.days.slots.time`; string, optional) — `HH:MM` in the restaurant's timezone. - `at` (`getSchedule.response.200.days.slots.at`; integer, optional) — Unix seconds. Send it as `slotAt`. - `available` (`getSchedule.response.200.days.slots.available`; boolean, optional) — `false` means the slot exists but is full. ### 401 A missing, unknown or revoked key (`UNAUTHENTICATED`). #### Example ```json { "error": { "code": "UNAUTHENTICATED", "message": "No valid key." } } ``` - `error` (`getSchedule.response.401.error`; object, required) - `code` (`getSchedule.response.401.error.code`; string, required) - one of `"UNAUTHENTICATED"`, `"FORBIDDEN"`, `"NOT_FOUND"`, `"METHOD_NOT_ALLOWED"`, `"VALIDATION"`, `"SLOT_UNAVAILABLE"`, `"DELIVERY_UNAVAILABLE"`, `"PRODUCT_UNAVAILABLE"`, `"COUPON_INVALID"`, `"MINIMUM_NOT_REACHED"`, `"RATE_LIMITED"`, `"INTERNAL"` - `message` (`getSchedule.response.401.error.message`; string, required) — Human readable. - `fields` (`getSchedule.response.401.error.fields`; map, optional) — Only present when the error belongs to specific request fields. ### 403 A public key on a secret route, or used from an origin that is not registered for it (`FORBIDDEN`). #### Example ```json { "error": { "code": "FORBIDDEN", "message": "Origin not registered for this key." } } ``` - `error` (`getSchedule.response.403.error`; object, required) - `code` (`getSchedule.response.403.error.code`; string, required) - one of `"UNAUTHENTICATED"`, `"FORBIDDEN"`, `"NOT_FOUND"`, `"METHOD_NOT_ALLOWED"`, `"VALIDATION"`, `"SLOT_UNAVAILABLE"`, `"DELIVERY_UNAVAILABLE"`, `"PRODUCT_UNAVAILABLE"`, `"COUPON_INVALID"`, `"MINIMUM_NOT_REACHED"`, `"RATE_LIMITED"`, `"INTERNAL"` - `message` (`getSchedule.response.403.error.message`; string, required) — Human readable. - `fields` (`getSchedule.response.403.error.fields`; map, optional) — Only present when the error belongs to specific request fields. ### 422 The request is not valid, see `fields` (`VALIDATION`). #### Example ```json { "error": { "code": "VALIDATION", "message": "The request is not valid.", "fields": { "products": "Unknown product." } } } ``` - `error` (`getSchedule.response.422.error`; object, required) - `code` (`getSchedule.response.422.error.code`; string, required) - one of `"UNAUTHENTICATED"`, `"FORBIDDEN"`, `"NOT_FOUND"`, `"METHOD_NOT_ALLOWED"`, `"VALIDATION"`, `"SLOT_UNAVAILABLE"`, `"DELIVERY_UNAVAILABLE"`, `"PRODUCT_UNAVAILABLE"`, `"COUPON_INVALID"`, `"MINIMUM_NOT_REACHED"`, `"RATE_LIMITED"`, `"INTERNAL"` - `message` (`getSchedule.response.422.error.message`; string, required) — Human readable. - `fields` (`getSchedule.response.422.error.fields`; map, optional) — Only present when the error belongs to specific request fields. ### 429 Too many requests (`RATE_LIMITED`). Wait `Retry-After` seconds. #### Example ```json { "error": { "code": "RATE_LIMITED", "message": "Too many requests." } } ``` - `error` (`getSchedule.response.429.error`; object, required) - `code` (`getSchedule.response.429.error.code`; string, required) - one of `"UNAUTHENTICATED"`, `"FORBIDDEN"`, `"NOT_FOUND"`, `"METHOD_NOT_ALLOWED"`, `"VALIDATION"`, `"SLOT_UNAVAILABLE"`, `"DELIVERY_UNAVAILABLE"`, `"PRODUCT_UNAVAILABLE"`, `"COUPON_INVALID"`, `"MINIMUM_NOT_REACHED"`, `"RATE_LIMITED"`, `"INTERNAL"` - `message` (`getSchedule.response.429.error.message`; string, required) — Human readable. - `fields` (`getSchedule.response.429.error.fields`; map, optional) — Only present when the error belongs to specific request fields. # Get the store > The restaurant's address, branding, order types, payment methods, delivery zones and opening hours. Load this first: `orderTypes` tells you what the shopper can order right now. Source: https://developer.unipage.be/storefront/api/store/retrieve/ · Markdown: https://developer.unipage.be/storefront/api/store/retrieve/index.md Path: Storefront API › Store `GET /store` The restaurant's address, branding, order types, payment methods, delivery zones and opening hours. Load this first: `orderTypes` tells you what the shopper can order right now. ## Authentication Requires one of the following: - `publicKey`, apiKey, in header, header `X-Unipage-Key` - `secretKey`, http, header `Authorization` ## Code samples ### cURL ```bash curl --request GET \ --url https://api.unipage.eu/storefront/v1/store \ --header 'X-Unipage-Key: ' ``` ### TypeScript ```typescript const url = 'https://api.unipage.eu/storefront/v1/store'; const options = {method: 'GET', headers: {'X-Unipage-Key': ''}}; fetch(url, options) .then(res => res.json()) .then(json => console.log(json)) .catch(err => console.error(err)); ``` ### Python ```python import requests url = "https://api.unipage.eu/storefront/v1/store" headers = {"X-Unipage-Key": ""} response = requests.get(url, headers=headers) print(response.text) ``` ## Responses ### 200 The store. #### Example ```json { "id": "k3x9p", "name": "Example Fries", "address": { "line1": "Main Street 1", "postcode": "1000", "city": "Brussels", "country": "BE" }, "phone": "+32 2 000 00 00", "email": "shop@example.com", "timezone": "Europe/Brussels", "currency": "EUR", "branding": { "primaryColor": "#800F4D", "secondaryColor": "#002352", "coverImage": "string", "logo": "string" }, "acceptsOrders": true, "orderTypes": [ "TAKEOUT", "DELIVERY" ], "payment": { "methods": [ "online" ], "default": "online" }, "delivery": { "enabled": true, "minimumAmount": 50, "zones": [ { "id": "z81qm", "postcode": "1000", "name": "1000", "cost": 10, "freeFrom": 100 } ] }, "serviceCost": { "amount": 0.5, "omitFrom": 0 }, "notesEnabled": true, "openingHours": [ { "day": "MON", "isOpen": true, "periods": [ { "start": "11:00", "end": "14:00" } ] } ] } ``` - `id` (`getStore.response.200.id`; string, optional) - `name` (`getStore.response.200.name`; string, optional) - `address` (`getStore.response.200.address`; object, optional) - `line1` (`getStore.response.200.address.line1`; string, optional) - `postcode` (`getStore.response.200.address.postcode`; string, optional) - `city` (`getStore.response.200.address.city`; string, optional) - `country` (`getStore.response.200.address.country`; string, optional) - `phone` (`getStore.response.200.phone`; string | null, optional) - `email` (`getStore.response.200.email`; string | null, optional) - `timezone` (`getStore.response.200.timezone`; string, optional) — Every `date` and `time` in the API is in this timezone. - `currency` (`getStore.response.200.currency`; string, optional) - `branding` (`getStore.response.200.branding`; object, optional) - `primaryColor` (`getStore.response.200.branding.primaryColor`; string | null, optional) - `secondaryColor` (`getStore.response.200.branding.secondaryColor`; string | null, optional) - `coverImage` (`getStore.response.200.branding.coverImage`; string | null, optional) — Image URL. - `logo` (`getStore.response.200.branding.logo`; string | null, optional) — Image URL. - `acceptsOrders` (`getStore.response.200.acceptsOrders`; boolean, optional) - `orderTypes` (`getStore.response.200.orderTypes`; array, optional) — What you can order through this API right now. Empty when the restaurant does not accept orders. `TAKEOUT` unless the restaurant only delivers; `DELIVERY` when delivery is on and the restaurant has at least one postcode zone. A delivery-only restaurant without a postcode zone has no order type here. - one of `"TAKEOUT"`, `"DELIVERY"` - `payment` (`getStore.response.200.payment`; object, optional) - `methods` (`getStore.response.200.payment.methods`; array, optional) — The ways the restaurant collects the money itself: pass one of these as `paymentType` when placing an order. `external` (you collect the money) is always accepted and is not listed here. - one of `"online"`, `"offline"` - `default` (`getStore.response.200.payment.default`; string | null, optional) — The first of `methods`, `null` when there is none. - one of `"online"`, `"offline"`, `null` - `delivery` (`getStore.response.200.delivery`; object, optional) - `enabled` (`getStore.response.200.delivery.enabled`; boolean, optional) - `minimumAmount` (`getStore.response.200.delivery.minimumAmount`; number, optional) - `zones` (`getStore.response.200.delivery.zones`; array, optional) — Postcode zones. - `id` (`getStore.response.200.delivery.zones.id`; string, optional) — Send as `delivery.zoneId`. - `postcode` (`getStore.response.200.delivery.zones.postcode`; string, optional) - `name` (`getStore.response.200.delivery.zones.name`; string, optional) - `cost` (`getStore.response.200.delivery.zones.cost`; number, optional) - `freeFrom` (`getStore.response.200.delivery.zones.freeFrom`; number, optional) — `0` means the fee is always charged. - `serviceCost` (`getStore.response.200.serviceCost`; object, optional) - `amount` (`getStore.response.200.serviceCost.amount`; number, optional) — `0` means no service cost. - `omitFrom` (`getStore.response.200.serviceCost.omitFrom`; number, optional) — `0` means always charged; otherwise only when the subtotal is below it. - `notesEnabled` (`getStore.response.200.notesEnabled`; boolean, optional) - `openingHours` (`getStore.response.200.openingHours`; array, optional) - `day` (`getStore.response.200.openingHours.day`; string, optional) - one of `"MON"`, `"TUE"`, `"WED"`, `"THU"`, `"FRI"`, `"SAT"`, `"SUN"` - `isOpen` (`getStore.response.200.openingHours.isOpen`; boolean, optional) - `periods` (`getStore.response.200.openingHours.periods`; array, optional) - `start` (`getStore.response.200.openingHours.periods.start`; string, optional) — `HH:MM`. - `end` (`getStore.response.200.openingHours.periods.end`; string, optional) — `HH:MM`. ### 401 A missing, unknown or revoked key (`UNAUTHENTICATED`). #### Example ```json { "error": { "code": "UNAUTHENTICATED", "message": "No valid key." } } ``` - `error` (`getStore.response.401.error`; object, required) - `code` (`getStore.response.401.error.code`; string, required) - one of `"UNAUTHENTICATED"`, `"FORBIDDEN"`, `"NOT_FOUND"`, `"METHOD_NOT_ALLOWED"`, `"VALIDATION"`, `"SLOT_UNAVAILABLE"`, `"DELIVERY_UNAVAILABLE"`, `"PRODUCT_UNAVAILABLE"`, `"COUPON_INVALID"`, `"MINIMUM_NOT_REACHED"`, `"RATE_LIMITED"`, `"INTERNAL"` - `message` (`getStore.response.401.error.message`; string, required) — Human readable. - `fields` (`getStore.response.401.error.fields`; map, optional) — Only present when the error belongs to specific request fields. ### 403 A public key on a secret route, or used from an origin that is not registered for it (`FORBIDDEN`). #### Example ```json { "error": { "code": "FORBIDDEN", "message": "Origin not registered for this key." } } ``` - `error` (`getStore.response.403.error`; object, required) - `code` (`getStore.response.403.error.code`; string, required) - one of `"UNAUTHENTICATED"`, `"FORBIDDEN"`, `"NOT_FOUND"`, `"METHOD_NOT_ALLOWED"`, `"VALIDATION"`, `"SLOT_UNAVAILABLE"`, `"DELIVERY_UNAVAILABLE"`, `"PRODUCT_UNAVAILABLE"`, `"COUPON_INVALID"`, `"MINIMUM_NOT_REACHED"`, `"RATE_LIMITED"`, `"INTERNAL"` - `message` (`getStore.response.403.error.message`; string, required) — Human readable. - `fields` (`getStore.response.403.error.fields`; map, optional) — Only present when the error belongs to specific request fields. ### 429 Too many requests (`RATE_LIMITED`). Wait `Retry-After` seconds. #### Example ```json { "error": { "code": "RATE_LIMITED", "message": "Too many requests." } } ``` - `error` (`getStore.response.429.error`; object, required) - `code` (`getStore.response.429.error.code`; string, required) - one of `"UNAUTHENTICATED"`, `"FORBIDDEN"`, `"NOT_FOUND"`, `"METHOD_NOT_ALLOWED"`, `"VALIDATION"`, `"SLOT_UNAVAILABLE"`, `"DELIVERY_UNAVAILABLE"`, `"PRODUCT_UNAVAILABLE"`, `"COUPON_INVALID"`, `"MINIMUM_NOT_REACHED"`, `"RATE_LIMITED"`, `"INTERNAL"` - `message` (`getStore.response.429.error.message`; string, required) — Human readable. - `fields` (`getStore.response.429.error.fields`; map, optional) — Only present when the error belongs to specific request fields. # Cart > Price a cart before checkout. Source: https://developer.unipage.be/storefront/api/tags/cart/ · Markdown: https://developer.unipage.be/storefront/api/tags/cart/index.md Path: Storefront API Price a cart before checkout. ## Operations - [Quote a cart](/storefront/api/cart/quote/) # Menu > Categories, products and option groups. Source: https://developer.unipage.be/storefront/api/tags/menu/ · Markdown: https://developer.unipage.be/storefront/api/tags/menu/index.md Path: Storefront API Categories, products and option groups. ## Operations - [Get the menu](/storefront/api/menu/retrieve/) - [Get a product](/storefront/api/products/retrieve/) # Orders > Place and read orders. Secret key only, from your server. Source: https://developer.unipage.be/storefront/api/tags/orders/ · Markdown: https://developer.unipage.be/storefront/api/tags/orders/index.md Path: Storefront API Place and read orders. Secret key only, from your server. ## Operations - [Create an order](/storefront/api/orders/create/) - [Get an order](/storefront/api/orders/retrieve/) # Schedule > Selectable pickup and delivery slots. Source: https://developer.unipage.be/storefront/api/tags/schedule/ · Markdown: https://developer.unipage.be/storefront/api/tags/schedule/index.md Path: Storefront API Selectable pickup and delivery slots. ## Operations - [Get the schedule](/storefront/api/schedule/retrieve/) # Store > The restaurant behind your key. Source: https://developer.unipage.be/storefront/api/tags/store/ · Markdown: https://developer.unipage.be/storefront/api/tags/store/index.md Path: Storefront API The restaurant behind your key. ## Operations - [Get the store](/storefront/api/store/retrieve/) # Webhooks > Order events Unipage sends to your storefront's webhook URL. Source: https://developer.unipage.be/storefront/api/tags/webhooks/ · Markdown: https://developer.unipage.be/storefront/api/tags/webhooks/index.md Path: Storefront API Order events Unipage sends to your storefront's webhook URL. # order.created > Sent right after `POST /orders` placed an order, whatever its payment type, with the status the order ended up in. It is the first event for an order. An `external` order is already paid here: no `order.paid` follows. Source: https://developer.unipage.be/storefront/api/webhooks/order.created/ · Markdown: https://developer.unipage.be/storefront/api/webhooks/order.created/index.md Path: Storefront API `POST order.created` _Webhook._ Sent right after `POST /orders` placed an order, whatever its payment type, with the status the order ended up in. It is the first event for an order. An `external` order is already paid here: no `order.paid` follows. ## Header parameters - `X-Unipage-Event` (`order.created.header.X-Unipage-Event`; string, required) — The event name, the same as `event` in the body. - `X-Unipage-Timestamp` (`order.created.header.X-Unipage-Timestamp`; string, required) — Unix seconds when this delivery was signed. - `X-Unipage-Signature` (`order.created.header.X-Unipage-Signature`; string, required) — `sha256=` plus the hex HMAC-SHA256 of `.` with your webhook's signing secret. See [Order webhooks](/storefront/webhooks/). ## Request body _Required._ - `id` (`order.created.id`; string, required) — The event id. A retry of the same event keeps it: use it to drop repeats. - format `uuid` - `event` (`order.created.event`; string, required) - one of `"order.created"`, `"order.paid"`, `"order.status_changed"` - `created_at` (`order.created.created_at`; string, required) — When the event was sent (ISO 8601). - format `date-time` - `business_id` (`order.created.business_id`; string, required) — The restaurant id, as `id` in `GET /store`. - `data` (`order.created.data`; object, required) - `order` (`order.created.data.order`; object, required) — The order as `GET /orders/{id}` returns it at the moment of sending. - `id` (`order.created.data.order.id`; string, optional) - `status` (`order.created.data.order.status`; string, optional) — - `PENDING_PAYMENT`: an `online` order, until the payment provider confirms. - `PAYMENT_FAILED`: the online payment failed or was cancelled. - `PLACED`: the restaurant received the order (`offline`, `external`, or a confirmed payment). - `CHEF_STARTED`: the restaurant started preparing it. - `CHEF_COMPLETED`: ready for pickup or out for delivery. - `DECLINED`, `CANCELED`: the restaurant refused or cancelled it. - `PENDING_PLACEMENT`: being placed; you will rarely see it. New statuses may be added: treat an unknown one as "in progress". - one of `"PENDING_PLACEMENT"`, `"PENDING_PAYMENT"`, `"PAYMENT_FAILED"`, `"PLACED"`, `"CHEF_STARTED"`, `"CHEF_COMPLETED"`, `"DECLINED"`, `"CANCELED"` - `label` (`order.created.data.order.label`; string, optional) — The status in the order's locale. - `orderType` (`order.created.data.order.orderType`; string, optional) - one of `"TAKEOUT"`, `"DELIVERY"` - `slotAt` (`order.created.data.order.slotAt`; integer, optional) - `paymentType` (`order.created.data.order.paymentType`; string, optional) - one of `"online"`, `"offline"`, `"external"` - `isPaid` (`order.created.data.order.isPaid`; boolean, optional) — Read the payment outcome from this, never from the return URL. - `customerNotifications` (`order.created.data.order.customerNotifications`; boolean, optional) — `false` when Unipage sends the customer nothing for this order. - `lines` (`order.created.data.order.lines`; array, optional) — Includes the restaurant's charges as lines with `productId: null`. - `productId` (`order.created.data.order.lines.productId`; string | null, optional) - `name` (`order.created.data.order.lines.name`; string, optional) - `quantity` (`order.created.data.order.lines.quantity`; integer, optional) - `optionsLabel` (`order.created.data.order.lines.optionsLabel`; string, optional) - `total` (`order.created.data.order.lines.total`; number, optional) - `subtotal` (`order.created.data.order.subtotal`; number, optional) - `deliveryCost` (`order.created.data.order.deliveryCost`; number, optional) - `serviceCost` (`order.created.data.order.serviceCost`; number, optional) - `couponDiscount` (`order.created.data.order.couponDiscount`; number, optional) — Every reduction (coupon, promotional offer). - `total` (`order.created.data.order.total`; number, optional) - `currency` (`order.created.data.order.currency`; string, optional) - `locale` (`order.created.data.order.locale`; string, optional) - `customer` (`order.created.data.order.customer`; object, optional) - `firstName` (`order.created.data.order.customer.firstName`; string, optional) - `lastName` (`order.created.data.order.customer.lastName`; string, optional) - `email` (`order.created.data.order.customer.email`; string, optional) - `delivery` (`order.created.data.order.delivery`; object | null, optional) — For `DELIVERY` orders, else `null`. - `street` (`order.created.data.order.delivery.street`; string, optional) - `number` (`order.created.data.order.delivery.number`; string, optional) - `postcode` (`order.created.data.order.delivery.postcode`; string, optional) - `city` (`order.created.data.order.delivery.city`; string, optional) - `zone` (`order.created.data.order.delivery.zone`; string, optional) - `createdAt` (`order.created.data.order.createdAt`; integer, optional) — Unix seconds. ## Example request ```json { "id": "3f6c2a1e-9b7d-4c1a-8e2f-0a5b6c7d8e9f", "event": "order.created", "created_at": "2026-10-28T11:30:00+00:00", "business_id": "k3x9p", "data": { "order": { "id": "q4m8z", "status": "PLACED", "label": "Order received", "orderType": "TAKEOUT", "slotAt": 1792750500, "paymentType": "online", "isPaid": true, "customerNotifications": true, "lines": [ { "productId": "p7d2x", "name": "Medium fries", "quantity": 1, "optionsLabel": "", "total": 4 } ], "subtotal": 4, "deliveryCost": 0, "serviceCost": 0.5, "couponDiscount": 0, "total": 4.5, "currency": "EUR", "locale": "nl", "customer": { "firstName": "Ann", "lastName": "Example", "email": "ann@example.com" }, "delivery": { "street": "string", "number": "string", "postcode": "string", "city": "string", "zone": "string" }, "createdAt": 1792749000 } } } ``` ## Responses ### 200 Any 2xx acknowledges the event. Anything else (a 3xx included, redirects are not followed) or no answer within 5 seconds is a failed delivery. # order.paid > An `online` order's payment is confirmed by the payment provider (`isPaid` went from false to true). Source: https://developer.unipage.be/storefront/api/webhooks/order.paid/ · Markdown: https://developer.unipage.be/storefront/api/webhooks/order.paid/index.md Path: Storefront API `POST order.paid` _Webhook._ An `online` order's payment is confirmed by the payment provider (`isPaid` went from false to true). ## Header parameters - `X-Unipage-Event` (`order.paid.header.X-Unipage-Event`; string, required) — The event name, the same as `event` in the body. - `X-Unipage-Timestamp` (`order.paid.header.X-Unipage-Timestamp`; string, required) — Unix seconds when this delivery was signed. - `X-Unipage-Signature` (`order.paid.header.X-Unipage-Signature`; string, required) — `sha256=` plus the hex HMAC-SHA256 of `.` with your webhook's signing secret. See [Order webhooks](/storefront/webhooks/). ## Request body _Required._ - `id` (`order.paid.id`; string, required) — The event id. A retry of the same event keeps it: use it to drop repeats. - format `uuid` - `event` (`order.paid.event`; string, required) - one of `"order.created"`, `"order.paid"`, `"order.status_changed"` - `created_at` (`order.paid.created_at`; string, required) — When the event was sent (ISO 8601). - format `date-time` - `business_id` (`order.paid.business_id`; string, required) — The restaurant id, as `id` in `GET /store`. - `data` (`order.paid.data`; object, required) - `order` (`order.paid.data.order`; object, required) — The order as `GET /orders/{id}` returns it at the moment of sending. - `id` (`order.paid.data.order.id`; string, optional) - `status` (`order.paid.data.order.status`; string, optional) — - `PENDING_PAYMENT`: an `online` order, until the payment provider confirms. - `PAYMENT_FAILED`: the online payment failed or was cancelled. - `PLACED`: the restaurant received the order (`offline`, `external`, or a confirmed payment). - `CHEF_STARTED`: the restaurant started preparing it. - `CHEF_COMPLETED`: ready for pickup or out for delivery. - `DECLINED`, `CANCELED`: the restaurant refused or cancelled it. - `PENDING_PLACEMENT`: being placed; you will rarely see it. New statuses may be added: treat an unknown one as "in progress". - one of `"PENDING_PLACEMENT"`, `"PENDING_PAYMENT"`, `"PAYMENT_FAILED"`, `"PLACED"`, `"CHEF_STARTED"`, `"CHEF_COMPLETED"`, `"DECLINED"`, `"CANCELED"` - `label` (`order.paid.data.order.label`; string, optional) — The status in the order's locale. - `orderType` (`order.paid.data.order.orderType`; string, optional) - one of `"TAKEOUT"`, `"DELIVERY"` - `slotAt` (`order.paid.data.order.slotAt`; integer, optional) - `paymentType` (`order.paid.data.order.paymentType`; string, optional) - one of `"online"`, `"offline"`, `"external"` - `isPaid` (`order.paid.data.order.isPaid`; boolean, optional) — Read the payment outcome from this, never from the return URL. - `customerNotifications` (`order.paid.data.order.customerNotifications`; boolean, optional) — `false` when Unipage sends the customer nothing for this order. - `lines` (`order.paid.data.order.lines`; array, optional) — Includes the restaurant's charges as lines with `productId: null`. - `productId` (`order.paid.data.order.lines.productId`; string | null, optional) - `name` (`order.paid.data.order.lines.name`; string, optional) - `quantity` (`order.paid.data.order.lines.quantity`; integer, optional) - `optionsLabel` (`order.paid.data.order.lines.optionsLabel`; string, optional) - `total` (`order.paid.data.order.lines.total`; number, optional) - `subtotal` (`order.paid.data.order.subtotal`; number, optional) - `deliveryCost` (`order.paid.data.order.deliveryCost`; number, optional) - `serviceCost` (`order.paid.data.order.serviceCost`; number, optional) - `couponDiscount` (`order.paid.data.order.couponDiscount`; number, optional) — Every reduction (coupon, promotional offer). - `total` (`order.paid.data.order.total`; number, optional) - `currency` (`order.paid.data.order.currency`; string, optional) - `locale` (`order.paid.data.order.locale`; string, optional) - `customer` (`order.paid.data.order.customer`; object, optional) - `firstName` (`order.paid.data.order.customer.firstName`; string, optional) - `lastName` (`order.paid.data.order.customer.lastName`; string, optional) - `email` (`order.paid.data.order.customer.email`; string, optional) - `delivery` (`order.paid.data.order.delivery`; object | null, optional) — For `DELIVERY` orders, else `null`. - `street` (`order.paid.data.order.delivery.street`; string, optional) - `number` (`order.paid.data.order.delivery.number`; string, optional) - `postcode` (`order.paid.data.order.delivery.postcode`; string, optional) - `city` (`order.paid.data.order.delivery.city`; string, optional) - `zone` (`order.paid.data.order.delivery.zone`; string, optional) - `createdAt` (`order.paid.data.order.createdAt`; integer, optional) — Unix seconds. ## Example request ```json { "id": "3f6c2a1e-9b7d-4c1a-8e2f-0a5b6c7d8e9f", "event": "order.created", "created_at": "2026-10-28T11:30:00+00:00", "business_id": "k3x9p", "data": { "order": { "id": "q4m8z", "status": "PLACED", "label": "Order received", "orderType": "TAKEOUT", "slotAt": 1792750500, "paymentType": "online", "isPaid": true, "customerNotifications": true, "lines": [ { "productId": "p7d2x", "name": "Medium fries", "quantity": 1, "optionsLabel": "", "total": 4 } ], "subtotal": 4, "deliveryCost": 0, "serviceCost": 0.5, "couponDiscount": 0, "total": 4.5, "currency": "EUR", "locale": "nl", "customer": { "firstName": "Ann", "lastName": "Example", "email": "ann@example.com" }, "delivery": { "street": "string", "number": "string", "postcode": "string", "city": "string", "zone": "string" }, "createdAt": 1792749000 } } } ``` ## Responses ### 200 Any 2xx acknowledges the event. Anything else (a 3xx included, redirects are not followed) or no answer within 5 seconds is a failed delivery. # order.status_changed > The order's `status` changed after it was placed (the restaurant accepted, started, completed, declined or cancelled it, or an online payment moved it on). `data.previousStatus` is the status before the change. Source: https://developer.unipage.be/storefront/api/webhooks/order.status_changed/ · Markdown: https://developer.unipage.be/storefront/api/webhooks/order.status_changed/index.md Path: Storefront API `POST order.status_changed` _Webhook._ The order's `status` changed after it was placed (the restaurant accepted, started, completed, declined or cancelled it, or an online payment moved it on). `data.previousStatus` is the status before the change. ## Header parameters - `X-Unipage-Event` (`order.status_changed.header.X-Unipage-Event`; string, required) — The event name, the same as `event` in the body. - `X-Unipage-Timestamp` (`order.status_changed.header.X-Unipage-Timestamp`; string, required) — Unix seconds when this delivery was signed. - `X-Unipage-Signature` (`order.status_changed.header.X-Unipage-Signature`; string, required) — `sha256=` plus the hex HMAC-SHA256 of `.` with your webhook's signing secret. See [Order webhooks](/storefront/webhooks/). ## Request body _Required._ - `id` (`order.status_changed.id`; string, required) — The event id. A retry of the same event keeps it: use it to drop repeats. - format `uuid` - `event` (`order.status_changed.event`; string, required) - one of `"order.created"`, `"order.paid"`, `"order.status_changed"` - `created_at` (`order.status_changed.created_at`; string, required) — When the event was sent (ISO 8601). - format `date-time` - `business_id` (`order.status_changed.business_id`; string, required) — The restaurant id, as `id` in `GET /store`. - `data` (`order.status_changed.data`; object, required) - `previousStatus` (`order.status_changed.data.previousStatus`; string, optional) — The status before the change. ## Example request ```json { "id": "3f6c2a1e-9b7d-4c1a-8e2f-0a5b6c7d8e9f", "event": "order.created", "created_at": "2026-10-28T11:30:00+00:00", "business_id": "k3x9p", "data": { "order": { "id": "q4m8z", "status": "PLACED", "label": "Order received", "orderType": "TAKEOUT", "slotAt": 1792750500, "paymentType": "online", "isPaid": true, "customerNotifications": true, "lines": [ { "productId": "p7d2x", "name": "Medium fries", "quantity": 1, "optionsLabel": "", "total": 4 } ], "subtotal": 4, "deliveryCost": 0, "serviceCost": 0.5, "couponDiscount": 0, "total": 4.5, "currency": "EUR", "locale": "nl", "customer": { "firstName": "Ann", "lastName": "Example", "email": "ann@example.com" }, "delivery": { "street": "string", "number": "string", "postcode": "string", "city": "string", "zone": "string" }, "createdAt": 1792749000 }, "previousStatus": "PLACED" } } ``` ## Responses ### 200 Any 2xx acknowledges the event. Anything else (a 3xx included, redirects are not followed) or no answer within 5 seconds is a failed delivery. # Checkout flow > From reading the menu to a paid order, in the order a storefront makes the calls. Source: https://developer.unipage.be/storefront/checkout-flow/ · Markdown: https://developer.unipage.be/storefront/checkout-flow/index.md A storefront calls the public routes from the browser, and sends the two order calls through a small server of its own that holds the secret key. The examples below are shortened: every field is in the [API reference](/storefront/api/). 1. **Load the store** [`GET /store`](/storefront/api/store/retrieve/) tells you the `orderTypes` you can offer, the payment methods, delivery zones and service cost. An empty `orderTypes` means the restaurant does not accept orders right now. ```json { "id": "k3x9p", "name": "Example Fries", "timezone": "Europe/Brussels", "orderTypes": ["TAKEOUT", "DELIVERY"], "payment": { "methods": ["online", "offline"], "default": "online" }, "delivery": { "enabled": true, "minimumAmount": 50, "zones": [{ "id": "z81qm", "postcode": "1000", "name": "1000", "cost": 10, "freeFrom": 100 }] }, "serviceCost": { "amount": 0.5, "omitFrom": 0 } } ``` 2. **Show the menu** [`GET /menu?orderType=TAKEOUT`](/storefront/api/menu/retrieve/) returns the categories and products for an order type. Get [the product](/storefront/api/products/retrieve/) when `hasOptions` is true, to read its option groups. ```json { "categories": [{ "id": "c4n2w", "name": "Fries", "products": [{ "id": "p7d2x", "name": "Medium fries", "price": 4, "available": true, "soldOut": false, "hasOptions": true, "allergens": ["gluten"], "allergenSources": { "gluten": ["wheat"] } }] }] } ``` 3. **Pick a slot** [`GET /schedule?orderType=TAKEOUT&products=p7d2x`](/storefront/api/schedule/retrieve/) with the cart's `products`, so the slots respect preparation times. Reload it whenever the set of products in the cart changes. ```json { "timezone": "Europe/Brussels", "days": [{ "date": "2026-10-23", "weekday": "FRI", "isOpen": true, "slots": [{ "time": "12:15", "at": 1792750500, "available": true }] }] } ``` 4. **Quote the cart** [`POST /cart/quote`](/storefront/api/cart/quote/) with the lines, delivery zone, coupon and the chosen `slotAt`. Show the lines, `charges` and `total`, and only enable checkout when `canCheckout` is true. ```json title="Request" { "orderType": "TAKEOUT", "slotAt": 1792750500, "lines": [{ "productId": "p7d2x", "quantity": 1, "options": [{ "groupId": "g5t1r", "optionId": "o9w3e" }] }] } ``` ```json title="Response" { "lines": [{ "index": 0, "productId": "p7d2x", "name": "Medium fries", "quantity": 1, "unitPrice": 4, "optionsLabel": "Toppings: Feta", "total": 4, "errors": [] }], "subtotal": 4, "charges": [{ "name": "Packaging", "amount": 0.5 }], "deliveryCost": 0, "serviceCost": 0.5, "couponDiscount": 0, "coupon": null, "total": 5, "minimum": null, "errors": [], "canCheckout": true } ``` 5. **Place the order, from your server** [`POST /orders`](/storefront/api/orders/create/) with the secret key and a `returnUrl` on one of your registered origins. Unipage validates everything again, so handle `SLOT_UNAVAILABLE`, `PRODUCT_UNAVAILABLE`, `COUPON_INVALID` and `MINIMUM_NOT_REACHED` by sending the shopper back to the step it concerns. ```json title="Request" { "orderType": "TAKEOUT", "slotAt": 1792750500, "lines": [{ "productId": "p7d2x", "quantity": 1, "options": [{ "groupId": "g5t1r", "optionId": "o9w3e" }] }], "customer": { "firstName": "Ann", "lastName": "Example", "email": "ann@example.com", "phone": "+32470000000" }, "paymentType": "online", "returnUrl": "https://shop.example.com/order/{orderId}" } ``` ```json title="Response (201)" { "order": { "id": "q4m8z", "status": "PENDING_PAYMENT", "paymentType": "online", "isPaid": false, "total": 5, "...": "..." }, "next": { "type": "payment", "url": "https://demo.unipage.eu/pay/mollie/x7k2p" } } ``` 6. **Pay** If `next.type` is `payment` (`paymentType: "online"`), send the shopper's browser to `next.url`. For `offline` and `external` (`next.type: "confirmation"`) the order is placed and nothing is left to pay through Unipage. See [Payments](/storefront/payments/). 7. **Confirm** The shopper comes back on your `returnUrl` with `?status=success` or `?status=failed`. Don't rely on that value: read [`GET /orders/{id}`](/storefront/api/orders/retrieve/) from your server and use `isPaid` and `status`, or wait for the [webhook](/storefront/webhooks/). ```json { "id": "q4m8z", "status": "PLACED", "label": "Order received", "isPaid": true, "total": 5, "...": "..." } ``` # Customer messages > Let Unipage message the customer, or send every message yourself. Source: https://developer.unipage.be/storefront/customer-messages/ · Markdown: https://developer.unipage.be/storefront/customer-messages/index.md By default Unipage messages the customer of a Storefront API order exactly as it messages a customer of the restaurant's own webshop: the order confirmation, the message that the kitchen started (mail, SMS or push), and the review request. Each one is only sent when the restaurant has it switched on. They are in the order's `locale` (`nl`, `fr`, `en`, `de` or `es`, default the restaurant language). ## Sending your own Set `customerNotifications: false` on [`POST /orders`](/storefront/api/orders/create/): ```json { "customerNotifications": false } ``` Unipage then sends the customer of that order nothing at all, now or later, whatever the restaurant has switched on. The order shows the choice as `customerNotifications: false`. The restaurant's own notifications (new order mail, push in its apps) are not affected. Use [order webhooks](/storefront/webhooks/) to know when to send what: | Message | Send it on | |---|---| | Order confirmation | `order.created` with `isPaid: true` or `paymentType: "offline"`, or `order.paid` for an `online` order | | Payment failed | `order.status_changed` to `PAYMENT_FAILED` | | Being prepared | `order.status_changed` to `CHEF_STARTED` | | Ready | `order.status_changed` to `CHEF_COMPLETED` | | Declined or cancelled | `order.status_changed` to `DECLINED` or `CANCELED` | > **Note** > > The setting is per order and cannot be changed after the order is placed. # Errors and rate limits > The error shape, every error code, and the per-key rate limits. Source: https://developer.unipage.be/storefront/errors/ · Markdown: https://developer.unipage.be/storefront/errors/index.md ## Errors Every error on every route has one shape: ```json { "error": { "code": "SLOT_UNAVAILABLE", "message": "Human readable", "fields": { "customer.email": "Invalid e-mail" } } } ``` `fields` is only present when the error belongs to specific request fields. | Code | Status | Meaning | |---|---|---| | `UNAUTHENTICATED` | 401 | no valid key | | `FORBIDDEN` | 403 | wrong key kind or unregistered origin | | `NOT_FOUND` | 404 | unknown route, product or order | | `METHOD_NOT_ALLOWED` | 405 | | | `VALIDATION` | 422 | the request is not valid, see `fields` | | `SLOT_UNAVAILABLE` | 422 | the chosen time slot is full or no longer offered | | `DELIVERY_UNAVAILABLE` | 422 | no delivery, or not to this zone | | `PRODUCT_UNAVAILABLE` | 422 | a product is sold out or not orderable now | | `COUPON_INVALID` | 422 | the coupon is not valid for this order | | `MINIMUM_NOT_REACHED` | 422 | below the delivery minimum | | `RATE_LIMITED` | 429 | see `Retry-After` | | `INTERNAL` | 500 | a fault on our side, never a client mistake | > **Note** > > `COUPON_INVALID` and `MINIMUM_NOT_REACHED` only come from > [`POST /orders`](/storefront/api/orders/create/): the > [quote](/storefront/api/cart/quote/) reports both inside its 200 response > (`coupon.valid`, `minimum`, `canCheckout`). ## Rate limits Per key, answered with `429 RATE_LIMITED` and a `Retry-After` header (seconds): | Key | Limit | |---|---| | Public key | 1200 requests/min in total, and 120/min per IP address | | Secret key | 300 requests/min | # Keys and CORS > Public and secret storefront keys, registered origins and CORS behaviour. Source: https://developer.unipage.be/storefront/keys/ · Markdown: https://developer.unipage.be/storefront/keys/index.md You get two keys per storefront, both requested from Unipage. Together they are one storefront, with a name, its registered origins and optionally an [order webhook](/storefront/webhooks/). The public key's origins are where your shoppers may browse from and return to after paying. The restaurant is resolved from the key: there is no restaurant id in the URL. | | Public key | Secret key | |---|---|---| | Looks like | `upg_sf_pk_...` | `upg_sf_sk_...` | | Where it lives | in your browser bundle, safe to expose | only on your server, never in a browser | | Sent as | `X-Unipage-Key: ` header | `Authorization: Bearer ` header | | Grants | the read and quote routes | every route, including orders | | Bound to | the origins you registered (`https://shop.example.com`) | nothing: server-to-server only | ### Public key (browser) ```js const res = await fetch("https://api.unipage.eu/storefront/v1/menu?orderType=TAKEOUT", { headers: { "X-Unipage-Key": import.meta.env.VITE_UNIPAGE_PUBLIC_KEY }, }); ``` ### Secret key (server) ```sh curl https://api.unipage.eu/storefront/v1/orders/q4m8z \ -H "Authorization: Bearer $UNIPAGE_SECRET_KEY" ``` A Core API key (`upg_capi_...`, the restaurant's own back-office key) is also accepted where the secret key is, but give a storefront its own keys. ## Requesting keys When you request keys, give every origin your shop is served from (`scheme://host[:port]`, no path, no trailing slash). Local development needs no registration: every public key also accepts `localhost`, `127.0.0.1` and `[::1]` on any port, over http or https. A storefront on your machine that uses production keys places real orders, so test against a test restaurant. > **Caution** > > Both keys are shown to you once. Unipage only keeps a hash of the secret > key and cannot show it again. Origins can be changed later without a new key: the public key stays the same, so your storefront needs no new build. ## Rotating a key If a key leaks, ask Unipage to rotate it. Each key is rotated on its own: the old one stops working at once and the new one is shown to you once. - **Secret key**: only your server needs the new key. - **Public key**: it keeps its origins and webhook, but it is in your browser bundle, so rebuild and deploy your storefront with the new one. ## Authentication errors - A missing, unknown or revoked key answers `401 UNAUTHENTICATED`. - A public key on a secret route, or used from an origin that is not registered for it (and is not local), answers `403 FORBIDDEN`. - A request without an `Origin` header (curl, a server) is accepted with a public key: the origin list restricts browsers, the key identifies you. ## CORS A browser preflight (`OPTIONS`) is answered `204` for a registered or local origin, with: ```http Access-Control-Allow-Headers: X-Unipage-Key, Content-Type Access-Control-Allow-Methods: GET, POST, OPTIONS Access-Control-Max-Age: 600 ``` Every response (errors and 429 included) carries `Vary: Origin` and, for a registered or local origin: ```http Access-Control-Allow-Origin: Access-Control-Expose-Headers: Retry-After ``` The secret routes send no CORS headers for a browser: call them from your server. # Limitations > What the Storefront API does not cover yet. Source: https://developer.unipage.be/storefront/limitations/ · Markdown: https://developer.unipage.be/storefront/limitations/index.md - Eat-in and table ordering. - Delivery zones other than postcode zones. - Combo menus. - Products priced by weight. - The `DESELECT` and `INPUT_MULTIPLIER` option group types. The quote does not preview promotional offers or tips; the order applies the restaurant's offers. # Payments > Three ways to collect the money for an order, and what each does to the order. Source: https://developer.unipage.be/storefront/payments/ · Markdown: https://developer.unipage.be/storefront/payments/index.md `paymentType` on [`POST /orders`](/storefront/api/orders/create/) decides who collects the money. | `paymentType` | Who collects the money | The order after `POST /orders` | `next.type` | |---|---|---|---| | `online` | Unipage, with the restaurant's own payment provider | `PENDING_PAYMENT`, unpaid | `payment` | | `offline` | the restaurant, at pickup or on delivery | `PLACED`, unpaid | `confirmation` | | `external` | you, with your own payment provider | `PLACED`, paid | `confirmation` | `online` and `offline` are only accepted when the restaurant offers them: read `payment.methods` from [`GET /store`](/storefront/api/store/retrieve/). `external` is always accepted and is not listed there. ## Online: the restaurant's payment provider 1. **Place the order** Send `paymentType: "online"` and a `returnUrl` on one of your storefront's origins. `{orderId}` in it is replaced by the order id: ```json { "paymentType": "online", "returnUrl": "https://shop.example.com/order/{orderId}" } ``` 2. **Send the shopper to the payment page** The response has `next.type: "payment"` and `next.url`: redirect the shopper's browser there. 3. **Read the outcome** After paying, the shopper comes back on `returnUrl` with `?status=success` or `?status=failed` (failed or cancelled). The query string is only a hint: read [`GET /orders/{id}`](/storefront/api/orders/retrieve/) from your server and use `isPaid` and `status`. With a [webhook](/storefront/webhooks/) you also get `order.paid` when the provider confirms. Without `returnUrl` the shopper ends on the restaurant's own Unipage thank-you page. ## Offline: pay at the restaurant The order is placed at once (`PLACED`), unpaid. The restaurant collects the money at pickup or on delivery. Only possible when `payment.methods` contains `offline`. ## External: you collect the money Take the payment with your own provider first, then place the order with `paymentType: "external"`. The order arrives `PLACED` and paid (`isPaid: true`): the restaurant's till books it as a paid online order, like an order from a delivery platform. Optionally tell Unipage where you took the payment, for reconciliation: ```json { "paymentType": "external", "payment": { "provider": "stripe", "reference": "pi_3Q8xYz" } } ``` > **Only from your server** > > `external` places a paid order that nobody paid Unipage for. It needs the > secret key, so it can only come from your server. Never let the browser > decide the payment type: place the order with `external` only after your > server has seen the payment succeed. - Place the order after the payment succeeded. If the order is then refused (`SLOT_UNAVAILABLE`, `PRODUCT_UNAVAILABLE`, ...), refund the shopper. - Refunds are yours: the money never passes through the restaurant's Unipage payouts, so Unipage cannot refund it. - There is no `order.paid` [webhook](/storefront/webhooks/) for an `external` order: `order.created` already says `isPaid: true`. # Quickstart > From an empty page to a placed order, in the browser and on your server. Source: https://developer.unipage.be/storefront/quickstart/ · Markdown: https://developer.unipage.be/storefront/quickstart/index.md This page builds the smallest working storefront: read the restaurant and its menu in the browser with the public key, quote a cart, and place the order from a small server with the secret key. You need both keys, see [Keys and CORS](/storefront/keys/). For a complete storefront, start from the [example repository](https://github.com/UnipageEU/storefront-example): a Vue (`vue/`) and a React (`react/`) storefront that do the same thing, and the Node server (`server/`) that holds the secret key and receives [webhooks](/storefront/webhooks/). ## 1. Read the restaurant and the menu (browser) Every browser call sends the public key in `X-Unipage-Key`, from an origin you registered with it. ```js const API = "https://api.unipage.eu/storefront/v1"; const headers = { "X-Unipage-Key": "upg_sf_pk_..." }; async function api(path, options = {}) { const res = await fetch(`${API}${path}`, { ...options, headers: { ...headers, ...options.headers } }); const body = await res.json(); if (!res.ok) throw body.error; // { code, message, fields? } return body; } const store = await api("/store"); if (store.orderTypes.length === 0) { // The restaurant does not take orders right now. } const orderType = store.orderTypes[0]; // "TAKEOUT" or "DELIVERY" const menu = await api(`/menu?orderType=${orderType}`); // menu.categories[].products[]: { id, name, price, available, soldOut, hasOptions, ... } ``` A product with `hasOptions: true` has option groups: read them with [`GET /products/{id}`](/storefront/api/products/retrieve/) and send the chosen options on the cart line. ## 2. Pick a slot (browser) Ask the schedule for the products in the cart, so the slots respect their preparation times. ```js const cart = [{ productId: "p7d2x", quantity: 2 }]; const ids = [...new Set(cart.map((line) => line.productId))].join(","); const schedule = await api(`/schedule?orderType=${orderType}&products=${ids}`); const slot = schedule.days .flatMap((day) => day.slots) .find((s) => s.available); // slot: { time: "12:15", at: 1792750500, available: true } ``` ## 3. Quote the cart (browser) The quote is priced by the same code that prices the order: show its lines and `total`, and only offer checkout when `canCheckout` is true. ```js const quote = await api("/cart/quote", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ orderType, lines: cart, slotAt: slot.at }), }); // quote: { lines, subtotal, charges, deliveryCost, serviceCost, couponDiscount, total, canCheckout, errors, ... } ``` ## 4. Place the order (your server) The browser sends the cart, slot and customer to your own server, which adds the secret key and the `returnUrl`. The browser never sees the secret key and never chooses how the order is paid. ```js // server.js: node server.js, with UNIPAGE_SECRET_KEY in the environment. import http from "node:http"; const API = "https://api.unipage.eu/storefront/v1"; const SHOP = "https://shop.example.com"; // one of the public key's origins http.createServer(async (req, res) => { if (req.method !== "POST" || req.url !== "/api/checkout") { res.writeHead(404).end(); return; } let raw = ""; for await (const chunk of req) raw += chunk; const { orderType, slotAt, lines, customer } = JSON.parse(raw || "{}"); const upstream = await fetch(`${API}/orders`, { method: "POST", headers: { Authorization: `Bearer ${process.env.UNIPAGE_SECRET_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ orderType, slotAt, lines, customer, // { firstName, lastName, email, phone } paymentType: "online", returnUrl: `${SHOP}/order/{orderId}`, }), }); // 201 { order, next }, or Unipage's own error ({ error: { code, ... } }). res.writeHead(upstream.status, { "Content-Type": "application/json" }); res.end(await upstream.text()); }).listen(3000); ``` `paymentType` must be one of the restaurant's `payment.methods` from `GET /store` (`online` or `offline`), or `external` when you take the payment yourself. See [Payments](/storefront/payments/). ## 5. Pay and confirm (browser and server) ```js const res = await fetch("/api/checkout", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ orderType, slotAt: slot.at, lines: cart, customer }), }); const { order, next, error } = await res.json(); if (error) { // SLOT_UNAVAILABLE, PRODUCT_UNAVAILABLE, ...: send the shopper back to that step. } else if (next.type === "payment") { window.location.href = next.url; // the restaurant's payment page } else { // "confirmation": placed, nothing left to pay through Unipage. } ``` After paying, the shopper comes back on `https://shop.example.com/order/?status=success` (or `?status=failed`). That query string is only a hint: have your server read [`GET /orders/{id}`](/storefront/api/orders/retrieve/) with the secret key and show `status` and `isPaid`, or wait for the [`order.paid` webhook](/storefront/webhooks/). ## Next steps - [Checkout flow](/storefront/checkout-flow/): every step with its request and response. - [Errors and rate limits](/storefront/errors/): what to do with each error code. - [Customer messages](/storefront/customer-messages/): send the order mails yourself. # Order webhooks > Get your storefront's orders pushed to your server as they are placed, paid and prepared. Source: https://developer.unipage.be/storefront/webhooks/ · Markdown: https://developer.unipage.be/storefront/webhooks/index.md Unipage can send your server an HTTPS `POST` every time one of your storefront's orders is placed, paid or changes status, so you never have to poll [`GET /orders/{id}`](/storefront/api/orders/retrieve/). ## Setting it up Ask Unipage to set a webhook URL on your storefront. It must be `https://` on a public address, and no redirect is followed. You receive a **signing secret** once: keep it on your server, next to the secret key. The webhook belongs to the storefront, not to a key: rotating the public key keeps it. Events are only sent for orders placed with your storefront's own secret key. After the secret key is rotated, orders placed with the old one send no more events: read those with [`GET /orders/{id}`](/storefront/api/orders/retrieve/). ## Events | Event | When | |---|---| | `order.created` | right after `POST /orders` placed the order, any payment type. Always the first event of an order. | | `order.paid` | an `online` order's payment was confirmed (`isPaid` became true) | | `order.status_changed` | every change of `status` after that, with `data.previousStatus` | A typical `online` order sends `order.created` (`PENDING_PAYMENT`), then `order.paid` and `order.status_changed` to `PLACED` when the payment is confirmed, then `order.status_changed` to `CHEF_STARTED` and `CHEF_COMPLETED`. An `external` order starts `PLACED` and paid: no `order.paid` follows. ## The request ```http POST /unipage/webhooks HTTP/1.1 Content-Type: application/json User-Agent: Unipage-Webhooks/1.0 X-Unipage-Event: order.status_changed X-Unipage-Timestamp: 1792749000 X-Unipage-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 ``` ```json { "id": "3f6c2a1e-9b7d-4c1a-8e2f-0a5b6c7d8e9f", "event": "order.status_changed", "created_at": "2026-10-28T11:30:00+00:00", "business_id": "k3x9p", "data": { "order": { "id": "q4m8z", "status": "CHEF_STARTED", "isPaid": true, "...": "..." }, "previousStatus": "PLACED" } } ``` `data.order` is the order exactly as [`GET /orders/{id}`](/storefront/api/orders/retrieve/) returns it at the moment of sending. The full schema is in the [API reference](/storefront/api/). ## Verifying the signature Every webhook request carries three headers: | Header | Value | |---|---| | `X-Unipage-Event` | the event name | | `X-Unipage-Timestamp` | when the request was signed, in Unix seconds | | `X-Unipage-Signature` | `sha256=` plus the hex HMAC-SHA256 of `.`, keyed with your signing secret (`whsec_...`) | The `User-Agent` is `Unipage-Webhooks/1.0`. To verify a request: 1. Read the **raw** request body, exactly the bytes you received, before any JSON parsing. A re-serialised body does not match. 2. Compute the signature over `.` and compare it with `X-Unipage-Signature` in constant time. 3. Refuse a timestamp more than 5 minutes from your clock, so a captured request cannot be replayed. 4. Answer `401` when the check fails. Otherwise answer `2xx` at once and do the work afterwards. ### Node.js ```js import { createHmac, timingSafeEqual } from "node:crypto"; // headers: lower-cased header names, rawBody: the body as a string. export function verify(secret, headers, rawBody) { const timestamp = String(headers["x-unipage-timestamp"] || ""); const signature = String(headers["x-unipage-signature"] || ""); if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) { return false; } const expected = Buffer.from( "sha256=" + createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex"), ); const given = Buffer.from(signature); return given.length === expected.length && timingSafeEqual(given, expected); } ``` With Express, keep the body raw on the webhook route: ```js app.post("/unipage/webhook", express.raw({ type: "application/json" }), (req, res) => { const rawBody = req.body.toString("utf8"); if (!verify(process.env.UNIPAGE_WEBHOOK_SECRET, req.headers, rawBody)) { return res.sendStatus(401); } res.sendStatus(200); const event = JSON.parse(rawBody); // Drop event.id if you handled it before, then do the work. }); ``` ### PHP ```php function verify(string $secret, string $timestamp, string $signature, string $rawBody): bool { if (! ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) { return false; } $expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); return hash_equals($expected, $signature); } $rawBody = file_get_contents('php://input'); $ok = verify( getenv('UNIPAGE_WEBHOOK_SECRET'), $_SERVER['HTTP_X_UNIPAGE_TIMESTAMP'] ?? '', $_SERVER['HTTP_X_UNIPAGE_SIGNATURE'] ?? '', $rawBody, ); if (! $ok) { http_response_code(401); exit; } $event = json_decode($rawBody, true); // Drop $event['id'] if you handled it before, then do the work. http_response_code(200); ``` ## Answering, retries and order - Answer with any `2xx` within 5 seconds. Do the work after answering. - Anything else (an error status, a redirect, a timeout) is a failed delivery. It is tried 3 times in total, about 30 seconds and 2 minutes apart, always with the same event `id`. Store the ids you handled and drop repeats. - Events can arrive out of order, for instance when an earlier one is being retried. Use `data.order.status` as the current state, not the order of arrival, and read [`GET /orders/{id}`](/storefront/api/orders/retrieve/) when in doubt. - After 50 failed deliveries in a row the webhook is switched off. Ask Unipage to set the URL again to switch it back on. - New events may be added: answer an event you do not know with a `2xx` and ignore it. The [example storefront](https://github.com/UnipageEU/storefront-example) contains a complete receiver (`server/src/webhooks.js`).