CnterDocumentationSign in

Start here

  • What Cnter is
  • Core concepts
  • Quickstart

Platform

  • Authentication
  • Conventions
  • Errors
  • Organization
  • The dashboard

Modules

  • Content Management
  • Online Ordering
  • Restaurant Ordering
  • Promotions
  • Reservations
  • Bookings
  • Contact Form

Payments

  • AgnusPAY
  • Tax

Services

  • Address autocomplete
  • Video
  • Email

Reference

  • API reference

Modules

Restaurant Ordering

Pickup, delivery and dine-in. The fulfillment step, the live kitchen board and printed tickets.

A full brief for whoever builds against this.

Restaurant Ordering is Online Ordering plus the question every restaurant has to ask before payment: how is the customer getting this. That answer travels with the order to a live board in the kitchen and onto a printed ticket.

Build the cart on Online Ordering, and read it first. The quote, the amount, the five states an address can be in, whether the kitchen is open and every refusal live on that page. This one is the configuration underneath: what a venue offers, what a dish can be ordered with, and how its delivery area is drawn.

Read the settings, do not hardcode them#

GET/api/public/{organizationId}/restaurantPublic

Which fulfillment types are on, the indicative timings, the delivery and pickup terms, and the tax configuration.

json
{
  "enabled": true,
  "fulfillment_types": ["pickup", "delivery"],
  "default_prep_minutes": 20,
  "delivery_prep_minutes": 40,
  "delivery_area": { "from_fee": 2.5, "min_order_from": 15, "zone_count": 3 },
  "pickup": {
    "min_order_amount": 0,
    "lead_minutes": 20,
    "instructions": "Ring the bell at the side entrance.",
    "address": { "street": "Dam", "house_number": "1",
                 "postal_code": "1012 JS", "city": "Amsterdam", "country": "NLD" }
  },
  "tax": {
    "enabled": true,
    "label": "BTW",
    "prices_include_tax": true,
    "rates": [
      { "id": "8f1c...", "name": "Laag (eten)", "rate": 9,  "is_default": true },
      { "id": "b2d7...", "name": "Hoog",        "rate": 21, "is_default": false }
    ]
  }
}

pickup.lead_minutes is the minimum notice your customer asks for a collection, in minutes. It is a request, not a rule: the checkout does not enforce it, so offer no collection time sooner than that yourself.

Three rules follow from this response, and all three matter more than they look:

  • /A type that is not in fulfillment_types must not appear anywhere. Not greyed out, not disabled. Absent.
  • /Exactly one type means there is nothing to choose. Select it silently and show no picker: a radio group with one option is a question with one answer.
  • /enabled: false means hide restaurant ordering altogether.

Your customer changes these whenever they like. A kitchen that stops delivering on a quiet Monday expects that to reach their website the same minute. A hardcoded list means someone picks delivery, types a full address, and is told at the payment step that it is unavailable. The checkout does refuse it, with 400 FULFILLMENT_TYPE_UNAVAILABLE, so nothing invalid gets through. But by then you have wasted their time.

The three types#

TypeAsk forRejected without
pickupAn optional collection time. No address — anyone willing to drive to the restaurant is welcome, wherever they live.Nothing, unless the basket is under the pickup minimum.
deliveryA full address, ideally through autocomplete, checked against the delivery area.address, with 400 ADDRESS_REQUIRED.
dine_inNot available yet — table ordering is being designed around a QR code per table. The setting cannot be switched on, so this type never appears in fulfillment_types.—

Options and extras#

Dishes can carry option groups: a required "pick your pasta", extras with a surcharge, "without onion" — sometimes with a discount. Your customer configures them once per group and attaches them to categories or individual dishes; you render them and send the picks.

GET/api/public/{organizationId}/restaurant/modifiers?content_item_id={id}Public

The groups that apply to one dish, include/exclude precedence already resolved. Omit the parameter to fetch every group at once for a menu prefetch.

json
{
  "groups": [
    { "id": "…", "name": "Soort pasta", "selection_type": "single",
      "min_select": 1, "max_select": 1, "allow_quantity": false,
      "options": [
        { "id": "…", "name": "Penne", "kind": "add", "price_delta": 0,
          "is_available": true, "is_default": true },
        { "id": "…", "name": "Tagliatelle", "kind": "add", "price_delta": 0.5,
          "is_available": false, "is_default": false }
      ] }
  ]
}

min_select >= 1 means the group is required — the checkout refuses an order that skips it. is_available: false means sold out: grey the option out rather than hiding it. price_delta can be negative; the server does all arithmetic, including a €0 floor per line. Send the picks on the cart line as ids and quantities, never prices:

json
{ "content_item_id": "…", "quantity": 2,
  "options": [ { "option_id": "…", "quantity": 2 }, { "option_id": "…" } ] }
