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

Online Ordering

Cart to paid order. One checkout endpoint, two payment paths, and a server that owns every amount.

A full brief for whoever builds against this.

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#

  1. 01

    Read the catalog

    Fetch the menu or product list from Content Management with content_type set. Show your own prices for display.

  2. 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.

  3. 03

    Build a cart

    A cart line is { content_item_id, quantity }. Nothing else is trusted.

  4. 04

    Post to checkout

    Online payment redirects to Stripe. Pay in person creates an unpaid order straight away.

  5. 05

    Confirm, do not promise

    Confirm the order was placed. The emails about timing come from Cnter.

What the customer can pay with#

GET/api/public/{organizationId}/payment-methodsPublic

The active online methods and the active pay-in-person methods.

json
{
  "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.

Two empty arrays means no checkout

When AgnusPAY is paused, inactive or not yet onboarded, stripe comes back empty — but custom does not. Manual methods are settled by the merchant, involve no payment provider and carry no fee, so they stay available whatever AgnusPAY is doing. Hide the checkout button only when both arrays are empty.

GET/api/public/{organizationId}/feesPublic

Active order fees: service charges, packaging, a tip. Not the delivery fee — that comes from the zone.

json
[
  { "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 }
]
FieldMeaning
is_requiredAlways applied. Show it, do not offer to remove it.
is_variableThe customer chooses the amount. A tip is the usual case.
typefixed for an amount, percentage for a share of the subtotal.
sort_orderThe 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.

POST/api/client/v1/{organizationId}/quotePublic

Everything 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.

ts
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.

A basket that can be ordered
{
  "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" }
  }
}
FieldMeaning
totalThe number to show and the number that will be charged. Everything else is the breakdown behind it.
subtotalThe 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.
discounttotal is what the promotions and the code together take off. code is a verdict — { "valid": true }, { "valid": false }, or null when none was entered.
taxWhat is owed, and inclusive says whether it sits inside total or on top of it. Show this rather than computing it.
deliveryWhere the address stands. Null for pickup, dine-in and a basket with no fulfillment.
openingWhether the kitchen is taking orders, and when it next will.

Never add the parts up yourself

On a discounted basket, and on an organization that prices without tax, subtotal + fees_total is not total. The sum that produced total is not on the wire and does not need to be. Render the lines for the customer to read, and take the amount from total.

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.

StateWhenWhat the cart does
delivery is nullPickup, 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.
unrestrictedYour customer drew no zones.They deliver everywhere. No fee, no minimum, nothing to ask for.
unknownZones 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_addressThe 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.
outsideNo zone accepted the address.Refused. reason separates OUT_OF_AREA from EXCLUDED_AREA; message is written for your logs, not for the buyer.
resolvedA zone matched.Show zone.fee. shortfall above zero means the basket is too small for this zone, not that the address is wrong.

A total without an address is not the final amount

In unknown and needs_address the basket is still orderable: true, and total contains no delivery fee — there is no zone yet to take one from. A cart that prints that number as the amount due shows too little, and the buyer meets the real figure on the payment page, which is the one place it costs you the order. Treat total as final only once delivery is resolved, unrestricted, or null.

The delivery fee is already in `fees`

When a zone resolves, its fee is pushed into fees[] as Delivery — <zone name> and counted in fees_total and in total. Rendering the fee list therefore renders the delivery line too. delivery.zone.fee is the same number, there so you can show it beside the address; adding it to anything charges the customer twice on screen.

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.

These hours are the kitchen's, not the building's

A venue can keep ordering hours narrower than its opening hours, and this block resolves that — including holiday closures and any day marked as an exception. The open_now on Organization answers a different question and is cached for a minute; this one is never cached. Closing time is exclusive: at 17:00 with a 17:00 close, the kitchen is shut.

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.

A refusal carries four keys, and no amounts — with one exception, below
{
  "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.

CodeWhat happenedWhat to do
VENUE_CLOSEDNo 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_HOURSA 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_ORDERThe 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_AREANo 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_UNAVAILABLEThis venue does not offer that fulfillment type.Offer only the types Restaurant Ordering lists as enabled.
INVALID_DISCOUNT_CODEThe 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_DISABLEDOnline 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_UNAVAILABLEA 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.

What the quote cannot know

It takes no customer email, so a promotion limited per customer or to a first order is not reflected in what it returns. The checkout applies those, and its amount is the one that is charged. The gap is small and always in the buyer's favour, but it is real: the quote is authoritative about the basket, not about who is holding it.

The checkout endpoint#

POST/api/client/v1/{organizationId}/checkoutPublic

Online payment. No key; the organization is in the path. Answers with a Stripe URL to redirect to. The client names it checkout.

POST/api/online-ordering/checkoutcreate_orders

Pay 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.

Request body
{
  "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"
  }
}
FieldRequiredNotes
itemsyes1 to 100 lines. Each is content_item_id plus quantity up to 1000.
customernoName, email and phone. Send the email: it is how your customer's status updates reach the buyer.
success_url / cancel_urlonline onlyAbsolute URLs. Required for the Stripe path, rejected as pointless on the in-person path.
organization_idnoNot 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_idin person onlyAn id from the custom array of /payment-methods.
selected_fee_idsnoOptional fees the customer ticked.
fee_overridesnoAmounts for variable fees, keyed by fee id.
discount_codenoWhatever the customer typed. Up to 64 characters.
fulfillmentrestaurantsPickup, delivery or dine-in. See Restaurant Ordering.

Never send a price

Line items accept name, description and image_url for display, but no price field exists and a total sent from a client is not read. The server prices every line from the content item. Show your own subtotal for display only, and treat the response as the truth.

Paying online#

json
{
  "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.

The redirect is not a confirmed order

The order is created after the payment settles, through a webhook. Someone who closes the tab on the Stripe page has no order. Your success page should say the payment went through, not that the kitchen has it.

Paying in person#

json
{
  "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.

ts
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.

Restaurant OrderingFulfillment, prep times and the kitchen board.PromotionsShowing discounts in the cart before payment.TaxRates, inclusive pricing and the VAT breakdown.
PreviousContent ManagementNextRestaurant Ordering

On this page

  • The flow
  • What the customer can pay with
  • What the basket costs
  • The checkout endpoint
  • Paying online
  • Paying in person
  • Checking what arrived
  • After the order