Modules
Online Ordering
Cart to paid order. One checkout endpoint, two payment paths, and a server that owns every amount.
Online Ordering turns a catalog into an order. You collect ids and quantities, post them, and either redirect to a payment page or create an unpaid order for someone to settle in person. The prices, fees, discounts and tax are all worked out server-side.
The flow
- 01
Read the catalog
Fetch the menu or product list from Content Management with
content_typeset. Show your own prices for display. - 02
Read what is available
Payment methods and fees come from public endpoints. Never hardcode either: your customer switches them on and off whenever they like.
- 03
Build a cart
A cart line is
{ content_item_id, quantity }. Nothing else is trusted. - 04
Post to checkout
Online payment redirects to Stripe. Pay in person creates an unpaid order straight away.
- 05
Confirm, do not promise
Confirm the order was placed. The emails about timing come from Cnter.
What the customer can pay with
/api/public/{organizationId}/payment-methodsPublicThe active online methods and the active pay-in-person methods.
{
"stripe": [
{ "id": "ideal", "name": "iDEAL" },
{ "id": "card", "name": "Mastercard / Visa" },
{ "id": "apple_pay", "name": "Apple Pay" }
],
"custom": [
{ "id": "3a91...", "title": "Pay at the counter", "description": "Cash or card on arrival" }
]
}Supported online methods are iDEAL, Mastercard and Visa, Apple Pay, Google Pay, Bancontact, Klarna, Link and Revolut Pay. Which of them appear depends on what the account is approved for and what your customer enabled.
/api/public/{organizationId}/feesPublicActive order fees: service charges, packaging, a tip. Not the delivery fee — that comes from the zone.
[
{ "id": "c41f...", "name": "Delivery", "type": "fixed",
"amount": 3.5, "is_required": true, "is_variable": false, "sort_order": 1 },
{ "id": "9b22...", "name": "Tip", "type": "fixed",
"amount": 0, "is_required": false, "is_variable": true, "sort_order": 2 }
]| Field | Meaning |
|---|---|
is_required | Always applied. Show it, do not offer to remove it. |
is_variable | The customer chooses the amount. A tip is the usual case. |
type | fixed for an amount, percentage for a share of the subtotal. |
sort_order | The order your customer put them in. Render in it. |
Optional fees the customer ticked go into selected_fee_ids. Variable fees go into fee_overrides as { "<fee id>": 10 }. Required fees need no mention: they are applied whether you send them or not.
What the basket costs
The checkout owns every amount, and it owns them too late: by the time it answers, the customer is already looking at a payment page. The quote runs the same function over the same basket and answers before anyone commits. Ask it whenever the basket, the fulfillment or the address changes.
/api/client/v1/{organizationId}/quotePublicEverything this basket comes to — items, fees, the delivery fee for the address, discounts and tax. Same function as the checkout, so what this shows is what will be charged.
const { data: quote } = await cnter.ordering.quote({}, {
items: [{ content_item_id: '8f2a1c34-...', quantity: 2 }],
fulfillment: {
type: 'delivery',
address: { postal_code: '1054 DJ', street: 'Eerste Helmersstraat', house_number: '56' },
},
selected_fee_ids: ['9b22...'],
discount_code: 'WELCOME10',
})
if (!quote.orderable) return showRefusal(quote.refusal)The body is the checkout body minus the parts that commit: no customer, no redirect URLs, no payment method. postal_code is the only field an address must carry here, so you can quote on a postcode alone and fill the rest in later.
{
"ok": true,
"contract": 1,
"data": {
"orderable": true,
"refusal": null,
"currency": "eur",
"subtotal": 18,
"fees": [
{ "id": "b9c706eb-...", "name": "Servicekosten", "amount": 0.5 },
{ "id": "4d10f2a7-...", "name": "Delivery — Zone", "amount": 2.5 }
],
"fees_total": 3,
"discount": { "total": 0, "code": null, "lines": [] },
"total": 21,
"tax": { "total": 1.73, "inclusive": true, "lines": [] },
"delivery": {
"state": "resolved",
"zone": { "name": "Zone", "fee": 2.5, "min_order_amount": 15,
"free_delivery_from": null, "extra_minutes": 20, "shortfall": 0 }
},
"opening": { "open_now": true, "reopens_at": null, "timezone": "Europe/Amsterdam" }
}
}| Field | Meaning |
|---|---|
total | The number to show and the number that will be charged. Everything else is the breakdown behind it. |
subtotal | The items alone, before fees and before any discount. Decimals, never cents. |
fees[] | Every fee applied to this basket, as id, name and amount. Required fees plus whatever you sent in selected_fee_ids — and the delivery fee. |
discount | total is what the promotions and the code together take off. code is a verdict — { "valid": true }, { "valid": false }, or null when none was entered. |
tax | What is owed, and inclusive says whether it sits inside total or on top of it. Show this rather than computing it. |
delivery | Where the address stands. Null for pickup, dine-in and a basket with no fulfillment. |
opening | Whether the kitchen is taking orders, and when it next will. |
The address, in five states
A cart asks about delivery long before it has a house number, so delivery.state says how far the answer got rather than only whether it succeeded. Each state is a different thing to put on screen.
| State | When | What the cart does |
|---|---|---|
delivery is null | Pickup, dine-in or no fulfillment block — and on any refusal raised before the address was looked at. | Render no delivery anything. On a refusal it means the address was never reached, not that it was accepted: a basket with a required option missing comes back this way even when you sent one. |
unrestricted | Your customer drew no zones. | They deliver everywhere. No fee, no minimum, nothing to ask for. |
unknown | Zones exist and you have sent no postcode yet. | The block carries from_fee, min_order_from and zone_count. Show "delivery from € 2.50" and ask for an address. |
needs_address | The postcode alone cannot decide it — a zone drawn on a map crosses it. | Ask for street and house number. Nothing was geocoded, so this costs nothing to reach. |
outside | No zone accepted the address. | Refused. reason separates OUT_OF_AREA from EXCLUDED_AREA; message is written for your logs, not for the buyer. |
resolved | A zone matched. | Show zone.fee. shortfall above zero means the basket is too small for this zone, not that the address is wrong. |
Whether the kitchen is open
opening.open_now is true, false or null. False means "as soon as possible" is not on offer: send no requested_at while it is false and the basket comes back with a VENUE_CLOSED refusal. reopens_at is { date, time } in the venue's own zone, which timezone names. Show that: "closed" sends a full basket away and "from 16:00" does not. reopens_at is null while open, and null when no day opens within a fortnight — a venue with every day switched off has no reopening to promise.
Null open_now means the hours could not be read, not that the venue is shut. The basket is still accepted and the checkout decides. Do not print an opening time you were not given.
When the answer is no
A basket that cannot be ordered is not an error. The status is 200, orderable is false, and refusal carries a code to branch on and a message for your logs. The checkout refuses the same basket for the same reason, so handling these here is what moves the bad news off the payment page.
{
"ok": true,
"contract": 1,
"data": {
"orderable": false,
"refusal": { "code": "BELOW_MINIMUM_ORDER",
"message": "Orders to Zone start at 15.00. Add 12.00 more to continue." },
"delivery": { "state": "resolved", "zone": { "shortfall": 12 } },
"opening": { "open_now": true, "reopens_at": null, "timezone": "Europe/Amsterdam" }
}
}That is the whole body. There is no total, no subtotal, no fees and no tax on a refusal, so code that reaches for data.total without checking orderable first finds nothing there — and a cart that renders the result prints an empty amount under a full basket. The one exception is INVALID_DISCOUNT_CODE: the basket is fine and only the code is not, so that refusal comes with every amount, priced as if no code had been entered, and discount.code says { "valid": false }. orderable stays false, because the checkout refuses the basket while the code is in it.
| Code | What happened | What to do |
|---|---|---|
VENUE_CLOSED | No time was chosen and the venue is shut at this moment. | Read opening.reopens_at and say when. Or take the basket anyway for a time the venue is open, by sending fulfillment.requested_at. |
OUTSIDE_OPENING_HOURS | A requested_at was sent, and the venue is closed at that moment. | Offer times the venue is open — the weekly hours and the exceptions are both in the organization endpoint. A closed day answers this whatever time is picked. |
BELOW_MINIMUM_ORDER | The basket is under the minimum. | On delivery, delivery.zone.shortfall is how much is missing. On pickup that number is only in the message. |
DELIVERY_OUT_OF_AREA | No zone accepted the address. | One code for both reasons; delivery.reason says whether the address fell outside every zone or inside an excluded one. The checkout reports the two separately. |
FULFILLMENT_TYPE_UNAVAILABLE | This venue does not offer that fulfillment type. | Offer only the types Restaurant Ordering lists as enabled. |
INVALID_DISCOUNT_CODE | The code was entered and does not apply. | Keep the basket, clear the field, and say so next to it. The amounts are already in this answer, priced without the code, so there is no second quote to ask for. |
ORDERING_DISABLED | Online ordering is switched off for this organization. | Hide the cart. delivery and opening are both null here — it is the one refusal that answers before anything is read. |
UNKNOWN_CATALOG_ITEM, UNPRICED_CATALOG_ITEM, CATALOG_UNAVAILABLE | A line points at something that is gone, has no price, or cannot be read. | Your cached menu is behind. Re-read the catalog and rebuild the basket. |
MODIFIER_* | The options on a line break the rules the merchant set. | Ten codes, from a required group left empty to too many choices in one. Send the customer back to that line. |
Genuine failures still arrive as failures: 400 VALIDATION_ERROR when the body is malformed, 429 when you exceed 60 quotes a minute or the day's address lookups run out, and 500 QUOTE_FAILED when pricing breaks. That last one never guesses an amount, deliberately — a total invented after a failure is a number somebody puts in front of a customer.
The checkout endpoint
/api/client/v1/{organizationId}/checkoutPublicOnline payment. No key; the organization is in the path. Answers with a Stripe URL to redirect to. The client names it checkout.
/api/online-ordering/checkoutcreate_ordersPay in person. Send the key and custom_payment_method_id, and no organization_id — the key names it. Creates the order immediately. The client names it checkoutInPerson.
Same body, two paths. A basket with custom_payment_method_id creates an unpaid order on the spot, so it needs a key carrying create_orders, and it goes from your server. Through the client that is checkoutInPerson, the one ordering call it sends your key to; the same basket through checkout arrives without a key and is refused with 401 API_KEY_MISSING. The online path also still answers at /api/online-ordering/checkout with ?organization_id= and no key, for storefronts that wrote that address down.
{
"items": [
{ "content_item_id": "8f2a1c34-...", "quantity": 2 }
],
"customer": {
"name": "Noor van Elderen",
"email": "noor@example.com",
"phone": "+31612345678"
},
"success_url": "https://yoursite.com/thank-you",
"cancel_url": "https://yoursite.com/checkout",
"selected_fee_ids": ["9b22..."],
"fee_overrides": { "9b22...": 5 },
"discount_code": "WELCOME10",
"fulfillment": {
"type": "delivery",
"requested_at": "2026-07-26T18:30:00.000Z",
"address": {
"street": "Eerste Helmersstraat",
"house_number": "56",
"postal_code": "1054 DJ",
"city": "Amsterdam",
"note": "Ring the bell twice"
},
"customer_note": "No onions please"
}
}| Field | Required | Notes |
|---|---|---|
items | yes | 1 to 100 lines. Each is content_item_id plus quantity up to 1000. |
customer | no | Name, email and phone. Send the email: it is how your customer's status updates reach the buyer. |
success_url / cancel_url | online only | Absolute URLs. Required for the Stripe path, rejected as pointless on the in-person path. |
organization_id | no | Not needed on either path above: checkout takes it from the path, overwriting one in the body, and checkoutInPerson from the key, ignoring one you send. Only the older online address reads it, from the body or as a query parameter. |
custom_payment_method_id | in person only | An id from the custom array of /payment-methods. |
selected_fee_ids | no | Optional fees the customer ticked. |
fee_overrides | no | Amounts for variable fees, keyed by fee id. |
discount_code | no | Whatever the customer typed. Up to 64 characters. |
fulfillment | restaurants | Pickup, delivery or dine-in. See Restaurant Ordering. |
Paying online
{
"success": true,
"data": {
"checkout_url": "https://checkout.stripe.com/...",
"session_id": "cs_...",
"order_number": "ORD-0042",
"fulfillment": { "type": "delivery", "address": { } }
}
}Redirect to checkout_url. Stripe renders its own payment page with every method your customer enabled, in the buyer's language.
Paying in person
{
"success": true,
"data": {
"order_id": "b7e0...",
"order_number": "ORD-0042",
"payment_type": "manual",
"fulfillment": { "type": "pickup" }
}
}The order exists immediately, marked unpaid, and appears in the dashboard right away. Staff marks it paid when they collect the money — and only an order paid this way can be marked paid; an online payment is confirmed by Stripe. This path needs the create_orders scope, which is deliberately narrow: it can create a pending order and nothing else.
A key without create_orders is refused with 403 PERMISSION_DENIED. A custom_payment_method_id that is not an active method of this organization — one the merchant switched off since your page was built — is 404 CUSTOM_PAYMENT_METHOD_NOT_FOUND, and nothing is created: read the ids from custom in the payment methods when the checkout renders.
Checking what arrived
Both responses echo data.fulfillment, which is what the server actually stored. Log it while building and compare it to what you sent. If it comes back null, your block never arrived.
Anything unrecognised at the top level of the body is thrown away and listed in data.ignored_fields. A field in that list did not take effect, whatever the 200 suggests. Most often it is a fulfillment block sent under the wrong name.
const res = await fetch(url, { method: 'POST', headers, body }).then(r => r.json())
// Sanity checks worth keeping until you have seen them come back clean once.
console.log('stored fulfillment:', res.data?.fulfillment)
if (res.data?.ignored_fields) console.warn('ignored:', res.data.ignored_fields)After the order
The order appears in the dashboard, and for restaurants on the live board. Your customer moves it along, and Cnter emails the buyer at each step. You do not send any of those: sending your own means the buyer gets everything twice, from two systems that will eventually disagree.
Orders can also be read and written directly with the read_orders and write_orders scopes, which is how a point of sale or an ERP stays in step. That is a back-office integration, not a storefront one.
What write_orders may do there has limits. POST /api/orders takes lines from this organization's own catalog only: a content_item_id that is not one of its items — another organization's, or nobody's — is 400 UNKNOWN_CATALOG_ITEM, the checkout's own refusal, and nothing is created. A key may create an order as pending, completed or cancelled, or send no status at all; any other value is 400 VALIDATION_ERROR — refunded and disputed are what Stripe makes of a payment. user_id says who on staff placed an order, which is the dashboard's to say: from a key it is ignored, and named in data.ignored_fields with any top-level field the create does not know. DELETE /api/orders/{id} removes an order with its lines, and only an order no money was ever recorded on: pending or cancelled, with no Stripe payment, payout, dispute or refund, and at most an in-person payment still to be collected. Any other is 409 ORDER_HAS_PAYMENT, for a key and in the dashboard alike — cancel it with PUT and status: cancelled instead, or refund it from the dashboard. An id that is not an order of this organization is 404 ORDER_NOT_FOUND.