CodeWhat to do
MODIFIER_REQUIRED / MODIFIER_TOO_FEW / MODIFIER_TOO_MANY / MODIFIER_SINGLE_CHOICEA picking rule was broken. The message is customer-ready and names the group — show it next to that group.
MODIFIER_QUANTITY_NOT_ALLOWED / MODIFIER_QUANTITY_OUT_OF_RANGEThe amount is wrong for that group. Show the message at the option.
MODIFIER_OPTION_UNAVAILABLESold out while the basket was open. Refetch the modifiers and re-render the group.
MODIFIER_GROUP_NOT_APPLICABLEYour cached menu is stale — the merchant moved or detached the group. Refetch.
MODIFIER_DUPLICATE_OPTION / MODIFIER_OPTION_NOT_FOUNDAn integration bug: the same option twice (use quantity), or a bad id.

The two rules that cost the most when missed

Two identical dishes with different options are **two cart lines** — the server cannot un-merge what a storefront collapses into one. And never send prices: option deltas are resolved server-side from the ids, exactly like dish prices.

The delivery area#

A restaurant delivers in its own region. Your customer draws that region as zones — built from postcodes, from an area on a map, or both — and each zone carries its own delivery fee, minimum order value and extra delivery time. Zones can also exclude an area, and an exclusion always wins.

POST/api/client/v1/{organizationId}/restaurant/deliveryPublic

Whether an address can be delivered to, and what it costs on this basket.

json
// the body
{ "postal_code": "1012 JS", "subtotal": 24.50 }

A POST that only reads

The address goes in the body, not in the URL. A query string is part of the request line every platform writes to its logs, and street, house number, postcode and city do not belong there — so the verb follows where the data ends up rather than what the call does. The older GET …/restaurant/delivery-check keeps answering for anything already built on it.

json
// the answer
{
  "deliverable": true,
  "unrestricted": false,
  "state": "resolved",
  "shortfall": 0,
  "zone": {
    "name": "Centrum",
    "fee": 2.5,
    "min_order_amount": 15,
    "free_delivery_from": 30,
    "extra_minutes": 0
  }
}

A cart does not call this. The quote takes the basket and the address together and answers with the fee, the minimum, the shortfall and any refusal in one call, priced by the same function as the checkout — see Online Ordering. This endpoint answers the one question the quote cannot: whether an address is deliverable when there is no basket yet, because a quote needs at least one line. That makes it an address-field check, and nothing more.

unrestricted: true means no zones are set and this customer delivers everywhere; show nothing in that case. A shortfall above zero means the address is fine but the basket is too small, which the customer fixes by ordering more, not by moving house.

Branch on state, the same word the quote uses: resolved, outside, unrestricted or needs_address. The last one comes with deliverable: null and a needs list — a postcode alone could not place the address in a zone drawn on the map, so ask for the street and house number and check again. It is not a no, and the customer should not hear one: the full address may well be inside.

Never send a fee, and never add one

The server applies the delivery fee itself, and on the quote it arrives as a line in fees[] that is already counted in the total. Adding zone.fee to that total charges the customer twice on screen; a fee posted to the checkout is discarded rather than trusted. Pass the full address where you have it — postcode-only zones never need more, but a zone drawn on a map does.

This call is not free

Every request here spends one of the organization's 5000 daily address lookups, whether or not anything had to be geocoded. The quote only spends one when a provider is actually called, and caches the result for a day. Asking both at the address step costs a merchant quota for an answer they already had.

The fulfillment block#

json
"fulfillment": {
  "type": "delivery",
  "requested_at": "2026-07-26T18:30:00.000Z",
  "address": {
    "street": "Eerste Helmersstraat",
    "house_number": "56",
    "postal_code": "1054 DJ",
    "city": "Amsterdam",
    "country": "NLD",
    "note": "Ring the bell twice"
  },
  "table_number": "12",
  "customer_note": "No onions please"
}
FieldRequiredNotes
typeyes, within the blockpickup, delivery or dine_in. Must be one your customer enabled.
requested_atnoISO 8601 UTC. The time the buyer asked for. Omit for as soon as possible.
addressfor deliverystreet, house_number, postal_code, city, plus optional country and note.
table_numbernoOnly meaningful for dine-in. Up to 20 characters.
customer_notenoUp to 1000 characters. Printed on the kitchen ticket.

Spelling is enforced here

The fulfillment block and its address reject any key they do not recognise, and the 400 names it: fulfillment.address: unknown field(s) housenumber. A silently dropped address is a courier in the wrong street two hours later.

The block itself is optional, for backwards compatibility with storefronts built before it existed. Omit it and the order still goes through, but it reaches the kitchen with no type, no time and no address, which is not usable for a restaurant. Always send it.

The customer_note is printed, so it is for allergies and doorbell instructions. Not marketing copy, and not anything long.

Timing#

default_prep_minutes is how long the kitchen takes, for pickup and delivery alike. delivery_prep_minutes is the ride, and a delivery takes both: with 20 and 30, pickup is "ready in about 20 minutes" and delivery "with you in about 50", not 30. An order the venue accepts automatically also gets its delivery zone's extra minutes. They are what your customer configured, and indicative only.

The kitchen confirms the real time when it accepts the order, and the buyer receives that by email. Do not present your own estimate as a guarantee, and do not state a delivery time on your confirmation page. Two sources naming different times is worse than one saying nothing.

The live board#

Inside the dashboard, orders land on a live board. Staff accepts an order, sets how long it will take, marks it ready, and hands it over or sends it out. The board updates in real time across every device that has it open, so two people in a kitchen cannot both accept the same order.

Each organization defines its own statuses and the emails attached to them, which is why your site should not model an order lifecycle of its own. There is nothing for you to build here. It is worth knowing it exists, because it is the reason your storefront never has to tell anyone how long their food will be.

Tickets and receipts#

Orders can print automatically to a kitchen printer as they arrive. The ticket layout, what appears on it and which printer takes which orders are configured under Settings then Restaurant Ordering. Receipts for the buyer are configured in the same place.

Grouping is worth matching. If your customer mapped a category field, the ticket groups by it. Rendering your menu in the same groups means staff and buyer are looking at the same menu when something needs discussing.

Errors specific to this module#

These are the checkout's, at the moment an order is refused. The quote reports the same conditions earlier and in a different shape, so read the table as "what the customer meets at the end" and the note under it as "what your cart sees all along".

StatusCodeCause
400FULFILLMENT_TYPE_UNAVAILABLEThat type is switched off. Read the settings and only offer what is there.
400ADDRESS_REQUIREDDelivery without an address.
400DELIVERY_OUT_OF_AREAThe address falls outside every delivery zone. Check it at the address step so nobody hears this at the payment screen.
400DELIVERY_AREA_EXCLUDEDThe address sits in an area your customer explicitly excluded.
400DELIVERY_ADDRESS_INCOMPLETEThe zone can only be decided from a full address and the street or house number is empty. Not a refusal of the address: complete it and send again.
400BELOW_MINIMUM_ORDERThe basket is under the minimum for that zone, or under the pickup minimum. The message names the shortfall.
400VALIDATION_ERRORA misspelled key inside fulfillment. The message names it.
403ORDERING_DISABLEDOnline ordering is switched off for this organization.
403PERMISSION_DENIEDA pay-in-person checkout with a key that lacks create_orders.
404CUSTOM_PAYMENT_METHOD_NOT_FOUNDA pay-in-person checkout naming a method that is not active for this organization. Nothing is created.
503—Payments are briefly unavailable. Show "Ordering is briefly unavailable" rather than a raw error: it is temporary, and a customer shown a stack trace does not come back an hour later to try again.

The same conditions, two shapes

Ask the quote about a basket and these arrive as 200 with orderable: false and the code under refusal, because a basket that cannot be ordered yet is an answer rather than a failure. Same conditions, same names, three differences worth knowing. DELIVERY_AREA_EXCLUDED has no quote form: both reasons come back as DELIVERY_OUT_OF_AREA, and delivery.reason tells them apart. ADDRESS_REQUIRED cannot arise there either — a delivery basket with no address is priced as unknown rather than refused. And a closed kitchen is a refusal the checkout has too, but only the quote says when it opens again. Branch on refusal.code while the customer is still shopping, and on the failure at the moment they commit. See Online Ordering.

A refused order needs a sentence, not a silent button

The three delivery refusals above are not exceptional — a restaurant that delivers locally turns addresses away every day. Write the refusal branch before the success branch: say *“we do not deliver to this address”* next to the address field, keep the basket, and offer pickup when it is enabled. Through the client a refusal arrives as a thrown error carrying the code above, so the branch is a catch rather than an if; a call that returns has not been refused. The two failures we see most often are a checkout that never handles the refusal at all, leaving the customer pressing a button that appears dead, and one that prints the raw code, which nobody can act on. BELOW_MINIMUM_ORDER is the odd one out: it is fixed by adding an item, so keep the customer on the cart rather than sending them back to the address step.

Quoting the basket first does not replace this branch. The quote is a courtesy so the news arrives early; the checkout is the authority. The two can legitimately disagree — a zone gets redrawn while a basket is open, or the customer edits the address after quoting — and only one of them decides whether an order exists.

Online OrderingThe cart itself: the quote, the amount, the address states and the refusals.Address autocompleteKeyless, worldwide, and already in the right shape.TaxMultiple rates, and where to show them.
PreviousOnline OrderingNextPromotions

On this page

  • Read the settings, do not hardcode them
  • The three types
  • Options and extras
  • The delivery area
  • The fulfillment block
  • Timing
  • The live board
  • Tickets and receipts
  • Errors specific to this module