API v1 · document 1.0.0
Vittles API
Put the venue's menu on its own website, take orders into the Vittles counter, and read their status back — from your server, with one key. No SDK, no webhooks, no surprises: every field on this page is the field the server validates.
openapi.jsonAuthenticationKeys & scopesTest modeMenuDaily menuVariants and amountsOrdersOrder statusQuoteDelivery zones and addressesInquiriesErrorsIdempotencyRate limitsVersioningAI prompt
Authentication
Every request except GET /openapi.json carries Authorization: Bearer <key>. Keys are issued by the venue owner in Vittles under Channels → API. There is no CORS: secret keys are for your server, not the browser.
Authorization: Bearer sk_live_…
Content-Type: application/json
Accept-Language: enA secret key that arrives with an Origin header is rejected with 403 secret_key_in_browser— browsers add that header to every request and servers never do. Call the API from your route handler, API route, WordPress hook or cron, and let your page talk to your server.
Keys & scopes
sk_live_… is a secret key for your server (scopes chosen by the owner, menu:read, orders:write and inquiries:write by default). sk_test_… is the same with test mode: nothing is stored, the counter stays silent. pk_live_… is the kiosk's public key — it may only be used by the Vittles kiosk page. The venue is always derived from the key, never from the request.
| Prefix | Kind | Mode | Where it lives |
|---|---|---|---|
sk_live_… | secret key | live | your server's secrets — never the browser |
sk_test_… | secret key | test | your server's secrets — never the browser |
pk_live_… | kiosk key (public) | live | the Vittles kiosk page on the tablet |
Scopes: menu:read, orders:quote, orders:write, orders:read, inquiries:write. A secret key gets the scopes the owner chose (menu:read, orders:write and inquiries:write by default; orders:write includes orders:quote and orders:read); a kiosk key always has menu:read and orders:write. A call outside the key's scopes is 403 insufficient_scope. Rotating a key keeps the old one valid for 24 hours so you can switch without downtime. Reserved for later, not implemented: menu:write and settings:* scopes, and server-sent events. They will appear here first.
Scopes say what a key may do: menu:read (GET /menu, POST /offers/seen), orders:quote (POST /orders/quote and POST /addresses/suggest, nothing is written), orders:write (POST /orders; it includes orders:quote and orders:read), orders:read (GET /orders/{id} for the orders of the key's own channel: a secret key reads orders created with secret keys, the kiosk key the kiosk's) and inquiries:write (POST /inquiries). The owner can change a secret key's scopes at any time in Channels → API; the kiosk key always has menu:read and orders:write. A call outside the scopes is 403 insufficient_scope.
The owner decides per channel what the menu shows and what it allows (Vittles → Channels): hide sold-out items or show them as sold out, hide items sold only in the restaurant, include or exclude categories (for example no drinks), switch the daily menu and photos off, set the channel to browse only, and narrow the fulfillment types and payment methods. A secret key and the kiosk key each follow their channel's rules, and a single key can carry its own content rules on top. GET /menu returns the menu after these rules; POST /orders and POST /orders/quote reject what the rules hide or forbid with the usual codes and details.reason: "channel" (item_not_orderable, ordering_disabled, fulfillment_not_offered, payment_method_not_offered). Test keys follow the same rules. Allergens and prices are always sent.
Keys and the widget are part of the Ultra plan; on a lower plan the API answers 403 plan_required (test keys included) and the embed shows a notice linking to the hosted menu. Keys are kept, not deleted — they answer again as soon as the plan is restored.
Test mode
A sk_test_ key runs POST /orders as a dry run through the same pricing and validation as a live order, inside a transaction that is rolled back. The response is a real-looking order with id: "test_…", number: 0 and test: true. GET /orders/test_… answers from the id's age: new → accepted → in_kitchen → ready (3 min) → closed (5 min, is_final). A test key cannot read live orders. GET /menu returns the real menu.
Variants and amounts
An item can come in sizes or options with their own price — Kofola 0.3 l for 1.80 and 0.5 l for 2.50 is ONE item with two variants. Each variant has an id, an optional name (the venue's own label, large), an amount ({ "value": 0.5, "unit": "l" }; units g, kg, ml, cl, dl, l, pcs, portion), its price_cents, available and a ready-to-show label in the response language. The item's price_cents is then the lowest variant price — show it as "from …" and let the guest pick. Send the pick as variant_id next to item_id on the order line; two sizes of one item are two lines. An item with variants and no variant_id is 422 variant_required; a variant_id the item does not have (deleted since you read the menu, another item's, or any on an item without variants) is 422 variant_invalid — refresh GET /menu; a sold-out variant (available: false) is 422 item_unavailable with the full name in details.item_name (Kofola 0,5 l). All three carry details.item_name. The quote and the order status show the chosen variant as items[].variant, and the line's name ends with it. Items with variants: [] are ordered exactly as before. amount is also on items and daily dishes without variants: the structured form of portion (which stays the text to show), or null when the venue wrote none or free text.
// GET /menu — categories[].items[]
{
"id": "5b0c7a52-1d3e-4f6a-8b9c-0d1e2f3a4b5c",
"name": "Kofola",
"portion": null,
"amount": null,
"price_cents": 180,
"variants": [
{ "id": "9e8d7c6b-5a49-4382-a1b0-c9d8e7f6a5b4", "name": null,
"amount": { "value": 0.3, "unit": "l" }, "price_cents": 180, "available": true, "label": "0.3 l" },
{ "id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "name": null,
"amount": { "value": 0.5, "unit": "l" }, "price_cents": 250, "available": true, "label": "0.5 l" }
]
}
// POST /orders (and /orders/quote) — items[]
{ "item_id": "5b0c7a52-1d3e-4f6a-8b9c-0d1e2f3a4b5c", "variant_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "quantity": 2 }
// POST /orders/quote — items[] (GET /orders/{id} has the same "variant")
{ "item_id": "5b0c7a52-1d3e-4f6a-8b9c-0d1e2f3a4b5c", "name": "Kofola 0,5 l", "quantity": 2,
"unit_price_cents": 250, "line_total_cents": 500, …,
"variant": { "id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "name": null,
"amount": { "value": 0.5, "unit": "l" }, "price_cents": 250, "label": "0,5 l" } }Dish tags
Each item carries tags: the venue's labels for the dish as keys, at most 4, in a fixed order (new, spicy, very_spicy, vegetarian, vegan, seasonal, homemade, kids). Show them in your own words and let guests filter by them. spicy and very_spicy never come together. Tags are content: a change bumps menu_version like any other menu edit. More keys may be added later, so ignore keys you do not know. [] = no tags.
Photos
Categories and items carry photos: up to 6 photos in the owner's order, each { url, width, height, alt } (width and height are null for photos linked from another website, alt is the owner's description in the Accept-Language language, otherwise in the other one, otherwise null). The first photo is the main one and always equals photo_url: show it wherever there is room for one, and the rest as a gallery the guest can swipe through. venue.theme.photos.slideshow describes what the hosted menu does with galleries (mode manual or auto, effect fade, slide or slow_zoom, interval_ms 3000 to 8000); it never tells you what to do, and an unknown mode should be treated as manual. Photos are content: a change bumps menu_version. photos: [] = no photos, also when the channel hides photos.
Orders
POST /api/v1/orders
Creates an order. The server prices every line from the menu, adds the venue's add-ons, applies the coupon and address discounts (never to daily-menu lines), and checks the ordering state (409), the fulfillment and payment offer (422), item availability (422), whether each item can be ordered online or through this channel (422 item_not_orderable, details.reason: "channel" when the channel hides its category or the daily menu), the daily menu's window and soup choice (422 daily_*) and the variant choice (422 variant_required, 422 variant_invalid). customer.phone is required on every order — table, pickup and delivery, secret and kiosk keys alike — and must be a complete number (400 invalid_request, param: "customer.phone"). The counter rings. Guest card: an order with a phone or e-mail creates or finds one; guest_card: false only stops a NEW card from being created (a card the guest already has still gets the order) — your form must show ordering.form.guest_card_notice next to those fields whenever it is not null.
Idempotency-Key— Client-generated key (up to 200 printable ASCII characters, e.g. a UUID) that makes the request safe to retry. Same key = same order, whatever the body says. Generate it when the cart is first submitted and keep it across every retry, including after 429 and 5xx. Keys starting withplatba:are reserved for orders paid online in the hosted menu and are rejected with400.Accept-Language—skoren— language oferror.messageandordering.message. Without the header,ordering.messageand the ordering errors (409/422) use the venue's language; every other message defaults tosk.
Request body
| Field | Type | Description |
|---|---|---|
fulfillmentrequired | "table" | "pickup" | "delivery" | How the guest gets the food. Must be one of ordering.fulfillment_types. |
table | string | Table label for fulfillment: "table". Accepted only with a secret api key, whose integrator vouches for the table (the signed table QR codes of the hosted menu are not needed here); a kiosk key cannot order to a table (fulfillment_not_offered). |
address | string | Delivery address for fulfillment: "delivery". |
address_token | string | address_token of the suggestion the guest picked (POST /addresses/suggest). Required for fulfillment: "delivery" when ordering.delivery.mode is zones; ignored in anywhere mode. Without it the server geocodes address once and takes the first suggestion with precision of street or better — or answers 422 address_unresolved. Valid for 30 minutes and only for this venue. |
customerrequired | object | Who is ordering. Required: phone always; name with a secret key (see ordering.form.required). |
customer.name | string | Guest name. With a secret key it is required for every fulfillment type (see ordering.form.required) — staff call out a name, not a number; the kiosk key needs none. |
customer.phonerequired | string | Phone number the venue can call about this order. Required on every order — every fulfillment type (table, pickup, delivery) and every key (secret and kiosk). It must be a complete number: with the country code (+421 900 123 456) or a Slovak number with the leading 0 (0900 123 456); a missing or incomplete number is 400 invalid_request with param: "customer.phone". |
customer.email | email | E-mail. Optional in the API; ordering.form.email says whether the venue wants it (required = ask every guest in your form, optional = recommended). |
guest_card | boolean | false = the guest chose “Don't create a guest card for me” for THIS order (offer it only when ordering.form.guest_card_opt_out_available is true, next to the sentence from ordering.form.guest_card_notice, and show ordering.form.guest_card_opt_out_notice once it is ticked): no NEW guest card is created from this order and the choice itself is not remembered. If the contact already has an active guest card, the order is still added to it (points, last visit), but nothing from this order is copied into the card. Omitted or true = no choice made. Any other value is 400. A per-guest coupon (valid once per guest) works with false too: it is counted per phone or e-mail, not per card. |
itemsrequired | array of objects | What the guest ordered. Add-ons (boxes, cutlery, delivery fee) are computed by the server from the venue's rules — do not send them. |
items[].item_idrequired | uuid | categories[].items[].id from GET /menu. |
items[].quantityrequired | integer | Whole number of portions. |
items[].soup_id | uuid | Daily menu only: the soup chosen for a daily.items[] dish with includes_soup: true — the id of a daily.soups[] entry with available: true (a sold-out one is 422 item_unavailable). Required when daily.soup_choice_required is true (otherwise 422 daily_soup_required, details.reason: "choose"); with a single available soup the server picks it, with none the dish is served without soup. Sending it for any other line is 422 daily_soup_required with details.reason: "invalid". |
items[].variant_id | uuid | The chosen variant: the id of one of the item's variants[] from GET /menu. Required when the item's variants is not empty (otherwise 422 variant_required); a variant that is not this item's, or a variant_id on an item without variants, is 422 variant_invalid; a sold-out variant is 422 item_unavailable. The line is priced with the variant and named after it (Kofola 0,5 l). Two sizes of one item are two lines. |
items[].offer_id | uuid | The id of an entry in offers from GET /menu when the guest added this line from that offer ("Add to your order?"). The line is then priced with the offer's discount. It must be the offer's item_id (and variant_id, when the offer has one), quantity 1, each offer at most once and at most two offer lines per order, and the order needs at least one other line (in trigger_category_id, when set); the offer must still be on and within its dates and hours. Otherwise 422 offer_invalid — the whole order is rejected, so drop offer_id from that line and let the guest confirm the regular price. |
items[].note | string | Note for the kitchen about this item. |
items[].metadata | object (free) | Your own data about this line, stored with it and shown to staff as-is. |
addon_choices | array of objects | The guest's choices for guest_choice add-ons. Omitted rules stay on their choice.default (which never preselects a paid piece). A count outside the rule's choice.min..choice.max is 422 invalid_addon_choice with details.addon_id, details.min and details.max; an id that is not such a rule is ignored. |
addon_choices[].addon_idrequired | uuid | addons[].id of a rule with quantity: "guest_choice" (cutlery, extra bags). |
addon_choices[].quantityrequired | integer | How many pieces in total, the included ones too. |
consumption | "eat_in" | "takeaway" | Only with fulfillment: "pickup" (otherwise 400): eat_in = the guest eats at the venue (no takeaway packaging; rules with applies_when eat_in), takeaway = takes it away. Omitted = takeaway; a kiosk key without it falls back to metadata.konzumacia (zjem tu = eat in). |
strict_total | boolean | true = refuse instead of accepting a different total: when client_total_cents (then required) differs from the server's subtotal, the order is NOT created and the answer is 409 total_changed with details.subtotal_cents (quote the same body for the lines, let the guest confirm, send again). An Idempotency-Key that already has an order still replays it (200). Default false: the order is accepted with the server's prices and flagged total_mismatch. |
coupon | string | Coupon code. Omit the field when the guest entered nothing; an empty string is treated as omitted. |
payment_method | "card" | "cash" | "card_on_delivery" | "cash_on_delivery" | "in_person" | "bank_transfer" | "qr_payment" | "card_online" | "external" | How the guest pays. Must be one of ordering.payment_methods and fit the fulfillment: card and cash are paid at the venue (pickup or table, never delivery), card_on_delivery and cash_on_delivery are paid to the courier (delivery only); otherwise payment_method_not_offered. Vittles never receives or holds the guest's money: card, cash, the cash-on-delivery methods, in_person (pay on collection, unspecified), bank_transfer and qr_payment are paid to the venue, and external means the guest already paid on your website (send payment_reference). card_online is rejected here: the hosted menu and the widget send the guest to the venue's own payment gateway. For bank_transfer and qr_payment the response carries the transfer details. Optional for backward compatibility, but a venue without any payment method takes no orders (ordering_disabled). |
payment_reference | string | Required with external, rejected with any other method: your reference of the payment made on your website (order or transaction number; letters, digits, spaces and _-./#:+). Stored with the order and shown to staff. Vittles does not verify it. |
note | string | Note for the whole order. |
client_total_cents | integer | The subtotal your UI showed the guest (sum of item prices × quantity, including add-ons, before discounts), in cents. Vittles always charges its own prices; a mismatch is accepted and flagged as total_mismatch: true. Use POST /orders/quote to get the exact number first. |
terms_accepted | boolean | Whether the guest ticked the venue's terms. Optional for API keys and not recorded; false is rejected. |
profiling_consent | boolean | Whether the guest consented to profiling. Accepted for shape parity with the hosted menu; NOT recorded by Vittles — keep your own consent log. |
guest_token | string | Opaque token from your own guest session (e.g. the token in a table QR code on your website). Stored with the order for your reference; authorises nothing. API keys only. |
metadata | object (free) | Your own data about the order, stored with it and shown to staff as-is. Keys starting with _ are reserved. |
{
"fulfillment": "pickup",
"customer": {
"name": "Jana Nováková",
"phone": "+421 900 123 456",
"email": "jana@example.com"
},
"items": [
{
"item_id": "3f8b2c1e-6a4d-4c0a-9d2e-1b5f7a8c9d01",
"quantity": 2
}
],
"payment_method": "in_person",
"client_total_cents": 1980
}200 — Replayed — the Idempotency-Key was already used (live key); the original order is returned from what was stored, before the body is read or anything is re-checked, so a venue that has since closed or switched an item off still answers 200.
Response 200
| Field | Type | Description |
|---|---|---|
idrequired | string | Order id. Keep it in your guest's session — it is the only ticket to GET /orders/{id}. Never put it in a URL, e-mail or log. |
numberrequired | integer | Human order number, unique per venue. Show it to the guest. 0 for test orders. |
statusrequired | "new" | "accepted" | "rejected" | "in_kitchen" | "ready" | "awaiting_delivery" | "out_for_delivery" | "awaiting_pickup" | "serving" | "closed" | "cancelled" | "expired" | "undelivered" | Current status. |
is_finalrequired | boolean | true when nothing more will happen to the order (closed, rejected, cancelled, expired). |
created_atrequired | date-time | When the order was created (ISO 8601). |
ready_atrequired | date-time | null | Estimated ready time set by staff on acceptance; null until then. |
accept_byrequired | date-time | null | Deadline for the venue to accept this new order. If staff have not accepted it by then, the order becomes expired (an order paid online is refunded). null when the venue has no acceptance window — an unaccepted order then expires only at the venue's daily closing. |
subtotal_centsrequired | integer | Sum of item prices × quantity including add-ons, before discounts. |
discount_centsrequired | integer | Total discount. |
discountrequired | object | How discount_cents was composed. |
discount.base_centsrequired | integer | Discount base: sum of the lines that are NOT add-ons and NOT daily-menu items (food and drinks, without packaging, delivery fee or the daily menu — coupons and address discounts don't apply to it). A percentage coupon, a coupon's minimum order value and the discount cap are all computed from this, not from subtotal_cents. |
discount.coupon_centsrequired | integer | Discount from the coupon. |
discount.address_centsrequired | integer | Discount from an address rule (delivery zone). |
discount.points_centsrequired | integer | Discount from loyalty points. Always 0 at creation — points are applied by staff. |
total_centsrequired | integer | What the guest pays: subtotal_cents - discount_cents. |
deliveryrequired | object | null | Delivery assessment. null for non-delivery orders, in anywhere mode, and for an address outside every zone (a suggestion only — an order gets 422 delivery_out_of_zone). |
delivery.zone_idrequired | string | Id of the delivery zone the address falls into. |
delivery.zonerequired | string | Zone name as the owner wrote it, e.g. North. |
delivery.distance_mrequired | integer | Estimated road distance from the venue in metres (straight line × the venue's road factor, rounded up). |
delivery.fee_centsrequired | integer | Delivery fee for this address; 0 when the food base reaches free_from_cents. It is also present as an add-on line. |
delivery.minimum_centsrequired | integer | Minimum order for this zone, counted from the food base (food and drinks without add-ons, daily-menu items included); 0 = none. |
delivery.free_from_centsrequired | integer | null | Food base (as for minimum_cents) from which delivery is free; null = never. |
delivery.lead_time_minutesrequired | integer | Delivery lead time for this zone (or the venue's default). |
paymentrequired | object | How the guest pays. Vittles never receives or holds the guest's money. |
payment.methodrequired | "card" | "cash" | "card_on_delivery" | "cash_on_delivery" | "in_person" | "bank_transfer" | "qr_payment" | "card_online" | "external" | null | The payment method, as sent. |
payment.transferrequired | object | null | Transfer details. Present only for bank_transfer and qr_payment, only in this response, never in the menu. |
payment.transfer.ibanrequired | string | null | Venue's IBAN. |
payment.transfer.recipientrequired | string | Account holder to write on the transfer. |
payment.transfer.variable_symbolrequired | string | The order number — write it as the variable symbol. |
total_mismatchrequired | boolean | true when client_total_cents was sent and differs from subtotal_cents. The order is accepted with the server's prices; tell the guest the new total. |
replayedrequired | boolean | true when this Idempotency-Key was already used: the original order is returned (HTTP 200), nothing new was created. |
testrequired | boolean | true for orders created with a test key: validated and priced but never stored. |
201 — Order created.
Response 201
| Field | Type | Description |
|---|---|---|
idrequired | string | Order id. Keep it in your guest's session — it is the only ticket to GET /orders/{id}. Never put it in a URL, e-mail or log. |
numberrequired | integer | Human order number, unique per venue. Show it to the guest. 0 for test orders. |
statusrequired | "new" | "accepted" | "rejected" | "in_kitchen" | "ready" | "awaiting_delivery" | "out_for_delivery" | "awaiting_pickup" | "serving" | "closed" | "cancelled" | "expired" | "undelivered" | Current status. |
is_finalrequired | boolean | true when nothing more will happen to the order (closed, rejected, cancelled, expired). |
created_atrequired | date-time | When the order was created (ISO 8601). |
ready_atrequired | date-time | null | Estimated ready time set by staff on acceptance; null until then. |
accept_byrequired | date-time | null | Deadline for the venue to accept this new order. If staff have not accepted it by then, the order becomes expired (an order paid online is refunded). null when the venue has no acceptance window — an unaccepted order then expires only at the venue's daily closing. |
subtotal_centsrequired | integer | Sum of item prices × quantity including add-ons, before discounts. |
discount_centsrequired | integer | Total discount. |
discountrequired | object | How discount_cents was composed. |
discount.base_centsrequired | integer | Discount base: sum of the lines that are NOT add-ons and NOT daily-menu items (food and drinks, without packaging, delivery fee or the daily menu — coupons and address discounts don't apply to it). A percentage coupon, a coupon's minimum order value and the discount cap are all computed from this, not from subtotal_cents. |
discount.coupon_centsrequired | integer | Discount from the coupon. |
discount.address_centsrequired | integer | Discount from an address rule (delivery zone). |
discount.points_centsrequired | integer | Discount from loyalty points. Always 0 at creation — points are applied by staff. |
total_centsrequired | integer | What the guest pays: subtotal_cents - discount_cents. |
deliveryrequired | object | null | Delivery assessment. null for non-delivery orders, in anywhere mode, and for an address outside every zone (a suggestion only — an order gets 422 delivery_out_of_zone). |
delivery.zone_idrequired | string | Id of the delivery zone the address falls into. |
delivery.zonerequired | string | Zone name as the owner wrote it, e.g. North. |
delivery.distance_mrequired | integer | Estimated road distance from the venue in metres (straight line × the venue's road factor, rounded up). |
delivery.fee_centsrequired | integer | Delivery fee for this address; 0 when the food base reaches free_from_cents. It is also present as an add-on line. |
delivery.minimum_centsrequired | integer | Minimum order for this zone, counted from the food base (food and drinks without add-ons, daily-menu items included); 0 = none. |
delivery.free_from_centsrequired | integer | null | Food base (as for minimum_cents) from which delivery is free; null = never. |
delivery.lead_time_minutesrequired | integer | Delivery lead time for this zone (or the venue's default). |
paymentrequired | object | How the guest pays. Vittles never receives or holds the guest's money. |
payment.methodrequired | "card" | "cash" | "card_on_delivery" | "cash_on_delivery" | "in_person" | "bank_transfer" | "qr_payment" | "card_online" | "external" | null | The payment method, as sent. |
payment.transferrequired | object | null | Transfer details. Present only for bank_transfer and qr_payment, only in this response, never in the menu. |
payment.transfer.ibanrequired | string | null | Venue's IBAN. |
payment.transfer.recipientrequired | string | Account holder to write on the transfer. |
payment.transfer.variable_symbolrequired | string | The order number — write it as the variable symbol. |
total_mismatchrequired | boolean | true when client_total_cents was sent and differs from subtotal_cents. The order is accepted with the server's prices; tell the guest the new total. |
replayedrequired | boolean | true when this Idempotency-Key was already used: the original order is returned (HTTP 200), nothing new was created. |
testrequired | boolean | true for orders created with a test key: validated and priced but never stored. |
Errors: 401 unauthorized, 403 insufficient_scope, 403 secret_key_in_browser, 403 origin_not_allowed, 403 plan_required, 400 invalid_request, 413 payload_too_large, 409 ordering_disabled, 409 ordering_paused, 409 ordering_closed, 409 delivery_paused, 422 fulfillment_not_offered, 422 payment_method_not_offered, 422 item_unavailable, 422 item_not_orderable, 422 item_unknown, 422 daily_menu_closed, 422 daily_soup_not_sold_alone, 422 daily_soup_required, 422 variant_required, 422 variant_invalid, 422 offer_invalid, 422 coupon_unknown, 422 coupon_disabled, 422 coupon_exhausted, 422 coupon_expired, 422 coupon_not_yet_valid, 422 coupon_min_total, 422 coupon_needs_contact, 422 coupon_needs_customer, 422 coupon_customer_exhausted, 422 coupon_already_used, 422 coupon_no_discount, 422 coupon_invalid, 422 address_unresolved, 422 address_imprecise, 422 delivery_out_of_zone, 422 delivery_minimum_not_met, 503 address_lookup_unavailable, 422 terms_not_accepted, 422 order_rejected, 429 rate_limited, 500 internal_error, 503 temporarily_unavailable
Test key: Dry run: full validation and pricing inside a rolled-back transaction. Nothing is stored, the counter stays silent, the response has id: "test_…", number: 0 and test: true.
curl
curl https://vittles.eu/api/v1/orders \
-X POST \
-H "Authorization: Bearer $VITTLES_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"fulfillment":"pickup","customer":{"name":"Jana Nováková","phone":"+421 900 123 456","email":"jana@example.com"},"items":[{"item_id":"3f8b2c1e-6a4d-4c0a-9d2e-1b5f7a8c9d01","quantity":2}],"payment_method":"in_person","client_total_cents":1980}'JavaScript (server-side fetch)
// Server-side only (route handler, API route, cron). Never in the browser.
const res = await fetch("https://vittles.eu/api/v1/orders", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VITTLES_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey, // crypto.randomUUID(), kept for every retry
},
body: JSON.stringify({
"fulfillment": "pickup",
"customer": {
"name": "Jana Nováková",
"phone": "+421 900 123 456",
"email": "jana@example.com"
},
"items": [
{
"item_id": "3f8b2c1e-6a4d-4c0a-9d2e-1b5f7a8c9d01",
"quantity": 2
}
],
"payment_method": "in_person",
"client_total_cents": 1980
}),
});
const body = await res.json();
if (!res.ok) {
// body.error.code is stable; body.error.message is for your log
throw new Error(`${body.error.code}: ${body.error.message} (${body.error.request_id})`);
}PHP (WordPress plugin)
// wp-config.php: define('VITTLES_API_KEY', 'sk_live_…'); — never in a theme option or in JavaScript.
$response = wp_remote_request('https://vittles.eu/api/v1/orders', [
'method' => 'POST',
'timeout' => 10,
'headers' => [
'Authorization' => 'Bearer ' . VITTLES_API_KEY,
'Content-Type' => 'application/json',
'Idempotency-Key' => wp_generate_uuid4(), // keep the same value for every retry
],
'body' => wp_json_encode([
'fulfillment' => 'pickup',
'customer' => [
'name' => 'Jana Nováková',
'phone' => '+421 900 123 456',
'email' => 'jana@example.com',
],
'items' => [
[
'item_id' => '3f8b2c1e-6a4d-4c0a-9d2e-1b5f7a8c9d01',
'quantity' => 2,
],
],
'payment_method' => 'in_person',
'client_total_cents' => 1980,
]),
]);
if (is_wp_error($response)) { error_log('Vittles: ' . $response->get_error_message()); return; }
$body = json_decode(wp_remote_retrieve_body($response), true);
if (wp_remote_retrieve_response_code($response) >= 400) {
error_log('Vittles ' . $body['error']['code'] . ': ' . $body['error']['message']);
}Order status
There are no webhooks. Poll GET /orders/{id} every 10 seconds through your own server (the key must not reach the browser) and stop when is_final is true. Send If-None-Match to get 304 while nothing changed.
GET /api/v1/orders/{id}
Status of one order for the guest — no contact data, no address. Poll every 10 seconds through your own server and stop when is_final is true. A key reads only orders of its own channel (a secret key the orders created with secret keys, a kiosk key the kiosk's); another venue's order, another channel's order, an unknown id and a malformed id all give 404. Needs orders:read (included in orders:write).
If-None-Match— TheETagfrom a previous response.Accept-Language—skoren— language oferror.messageandordering.message. Without the header,ordering.messageand the ordering errors (409/422) use the venue's language; every other message defaults tosk.
200 — Status.ETag: Weak tag of (status, ready_at).
Response 200
| Field | Type | Description |
|---|---|---|
idrequired | string | Order id. |
numberrequired | integer | Human order number. |
statusrequired | "new" | "accepted" | "rejected" | "in_kitchen" | "ready" | "awaiting_delivery" | "out_for_delivery" | "awaiting_pickup" | "serving" | "closed" | "cancelled" | "expired" | "undelivered" | Current status. |
is_finalrequired | boolean | true when nothing more will happen — stop polling. |
created_atrequired | date-time | When the order was created. |
ready_atrequired | date-time | null | Estimated ready time; null until staff accept the order. |
accept_byrequired | date-time | null | While the order is new: the deadline for the venue to accept it, after which it becomes expired (an order paid online is refunded). null once the order is no longer new, or when the venue has no acceptance window (an unaccepted order then expires at the daily closing). |
nowrequired | date-time | Server time at the moment of the response. Compute progress from this, not from the device clock. |
rejection_reasonrequired | string | null | Why staff rejected the order; null otherwise. |
total_centsrequired | integer | What the guest pays. |
itemsrequired | array of objects | Lines of the order, add-ons included. No line prices (only a chosen variant's own price), no contact data — this endpoint is safe to proxy to the guest. |
items[].namerequired | string | Item name as on the menu. |
items[].quantityrequired | integer | Portions. |
items[].dailyrequired | boolean | true for a line from the daily menu. |
items[].labelrequired | string | null | Daily menu only: the dish's label on the board (Menu 1); null otherwise. |
items[].souprequired | string | null | Daily menu only: the soup that comes with this dish, as the venue named it; null otherwise. |
items[].variantrequired | object | null | The variant the guest chose (name already ends with its label); null for a line without a variant. |
items[].variant.idrequired | string | Id of the variant at the time of the order (it may have been changed or deleted since). |
items[].variant.namerequired | string | null | The variant's own label as the owner wrote it; null when the amount alone named it. |
items[].variant.amountrequired | object | null | Amount of the variant; null when it had none. |
items[].variant.amount.valuerequired | number | The number: greater than 0, at most 3 decimals (0.5, 350). |
items[].variant.amount.unitrequired | "g" | "kg" | "ml" | "cl" | "dl" | "l" | "pcs" | "portion" | g, kg, ml, cl, dl, l, pcs (pieces) or portion. For the guest show label or portion, not a unit you translate yourself. |
items[].variant.price_centsrequired | integer | Price of the variant per portion at the time of the order. |
items[].variant.labelrequired | string | Text of the variant as the line's name ends with it (0,5 l), in the venue's language. |
items[].addonrequired | boolean | true for a line the venue's rules added (box, cutlery, delivery fee) or staff added as an add-on. |
items[].addon_idrequired | string | null | For an add-on line: the rule that added it; null otherwise. |
items[].addon_includedrequired | boolean | true for add-on pieces included free. |
items[].addon_chosenrequired | boolean | true for add-on pieces the guest chose. |
testrequired | boolean | true for a test order (test_… id). |
304 — Unchanged since the If-None-Match tag.ETag: The same tag, so the client can keep it.
Errors: 401 unauthorized, 403 insufficient_scope, 403 secret_key_in_browser, 403 origin_not_allowed, 403 plan_required, 404 not_found, 429 rate_limited, 500 internal_error, 503 temporarily_unavailable
Test key: A test_… id answers from its age without a database: new → accepted → in_kitchen → ready (3 min) → closed (5 min, is_final). A test key cannot read live orders.
curl
curl https://vittles.eu/api/v1/orders/8f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f \
-H "Authorization: Bearer $VITTLES_API_KEY" \
-H "Accept-Language: en"JavaScript (server-side fetch)
// Server-side only (route handler, API route, cron). Never in the browser.
const res = await fetch("https://vittles.eu/api/v1/orders/8f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.VITTLES_API_KEY}`,
},
});
const body = await res.json();
if (!res.ok) {
// body.error.code is stable; body.error.message is for your log
throw new Error(`${body.error.code}: ${body.error.message} (${body.error.request_id})`);
}PHP (WordPress plugin)
// wp-config.php: define('VITTLES_API_KEY', 'sk_live_…'); — never in a theme option or in JavaScript.
$response = wp_remote_request('https://vittles.eu/api/v1/orders/8f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f', [
'method' => 'GET',
'timeout' => 10,
'headers' => [
'Authorization' => 'Bearer ' . VITTLES_API_KEY,
],
]);
if (is_wp_error($response)) { error_log('Vittles: ' . $response->get_error_message()); return; }
$body = json_decode(wp_remote_retrieve_body($response), true);
if (wp_remote_retrieve_response_code($response) >= 400) {
error_log('Vittles ' . $body['error']['code'] . ': ' . $body['error']['message']);
}Quote
Discounts are computed from discount.base_cents — the lines that are not add-ons (food and drinks) and not from the daily menu. Packaging and the delivery fee are never discounted: a 10 % coupon on a 17.00 order with a 0.50 box and a 4.50 delivery fee takes 1.70 off, and subtotal_cents (22.00) still counts every line. Coupons and address discounts don't apply to daily-menu lines either (daily.discounts_apply: false, items[].daily: true in the quote) — they still count towards a delivery zone's minimum and free_from_cents. A coupon's minimum order value and the discount cap use the same base. Send the guest's coupon in coupon whenever they enter one; ordering.form.coupon in GET /menu is only a hint whether to show the field.
POST /api/v1/orders/quote
Prices an order without creating it: the same body as POST /orders (including the required customer.phone), the same validation, add-ons, coupon and discount rules, the same channel rules — and nothing is written. Use it to show the guest the exact total before sending. Rate-limited in its own bucket, separate from writes: quoting never spends the key's right to create orders, and the bucket still keeps a coupon check from being a free oracle. Needs orders:quote (included in orders:write), so a key that only shows prices cannot place orders.
Accept-Language—skoren— language oferror.messageandordering.message. Without the header,ordering.messageand the ordering errors (409/422) use the venue's language; every other message defaults tosk.
Request body
| Field | Type | Description |
|---|---|---|
fulfillmentrequired | "table" | "pickup" | "delivery" | How the guest gets the food. Must be one of ordering.fulfillment_types. |
table | string | Table label for fulfillment: "table". Accepted only with a secret api key, whose integrator vouches for the table (the signed table QR codes of the hosted menu are not needed here); a kiosk key cannot order to a table (fulfillment_not_offered). |
address | string | Delivery address for fulfillment: "delivery". |
address_token | string | address_token of the suggestion the guest picked (POST /addresses/suggest). Required for fulfillment: "delivery" when ordering.delivery.mode is zones; ignored in anywhere mode. Without it the server geocodes address once and takes the first suggestion with precision of street or better — or answers 422 address_unresolved. Valid for 30 minutes and only for this venue. |
customerrequired | object | Who is ordering. Required: phone always; name with a secret key (see ordering.form.required). |
customer.name | string | Guest name. With a secret key it is required for every fulfillment type (see ordering.form.required) — staff call out a name, not a number; the kiosk key needs none. |
customer.phonerequired | string | Phone number the venue can call about this order. Required on every order — every fulfillment type (table, pickup, delivery) and every key (secret and kiosk). It must be a complete number: with the country code (+421 900 123 456) or a Slovak number with the leading 0 (0900 123 456); a missing or incomplete number is 400 invalid_request with param: "customer.phone". |
customer.email | email | E-mail. Optional in the API; ordering.form.email says whether the venue wants it (required = ask every guest in your form, optional = recommended). |
guest_card | boolean | false = the guest chose “Don't create a guest card for me” for THIS order (offer it only when ordering.form.guest_card_opt_out_available is true, next to the sentence from ordering.form.guest_card_notice, and show ordering.form.guest_card_opt_out_notice once it is ticked): no NEW guest card is created from this order and the choice itself is not remembered. If the contact already has an active guest card, the order is still added to it (points, last visit), but nothing from this order is copied into the card. Omitted or true = no choice made. Any other value is 400. A per-guest coupon (valid once per guest) works with false too: it is counted per phone or e-mail, not per card. |
itemsrequired | array of objects | What the guest ordered. Add-ons (boxes, cutlery, delivery fee) are computed by the server from the venue's rules — do not send them. |
items[].item_idrequired | uuid | categories[].items[].id from GET /menu. |
items[].quantityrequired | integer | Whole number of portions. |
items[].soup_id | uuid | Daily menu only: the soup chosen for a daily.items[] dish with includes_soup: true — the id of a daily.soups[] entry with available: true (a sold-out one is 422 item_unavailable). Required when daily.soup_choice_required is true (otherwise 422 daily_soup_required, details.reason: "choose"); with a single available soup the server picks it, with none the dish is served without soup. Sending it for any other line is 422 daily_soup_required with details.reason: "invalid". |
items[].variant_id | uuid | The chosen variant: the id of one of the item's variants[] from GET /menu. Required when the item's variants is not empty (otherwise 422 variant_required); a variant that is not this item's, or a variant_id on an item without variants, is 422 variant_invalid; a sold-out variant is 422 item_unavailable. The line is priced with the variant and named after it (Kofola 0,5 l). Two sizes of one item are two lines. |
items[].offer_id | uuid | The id of an entry in offers from GET /menu when the guest added this line from that offer ("Add to your order?"). The line is then priced with the offer's discount. It must be the offer's item_id (and variant_id, when the offer has one), quantity 1, each offer at most once and at most two offer lines per order, and the order needs at least one other line (in trigger_category_id, when set); the offer must still be on and within its dates and hours. Otherwise 422 offer_invalid — the whole order is rejected, so drop offer_id from that line and let the guest confirm the regular price. |
items[].note | string | Note for the kitchen about this item. |
items[].metadata | object (free) | Your own data about this line, stored with it and shown to staff as-is. |
addon_choices | array of objects | The guest's choices for guest_choice add-ons. Omitted rules stay on their choice.default (which never preselects a paid piece). A count outside the rule's choice.min..choice.max is 422 invalid_addon_choice with details.addon_id, details.min and details.max; an id that is not such a rule is ignored. |
addon_choices[].addon_idrequired | uuid | addons[].id of a rule with quantity: "guest_choice" (cutlery, extra bags). |
addon_choices[].quantityrequired | integer | How many pieces in total, the included ones too. |
consumption | "eat_in" | "takeaway" | Only with fulfillment: "pickup" (otherwise 400): eat_in = the guest eats at the venue (no takeaway packaging; rules with applies_when eat_in), takeaway = takes it away. Omitted = takeaway; a kiosk key without it falls back to metadata.konzumacia (zjem tu = eat in). |
strict_total | boolean | true = refuse instead of accepting a different total: when client_total_cents (then required) differs from the server's subtotal, the order is NOT created and the answer is 409 total_changed with details.subtotal_cents (quote the same body for the lines, let the guest confirm, send again). An Idempotency-Key that already has an order still replays it (200). Default false: the order is accepted with the server's prices and flagged total_mismatch. |
coupon | string | Coupon code. Omit the field when the guest entered nothing; an empty string is treated as omitted. |
payment_method | "card" | "cash" | "card_on_delivery" | "cash_on_delivery" | "in_person" | "bank_transfer" | "qr_payment" | "card_online" | "external" | How the guest pays. Must be one of ordering.payment_methods and fit the fulfillment: card and cash are paid at the venue (pickup or table, never delivery), card_on_delivery and cash_on_delivery are paid to the courier (delivery only); otherwise payment_method_not_offered. Vittles never receives or holds the guest's money: card, cash, the cash-on-delivery methods, in_person (pay on collection, unspecified), bank_transfer and qr_payment are paid to the venue, and external means the guest already paid on your website (send payment_reference). card_online is rejected here: the hosted menu and the widget send the guest to the venue's own payment gateway. For bank_transfer and qr_payment the response carries the transfer details. Optional for backward compatibility, but a venue without any payment method takes no orders (ordering_disabled). |
payment_reference | string | Required with external, rejected with any other method: your reference of the payment made on your website (order or transaction number; letters, digits, spaces and _-./#:+). Stored with the order and shown to staff. Vittles does not verify it. |
note | string | Note for the whole order. |
client_total_cents | integer | The subtotal your UI showed the guest (sum of item prices × quantity, including add-ons, before discounts), in cents. Vittles always charges its own prices; a mismatch is accepted and flagged as total_mismatch: true. Use POST /orders/quote to get the exact number first. |
terms_accepted | boolean | Whether the guest ticked the venue's terms. Optional for API keys and not recorded; false is rejected. |
profiling_consent | boolean | Whether the guest consented to profiling. Accepted for shape parity with the hosted menu; NOT recorded by Vittles — keep your own consent log. |
guest_token | string | Opaque token from your own guest session (e.g. the token in a table QR code on your website). Stored with the order for your reference; authorises nothing. API keys only. |
metadata | object (free) | Your own data about the order, stored with it and shown to staff as-is. Keys starting with _ are reserved. |
{
"fulfillment": "pickup",
"customer": {
"name": "Jana Nováková",
"phone": "+421 900 123 456",
"email": "jana@example.com"
},
"items": [
{
"item_id": "3f8b2c1e-6a4d-4c0a-9d2e-1b5f7a8c9d01",
"quantity": 2
}
],
"payment_method": "in_person",
"client_total_cents": 1980
}200 — Price breakdown.
Response 200
| Field | Type | Description |
|---|---|---|
subtotal_centsrequired | integer | Sum of item prices × quantity including add-ons, before discounts. |
discount_centsrequired | integer | Total discount. |
discountrequired | object | How discount_cents was composed. |
discount.base_centsrequired | integer | Discount base: sum of the lines that are NOT add-ons and NOT daily-menu items (food and drinks, without packaging, delivery fee or the daily menu — coupons and address discounts don't apply to it). A percentage coupon, a coupon's minimum order value and the discount cap are all computed from this, not from subtotal_cents. |
discount.coupon_centsrequired | integer | Discount from the coupon. |
discount.address_centsrequired | integer | Discount from an address rule (delivery zone). |
discount.points_centsrequired | integer | Discount from loyalty points. Always 0 at creation — points are applied by staff. |
total_centsrequired | integer | What the guest pays: subtotal_cents - discount_cents. |
deliveryrequired | object | null | Delivery assessment. null for non-delivery orders, in anywhere mode, and for an address outside every zone (a suggestion only — an order gets 422 delivery_out_of_zone). |
delivery.zone_idrequired | string | Id of the delivery zone the address falls into. |
delivery.zonerequired | string | Zone name as the owner wrote it, e.g. North. |
delivery.distance_mrequired | integer | Estimated road distance from the venue in metres (straight line × the venue's road factor, rounded up). |
delivery.fee_centsrequired | integer | Delivery fee for this address; 0 when the food base reaches free_from_cents. It is also present as an add-on line. |
delivery.minimum_centsrequired | integer | Minimum order for this zone, counted from the food base (food and drinks without add-ons, daily-menu items included); 0 = none. |
delivery.free_from_centsrequired | integer | null | Food base (as for minimum_cents) from which delivery is free; null = never. |
delivery.lead_time_minutesrequired | integer | Delivery lead time for this zone (or the venue's default). |
itemsrequired | array of objects | Every line the order would have, with server prices and add-ons included. |
items[].item_idrequired | string | Menu item id. |
items[].namerequired | string | Item name. |
items[].quantityrequired | integer | Portions. |
items[].unit_price_centsrequired | integer | Server price per portion. |
items[].line_total_centsrequired | integer | unit_price_cents × quantity. |
items[].addonrequired | boolean | true for a line the venue's rules added (box, cutlery, delivery fee). |
items[].dailyrequired | boolean | true for a daily-menu line — coupons and address discounts don't apply to it (discount.base_cents leaves it out). |
items[].soup_idrequired | string | null | For a daily-menu dish with the soup included: the soup the server priced it with — the one you sent, or the only available soup it picked; null otherwise. |
items[].variantrequired | object | null | The variant the line was priced with (the variant_id you sent); name already ends with its label (Kofola 0,5 l). null for a line without a variant. |
items[].variant.idrequired | string | Id of the variant at the time of the order (it may have been changed or deleted since). |
items[].variant.namerequired | string | null | The variant's own label as the owner wrote it; null when the amount alone named it. |
items[].variant.amountrequired | object | null | Amount of the variant; null when it had none. |
items[].variant.amount.valuerequired | number | The number: greater than 0, at most 3 decimals (0.5, 350). |
items[].variant.amount.unitrequired | "g" | "kg" | "ml" | "cl" | "dl" | "l" | "pcs" | "portion" | g, kg, ml, cl, dl, l, pcs (pieces) or portion. For the guest show label or portion, not a unit you translate yourself. |
items[].variant.price_centsrequired | integer | Price of the variant per portion at the time of the order. |
items[].variant.labelrequired | string | Text of the variant as the line's name ends with it (0,5 l), in the venue's language. |
items[].zone_idrequired | string | null | For a delivery-fee line in zone mode: the zone; null otherwise. |
items[].up_to_mrequired | integer | null | For a per-km delivery-fee line: the step's upper bound in metres; null for a flat fee and for other lines. |
items[].addon_category_idrequired | string | null | For an add-on line from a per_item rule limited to one category (a box for every pizza): that category, and quantity is the number of portions of its items in the order. null for every other line. |
items[].addon_idrequired | string | null | For an add-on line: addons[].id of the rule that added it; null for food and for lines priced before rules had ids. |
items[].addon_includedrequired | boolean | true for add-on pieces included free (choice.included), priced 0. |
items[].addon_chosenrequired | boolean | true for add-on pieces the guest chose above the required minimum (addon_choices). |
addons_offeredrequired | array of objects | guest_choice add-ons this order offers (draw a stepper for each). Empty when none applies. |
addons_offered[].addon_idrequired | string | addons[].id of the guest_choice rule. |
addons_offered[].namerequired | string | Its name. |
addons_offered[].price_centsrequired | integer | Price of each piece above included. |
addons_offered[].minrequired | integer | Fewest pieces. |
addons_offered[].maxrequired | integer | Most pieces. |
addons_offered[].defaultrequired | integer | Preselected count. |
addons_offered[].includedrequired | integer | Pieces included free. |
addons_offered[].chosenrequired | integer | The count this quote used (your addon_choices, otherwise default). |
addons_offered[].availablerequired | boolean | false = sold out right now; nothing is added. |
testrequired | boolean | true when quoted with a test key. The quote itself never writes anything, with any key. |
Errors: 401 unauthorized, 403 insufficient_scope, 403 secret_key_in_browser, 403 origin_not_allowed, 403 plan_required, 400 invalid_request, 413 payload_too_large, 409 ordering_disabled, 409 ordering_paused, 409 ordering_closed, 409 delivery_paused, 422 fulfillment_not_offered, 422 payment_method_not_offered, 422 item_unavailable, 422 item_not_orderable, 422 item_unknown, 422 daily_menu_closed, 422 daily_soup_not_sold_alone, 422 daily_soup_required, 422 variant_required, 422 variant_invalid, 422 offer_invalid, 422 coupon_unknown, 422 coupon_disabled, 422 coupon_exhausted, 422 coupon_expired, 422 coupon_not_yet_valid, 422 coupon_min_total, 422 coupon_needs_contact, 422 coupon_needs_customer, 422 coupon_customer_exhausted, 422 coupon_already_used, 422 coupon_no_discount, 422 coupon_invalid, 422 address_unresolved, 422 address_imprecise, 422 delivery_out_of_zone, 422 delivery_minimum_not_met, 503 address_lookup_unavailable, 422 terms_not_accepted, 422 order_rejected, 429 rate_limited, 500 internal_error, 503 temporarily_unavailable
Test key: Same as with a live key.
curl
curl https://vittles.eu/api/v1/orders/quote \
-X POST \
-H "Authorization: Bearer $VITTLES_API_KEY" \
-H "Content-Type: application/json" \
-d '{"fulfillment":"pickup","customer":{"name":"Jana Nováková","phone":"+421 900 123 456","email":"jana@example.com"},"items":[{"item_id":"3f8b2c1e-6a4d-4c0a-9d2e-1b5f7a8c9d01","quantity":2}],"payment_method":"in_person","client_total_cents":1980}'JavaScript (server-side fetch)
// Server-side only (route handler, API route, cron). Never in the browser.
const res = await fetch("https://vittles.eu/api/v1/orders/quote", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VITTLES_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"fulfillment": "pickup",
"customer": {
"name": "Jana Nováková",
"phone": "+421 900 123 456",
"email": "jana@example.com"
},
"items": [
{
"item_id": "3f8b2c1e-6a4d-4c0a-9d2e-1b5f7a8c9d01",
"quantity": 2
}
],
"payment_method": "in_person",
"client_total_cents": 1980
}),
});
const body = await res.json();
if (!res.ok) {
// body.error.code is stable; body.error.message is for your log
throw new Error(`${body.error.code}: ${body.error.message} (${body.error.request_id})`);
}PHP (WordPress plugin)
// wp-config.php: define('VITTLES_API_KEY', 'sk_live_…'); — never in a theme option or in JavaScript.
$response = wp_remote_request('https://vittles.eu/api/v1/orders/quote', [
'method' => 'POST',
'timeout' => 10,
'headers' => [
'Authorization' => 'Bearer ' . VITTLES_API_KEY,
'Content-Type' => 'application/json',
],
'body' => wp_json_encode([
'fulfillment' => 'pickup',
'customer' => [
'name' => 'Jana Nováková',
'phone' => '+421 900 123 456',
'email' => 'jana@example.com',
],
'items' => [
[
'item_id' => '3f8b2c1e-6a4d-4c0a-9d2e-1b5f7a8c9d01',
'quantity' => 2,
],
],
'payment_method' => 'in_person',
'client_total_cents' => 1980,
]),
]);
if (is_wp_error($response)) { error_log('Vittles: ' . $response->get_error_message()); return; }
$body = json_decode(wp_remote_retrieve_body($response), true);
if (wp_remote_retrieve_response_code($response) >= 400) {
error_log('Vittles ' . $body['error']['code'] . ': ' . $body['error']['message']);
}Delivery zones and addresses
Delivery comes in two modes, announced in GET /menu as ordering.delivery.mode. In anywhere mode any address text is accepted and the fee is the venue's on_delivery add-on. In zones mode the venue has drawn delivery zones: call POST /addresses/suggest as the guest types (from 3 characters, debounced ~300 ms, always from your server), show the suggestions with their delivery block — zone, estimated road distance, fee, minimum order and lead time, or null outside every zone — and send the chosen suggestion's address_token in POST /orders/quote and POST /orders. The token is signed, valid 30 minutes and only for this venue; never store it or put it in a URL, it carries coordinates. Without a token the server geocodes address once and takes the first suggestion with precision of street or better. A place-only address is refused (422 address_imprecise), an address outside every zone too (422 delivery_out_of_zone), and a food base under the zone's minimum answers 422 delivery_minimum_not_met with details.minimum_cents and details.missing_cents. The fee appears as an add-on line with zone_id and up_to_m; free_from_cents in delivery says from which food base it drops to 0. Zone geometry is never exposed. Zones may overlap: among zones defined by named places (a town, a village, a part of a town) the smallest place that contains the address wins, so a part of a town with its own price beats the whole town; otherwise the zone order the owner set decides. When the lookup is unavailable (503 address_lookup_unavailable) offer pickup — a delivery order in zone mode cannot be placed without a resolved address.
POST /api/v1/addresses/suggest
Looks up what the guest typed and returns up to 5 suggestions, each already assessed against the venue's delivery zones: zone, road distance, fee, minimum and lead time — or delivery: null outside every zone. Send the chosen suggestion's address_token with the order. Call it from your server as the guest types (from 3 characters, debounced ~300 ms); it costs the venue geocoder budget. Only meaningful when ordering.delivery.mode is zones. Needs orders:quote (included in orders:write). The suggestions come from Geoapify, whose Free plan terms require attribution: show "Powered by Geoapify" as a link to https://www.geoapify.com/ right next to the suggestion list whenever it is visible.
Accept-Language—skoren— language oferror.messageandordering.message. Without the header,ordering.messageand the ordering errors (409/422) use the venue's language; every other message defaults tosk.
Request body
| Field | Type | Description |
|---|---|---|
queryrequired | string | What the guest typed — street, number, town. At least 3 characters; the lookup is biased to the venue's surroundings and limited to Slovakia. |
{
"query": "Hlinská 12, Žilina"
}200 — Suggestions.Cache-Control: no-store
Response 200
| Field | Type | Description |
|---|---|---|
suggestionsrequired | array of objects | Up to 5 suggestions, best first, each already assessed against the venue's zones. Empty when nothing matched. |
suggestions[].textrequired | string | Formatted address to show the guest. |
suggestions[].precisionrequired | "house" | "street" | "place" | house = the house number matched; street = street without a number (accepted — ask for the number in note); place = only a town (show it, do not let the guest pick it — an order with it gets 422 address_imprecise). |
suggestions[].deliveryrequired | object | null | Delivery assessment. null for non-delivery orders, in anywhere mode, and for an address outside every zone (a suggestion only — an order gets 422 delivery_out_of_zone). |
suggestions[].delivery.zone_idrequired | string | Id of the delivery zone the address falls into. |
suggestions[].delivery.zonerequired | string | Zone name as the owner wrote it, e.g. North. |
suggestions[].delivery.distance_mrequired | integer | Estimated road distance from the venue in metres (straight line × the venue's road factor, rounded up). |
suggestions[].delivery.fee_centsrequired | integer | Delivery fee for this address; 0 when the food base reaches free_from_cents. It is also present as an add-on line. |
suggestions[].delivery.minimum_centsrequired | integer | Minimum order for this zone, counted from the food base (food and drinks without add-ons, daily-menu items included); 0 = none. |
suggestions[].delivery.free_from_centsrequired | integer | null | Food base (as for minimum_cents) from which delivery is free; null = never. |
suggestions[].delivery.lead_time_minutesrequired | integer | Delivery lead time for this zone (or the venue's default). |
suggestions[].address_tokenrequired | string | Signed token of this suggestion — send it as address_token in POST /orders or /orders/quote. Valid 30 minutes, this venue only. Never store it or put it in a URL: it carries coordinates. |
Errors: 401 unauthorized, 403 insufficient_scope, 403 secret_key_in_browser, 403 origin_not_allowed, 403 plan_required, 400 invalid_request, 413 payload_too_large, 503 address_lookup_unavailable, 429 rate_limited, 500 internal_error, 503 temporarily_unavailable
Test key: Same as with a live key — the lookup is real and spends the venue's daily budget.
curl
curl https://vittles.eu/api/v1/addresses/suggest \
-X POST \
-H "Authorization: Bearer $VITTLES_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query":"Hlinská 12, Žilina"}'JavaScript (server-side fetch)
// Server-side only (route handler, API route, cron). Never in the browser.
const res = await fetch("https://vittles.eu/api/v1/addresses/suggest", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VITTLES_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"query": "Hlinská 12, Žilina"
}),
});
const body = await res.json();
if (!res.ok) {
// body.error.code is stable; body.error.message is for your log
throw new Error(`${body.error.code}: ${body.error.message} (${body.error.request_id})`);
}PHP (WordPress plugin)
// wp-config.php: define('VITTLES_API_KEY', 'sk_live_…'); — never in a theme option or in JavaScript.
$response = wp_remote_request('https://vittles.eu/api/v1/addresses/suggest', [
'method' => 'POST',
'timeout' => 10,
'headers' => [
'Authorization' => 'Bearer ' . VITTLES_API_KEY,
'Content-Type' => 'application/json',
],
'body' => wp_json_encode([
'query' => 'Hlinská 12, Žilina',
]),
]);
if (is_wp_error($response)) { error_log('Vittles: ' . $response->get_error_message()); return; }
$body = json_decode(wp_remote_retrieve_body($response), true);
if (wp_remote_retrieve_response_code($response) >= 400) {
error_log('Vittles ' . $body['error']['code'] . ': ' . $body['error']['message']);
}Inquiries
POST /api/v1/inquiries
Free-text inquiry from a plain form ("what would you like?"). No order is created and the counter does not ring: staff see the text with suggested menu items and confirm or reject it. Tell the guest "we got it", not "order accepted".
Idempotency-Key— Client-generated key (up to 200 printable ASCII characters, e.g. a UUID) that makes the request safe to retry. Same key = same order, whatever the body says. Generate it when the cart is first submitted and keep it across every retry, including after 429 and 5xx. Keys starting withplatba:are reserved for orders paid online in the hosted menu and are rejected with400.Accept-Language—skoren— language oferror.messageandordering.message. Without the header,ordering.messageand the ordering errors (409/422) use the venue's language; every other message defaults tosk.
Request body
| Field | Type | Description |
|---|---|---|
textrequired | string | What the guest wrote, unchanged. Vittles splits it by line breaks and ; to suggest menu items to staff. |
fulfillment | "table" | "pickup" | "delivery" | Optional; staff choose it when confirming if your form does not ask. |
address | string | Delivery address, if the guest gave one. |
customer | object | Who is asking. All optional; staff call back when a phone is given. |
customer.name | string | Guest name, if the form asked. |
customer.phone | string | Phone number, if the form asked. |
customer.email | email | E-mail, if the form asked. |
{
"text": "2x Margherita\n1x Coca-Cola",
"customer": {
"name": "Jana Nováková",
"phone": "+421 900 123 456"
}
}200 — Replayed — the Idempotency-Key was already used.
Response 200
| Field | Type | Description |
|---|---|---|
idrequired | string | Inquiry id. It is not an order id — GET /orders/{id} does not know it. |
replayedrequired | boolean | true when this Idempotency-Key was already used. |
testrequired | boolean | true for a test key: validated, not stored. |
201 — Inquiry created.
Response 201
| Field | Type | Description |
|---|---|---|
idrequired | string | Inquiry id. It is not an order id — GET /orders/{id} does not know it. |
replayedrequired | boolean | true when this Idempotency-Key was already used. |
testrequired | boolean | true for a test key: validated, not stored. |
Errors: 401 unauthorized, 403 insufficient_scope, 403 secret_key_in_browser, 403 origin_not_allowed, 403 plan_required, 400 invalid_request, 413 payload_too_large, 429 rate_limited, 500 internal_error, 503 temporarily_unavailable
Test key: Validated, not stored; 201 with id: "test_…".
curl
curl https://vittles.eu/api/v1/inquiries \
-X POST \
-H "Authorization: Bearer $VITTLES_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"text":"2x Margherita\n1x Coca-Cola","customer":{"name":"Jana Nováková","phone":"+421 900 123 456"}}'JavaScript (server-side fetch)
// Server-side only (route handler, API route, cron). Never in the browser.
const res = await fetch("https://vittles.eu/api/v1/inquiries", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VITTLES_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey, // crypto.randomUUID(), kept for every retry
},
body: JSON.stringify({
"text": "2x Margherita\n1x Coca-Cola",
"customer": {
"name": "Jana Nováková",
"phone": "+421 900 123 456"
}
}),
});
const body = await res.json();
if (!res.ok) {
// body.error.code is stable; body.error.message is for your log
throw new Error(`${body.error.code}: ${body.error.message} (${body.error.request_id})`);
}PHP (WordPress plugin)
// wp-config.php: define('VITTLES_API_KEY', 'sk_live_…'); — never in a theme option or in JavaScript.
$response = wp_remote_request('https://vittles.eu/api/v1/inquiries', [
'method' => 'POST',
'timeout' => 10,
'headers' => [
'Authorization' => 'Bearer ' . VITTLES_API_KEY,
'Content-Type' => 'application/json',
'Idempotency-Key' => wp_generate_uuid4(), // keep the same value for every retry
],
'body' => wp_json_encode([
'text' => '2x Margherita
1x Coca-Cola',
'customer' => [
'name' => 'Jana Nováková',
'phone' => '+421 900 123 456',
],
]),
]);
if (is_wp_error($response)) { error_log('Vittles: ' . $response->get_error_message()); return; }
$body = json_decode(wp_remote_retrieve_body($response), true);
if (wp_remote_retrieve_response_code($response) >= 400) {
error_log('Vittles ' . $body['error']['code'] . ': ' . $body['error']['message']);
}Errors
Every non-2xx response is { "error": { "code", "message", "param"?, "details"?, "request_id" } }. Branch on code — it never changes. message is for your log, in the Accept-Language language (sk | en). X-Request-Id is on every response; quote it when writing to support.
Error body
| Field | Type | Description |
|---|---|---|
errorrequired | object | Error. |
error.coderequired | "invalid_request" | "unauthorized" | "insufficient_scope" | "secret_key_in_browser" | "origin_not_allowed" | "plan_required" | "not_found" | "ordering_disabled" | "ordering_paused" | "ordering_closed" | "delivery_paused" | "payload_too_large" | "fulfillment_not_offered" | "payment_method_not_offered" | "item_unavailable" | "item_not_orderable" | "item_unknown" | "daily_menu_closed" | "daily_soup_not_sold_alone" | "daily_soup_required" | "variant_required" | "variant_invalid" | "offer_invalid" | "invalid_addon_choice" | "total_changed" | "coupon_unknown" | "coupon_disabled" | "coupon_exhausted" | "coupon_expired" | "coupon_not_yet_valid" | "coupon_min_total" | "coupon_needs_contact" | "coupon_needs_customer" | "coupon_customer_exhausted" | "coupon_already_used" | "coupon_no_discount" | "coupon_invalid" | "address_unresolved" | "address_imprecise" | "delivery_out_of_zone" | "delivery_minimum_not_met" | "terms_not_accepted" | "order_rejected" | "rate_limited" | "internal_error" | "address_lookup_unavailable" | "temporarily_unavailable" | Stable machine-readable code. |
error.messagerequired | string | Human sentence in the Accept-Language language (sk | en). |
error.param | string | The body field (dot notation, items[2].quantity) or header the error is about. |
error.details | object (free) | Machine-readable details, e.g. resumes_at for ordering_paused. |
error.request_idrequired | string | Vercel request id — quote it when writing to support. |
| HTTP | Code | When |
|---|---|---|
| 400 | invalid_request | The body, a field or a header has the wrong shape; param names it. |
| 401 | unauthorized | The key is missing, unknown, revoked, past its rotation grace period, or of a kind this endpoint does not accept (a kiosk key on POST /inquiries). |
| 403 | insufficient_scope | The key is valid but was not issued with the scope this endpoint needs. |
| 403 | secret_key_in_browser | A secret key (sk_…) arrived with an Origin header, which browsers add and servers never do. |
| 403 | origin_not_allowed | A kiosk key (pk_…) was used from a page that is not the Vittles kiosk. |
| 403 | plan_required | The venue's plan no longer includes this channel: secret keys (sk_…, live and test alike) need Ultra, kiosk keys (pk_…) need Pro. The key is kept and works again as soon as the plan is restored. |
| 404 | not_found | The order does not exist for this venue — also for a malformed id and for another venue's order (never 403). |
| 409 | ordering_disabled | Online ordering is switched off by the owner, the venue is not taking orders, or the owner set this channel to browse only (details.reason: "channel"). |
| 409 | ordering_paused | The kitchen paused online orders; details.resumes_at says until when. |
| 409 | ordering_closed | Outside kitchen hours; the message says when the kitchen opens. |
| 409 | delivery_paused | The kitchen paused delivery only; pickup and table orders still work. details.resumes_at says until when. Also returned by POST /orders/quote, so check it before you charge the guest. |
| 409 | total_changed | Only with strict_total: true: client_total_cents differs from the server's subtotal (prices or add-on rules changed). Nothing was created; details.subtotal_cents is the server's subtotal and details.client_total_cents yours. POST /orders/quote with the same body gives the lines and addons_offered; show the new total, let the guest confirm, then send again with the same Idempotency-Key. |
| 413 | payload_too_large | The request body exceeds ordering.form.max_body_bytes. |
| 422 | fulfillment_not_offered | The venue, or this channel, does not offer this fulfillment type; read ordering.fulfillment_types (details.reason: "channel" when the channel rule narrows it). |
| 422 | payment_method_not_offered | The venue, or this channel, does not offer this payment method; read ordering.payment_methods (details.reason: "channel" when the channel rule narrows it). |
| 422 | item_unavailable | An item is sold out right now; refresh the menu (available: false). |
| 422 | item_not_orderable | An item can be ordered only in the restaurant — the venue switched "Order online" off for it or its category (orderable: false in GET /menu), or the owner hid its category (or the daily menu) in this channel (details.reason: "channel"; GET /menu with this key leaves it out). details.item_id and details.item_name name it. Drop it from the cart or show it without an add button. |
| 422 | item_unknown | An item_id is not on this venue's menu. |
| 422 | daily_menu_closed | A daily-menu item was sent while daily.state is not open — before orders_from, from orders_until on, or after the venue unpublished the menu or removed the dish; details.item_name names it and details.orders_from / details.orders_until carry the window. Refresh GET /menu and drop daily lines from the cart. |
| 422 | daily_soup_not_sold_alone | A daily soup with orderable_alone: false was sent as its own line — it comes only with a daily item that has includes_soup: true. |
| 422 | daily_soup_required | A daily item with includes_soup: true needs soup_id (one of daily.soups[].id with available: true) when two or more soups are available — daily.soup_choice_required: true — or the soup_id sent is not today's soup, or was sent for an item without soup. details.reason is choose or invalid. |
| 422 | variant_required | The item has variants (sizes or options with their own price) and the line has no variant_id — send the id of one of its variants[]; details.item_name names the item. |
| 422 | variant_invalid | The variant_id does not belong to the item (deleted since the menu was read, or another item's), or the item has no variants at all. Refresh GET /menu and choose again; details.item_name names the item. |
| 422 | offer_invalid | A line with offer_id that the offer does not cover right now: the offer was switched off, deleted or is outside its dates or hours, the line has another item or variant than the offer, quantity is not 1, the same offer is on two lines, there are more than two offer lines, or the cart has no other line (in the offer's trigger_category_id, when set). details.item_name names the line, details.index is its position (from 1) and details.reason is invalid, quantity, repeated, trigger, limit or variant. Drop offer_id from that line (regular price), show the guest the new total and let them send again — never resend automatically. |
| 422 | invalid_addon_choice | An addon_choices[].quantity is outside the rule's choice.min..choice.max (the venue may have just changed it). details.addon_id, details.min and details.max say what is allowed; fix the stepper and send again. |
| 422 | coupon_unknown | No coupon with this code. |
| 422 | coupon_disabled | The coupon is switched off. |
| 422 | coupon_exhausted | The coupon's total use limit is spent. |
| 422 | coupon_expired | The coupon's validity has ended. |
| 422 | coupon_not_yet_valid | The coupon is not valid yet. |
| 422 | coupon_min_total | discount.base_cents (food and drinks, without add-ons and without daily-menu items) is below the coupon's minimum — subtotal_cents does not count. |
| 422 | coupon_needs_contact | A per-guest coupon needs customer.phone or customer.email — it is counted per contact, with or without a guest card. |
| 422 | coupon_needs_customer | A per-guest coupon could not be tied to a guest. |
| 422 | coupon_customer_exhausted | This guest (same phone or e-mail) has already used the coupon as many times as allowed. Only POST /orders checks it — a quote does not, so that a quote cannot reveal whether someone else's contact has used it. |
| 422 | coupon_already_used | The coupon was already applied. |
| 422 | coupon_no_discount | The coupon would give no discount on this order. |
| 422 | coupon_invalid | The coupon cannot be applied for a reason stated in details.reason. |
| 422 | address_unresolved | fulfillment: "delivery" in zone mode without a usable address_token, and address could not be geocoded to a street or a house. Call POST /addresses/suggest and send the chosen address_token. |
| 422 | address_imprecise | The address resolved only to a town (precision: "place"). Ask the guest for the street and house number. |
| 422 | delivery_out_of_zone | The address lies outside every active delivery zone. Offer pickup. |
| 422 | delivery_minimum_not_met | The food base (food and drinks without add-ons, daily-menu items included — unlike discount.base_cents, which leaves them out) is below the zone's minimum order; details.minimum_cents and details.missing_cents say how much. |
| 422 | terms_not_accepted | terms_accepted was sent and is not true. |
| 422 | order_rejected | The venue's rules rejected the order for a reason stated in details.reason. |
| 429 | rate_limited | Too many requests on this key; wait for Retry-After seconds and retry with the same Idempotency-Key. |
| 500 | internal_error | Our fault. Retry with the same Idempotency-Key; the request id is in our log. |
| 503 | address_lookup_unavailable | The address lookup (geocoder) is down, misconfigured or over its daily budget. Suggestions cannot be made; a delivery order in zone mode cannot be placed without one — offer pickup and retry later. details.reason: "venue_daily_limit" means this venue used up its own daily lookups; it resets at midnight (Europe/Bratislava). |
| 503 | temporarily_unavailable | The rate limiter or the database is unreachable (fail-closed), or a database migration is still pending — the message names it. Retry with backoff. |
Idempotency
Send Idempotency-Key on POST /orders and POST /inquiries. The same key always returns the same order (HTTP 200, replayed: true), even when the body differs. Generate it when the guest first submits and keep it across every retry — a lost response, not a double click, is what creates duplicate orders.
Idempotency-Key: 6d1f0c1e-3b8a-4f0e-9c1d-2a7b8e9f0a1bRate limits
Per key, four separate buckets: 30 writes per minute (POST /orders, POST /inquiries, POST /addresses/suggest), 60 quotes per minute (POST /orders/quote — quoting never spends the write bucket), 600 status reads per minute (GET /orders/{id}) and 120 menu reads per minute (GET /menu — a 304 counts too; cache the menu on your side and revalidate with If-None-Match instead of fetching it on every page view). Over the limit you get 429 rate_limited with Retry-After in seconds. If the limiter itself is unreachable the API fails closed with 503.
Versioning
The version is the first path segment after /api (currently v1). Fields are only added, never renamed or removed, within v1; new enum values may appear — treat unknown values as unknown, not as errors. A breaking change gets a new segment (v2) and v1 keeps working for at least six months after the announcement.
AI prompt
Working with an AI assistant on the venue's website? Paste this brief. It says which endpoint to use, where the key must live, how to retry, and what to show the guest — the mistakes it warns about are ones that actually happened.
Connect this website to Vittles — the venue's ordering system. Follow this
brief exactly; do not work around anything marked RULE, even when a shortcut
looks simpler.
## What I have
- Website: {{Astro / Next.js / WordPress / static HTML — and the repository URL or path}}
- Order form: {{a cart with "add" buttons / a plain text box "what would you like" / nothing yet}}
- Vittles URL: {{https://vittles.eu}}
- API key: it lives in the server's secrets. I will never paste it into this chat.
- Documentation: {{https://vittles.eu}}/docs/api and {{https://vittles.eu}}/api/v1/openapi.json
## Decide the path first
Vittles has two entrances. Pick ONE per form — never both.
A) The site knows the menu and the guest picks items into a cart
→ POST /api/v1/orders. An ORDER is created; the counter rings.
B) The site has a plain text box ("what would you like?")
→ POST /api/v1/inquiries. An INQUIRY is created: text and nothing else.
The counter does NOT ring until a person confirms it.
If the form e-mails the kitchen today, it is path B: repoint the form to your
server, add the header, done. Do path A only when the guest really picks
items from a list you fetched from Vittles.
Tell me which path you chose and why before writing code.
If the site only needs to SHOW the menu and take orders without custom code,
do neither: paste the widget snippet from the venue's Channels page
(`<div data-vittles-venue="…">` + `embed.js`) and stop here — see
{{https://vittles.eu}}/docs/embed. Keys and the widget belong to the venue's
Ultra plan; on a lower plan the API answers 403 `plan_required`.
## RULE 1 — the key never leaves the server
Authentication is `Authorization: Bearer <key>`. Whoever has the key can
create orders in the venue's name.
- read the key from an environment variable WITHOUT a public prefix
(`VITTLES_API_KEY`, not `PUBLIC_VITTLES_API_KEY`, not `NEXT_PUBLIC_*`)
- call Vittles ONLY from the server (Astro endpoint with
`export const prerender = false`, Next route handler, WordPress
`wp_remote_request`); the browser calls YOUR endpoint, which calls Vittles
- never send the `Origin` header: Vittles answers 403 `secret_key_in_browser`
to a secret key that arrives from a browser
- at the end, show me that the key is not in the client bundle
## RULE 2 — Vittles computes the prices
The server prices every line from its own menu. Whatever you send as a price
is only a checksum.
- take item ids and prices from GET /api/v1/menu; never hard-code them
- prices are integer CENTS: 9.50 EUR is `950`, not `9.5`
- add-ons (boxes, cutlery, delivery fee) come from `addons` rules and the
server adds them itself — do not send them as items
- send `client_total_cents` (the subtotal you showed, add-ons included) only
as a check; when the response says `total_mismatch: true`, show the guest
`total_cents` from the response — that is what they pay
- to show the exact total before sending, call POST /api/v1/orders/quote with
the same body; nothing is created
## RULE 3 — idempotency
- when the guest first submits the cart, generate `crypto.randomUUID()` and
keep it in the session
- send it as the `Idempotency-Key` header on EVERY retry of that cart (after
an error, a timeout, a 429, a 5xx)
- do NOT change the key after a failure. That is the whole point: the danger
is not a double click but a lost response — the guest adds a dessert,
submits again, and without the same key two orders exist
- a response with `replayed: true` (HTTP 200) means the order already
existed. It is not an error; show the confirmation
## RULE 4 — what to show the guest
Path A (order):
- after 201 show `number` and `total_cents` from the response; for
`bank_transfer` / `qr_payment` show `payment.transfer` (IBAN, recipient,
variable symbol) — it comes only in this response
- keep `id` in YOUR guest session (cookie / sessionStorage). Never in a URL,
an e-mail, a log or analytics — it is the only ticket to the status
- poll the status through your own server: GET /api/v1/orders/{id} every
10 seconds, send `If-None-Match` with the last `ETag`, and stop when
`is_final` is true. `expired` means the day closed without anyone
finishing the order — do not show it as "done"
- a daily-menu line has `daily: true` in the status: print it as
`label · name` ("Menu 1 · …") with `soup` under it, the same way the
kitchen ticket does
- required guest fields depend on `fulfillment`: read
`ordering.form.required` from the menu instead of guessing. The phone
(`customer.phone`) is required on EVERY order, table orders too — make
the phone field mandatory and send the number in full (`+421 …` or
`09…`); a missing or incomplete one is 400 `invalid_request` with
`param: "customer.phone"`
- delivery: read `ordering.delivery.mode` from the menu. In `zones` mode
call POST /api/v1/addresses/suggest through your server as the guest
types the address (from 3 characters, debounced ~300 ms), show each
suggestion with its `delivery` block (zone, distance, fee, minimum, lead
time — or "we do not deliver here" when it is `null`), let the guest pick
one that is not `precision: "place"`, and send its `address_token` in
the quote and the order. Never store the token or put it in a URL. On
422 `delivery_out_of_zone` / `address_imprecise` / `address_unresolved`
offer pickup; on `delivery_minimum_not_met` show `details.missing_cents`
- address suggestions come from Geoapify, whose Free plan terms require
attribution: show "Powered by Geoapify" as a link to
https://www.geoapify.com/ right under the suggestion list whenever it is
visible. Do not translate it and do not hide it
Path B (inquiry):
- after 201 say "We got it, we will get back to you" — NOT "Order accepted".
No order exists yet and the venue may decline
- the status cannot be read: an inquiry `id` is not an order id
- send the guest's `text` UNCHANGED. Do not join lines, do not strip `\n`,
do not trim the inside. Vittles splits the text by line breaks and `;` —
a comma is NOT a separator — and your "normalisation" breaks the matching
## RULE 5 — menu and cache
- GET /api/v1/menu returns an `ETag`. Send `If-None-Match` and reuse what
you have on 304
- the tag covers the whole body including `ordering.state`; do not build your
own cache on `menu_version`
- GET /api/v1/menu is rate-limited per key (120 per minute, a 304 counts
too): keep the menu in your server's cache and revalidate it at most once
a minute — never call Vittles once per page view of your site
- when `ordering.state` is not `open`, hide the order button and show
`ordering.message` (it is in the language of your `Accept-Language`);
`resumes_at` says when a pause ends
- show an item with `available: false` but do not let the guest order it.
Hiding it means the guest looks for it; leaving it orderable means 422
after the whole cart was filled
- show every item, but offer ordering only for `orderable: true` —
`orderable: false` with `available: true` means "in the restaurant only"
- `daily` is today's daily menu, or `null`. It is NOT a category: show it
as its own section above the menu while `daily.state` is `upcoming`
(no add button yet — ordering starts at `orders_from`) or `open`, and
hide it when it is `ended`. The state flips at `orders_from` and
`orders_until` without any edit and the `ETag` flips with it — refetch
the menu then, never decide the window from the guest's clock. Offer the
add button only for `orderable: true`. A dish with `includes_soup: true`
needs `soup_id` (a `daily.soups[].id` with `available: true`) on
its line when `soup_choice_required` is true. Coupons and address discounts never
apply to it (`discounts_apply: false`) — do not show them as discounted
- an item with a non-empty `variants` (sizes such as Kofola 0.3 l / 0.5 l)
is ONE item: show its `price_cents` as "from …", let the guest pick one
of `variants[]` (show its `label`, grey out `available: false`) and
send the pick as `variant_id` next to `item_id`. Two sizes are two cart
lines. Without `variant_id` the order is 422 `variant_required`
- offer only `ordering.fulfillment_types` and `ordering.payment_methods`
## RULE 6 — errors
Every non-2xx response is `{ "error": { "code", "message", "param"?,
"details"?, "request_id" } }`. Branch on `code`; log `message` and
`request_id`.
- 400 `invalid_request` → `param` names the field; that is YOUR bug, fix the
body. The API is strict: unknown fields are rejected, free data goes in
`metadata`
- 401 / 403 → your configuration (key, scope, Origin). Log it, tell the guest
"ordering is temporarily unavailable"; 403 `plan_required` means the
venue's plan does not include the API (`details.plan`) — tell the venue,
not the guest
- 409 `ordering_disabled` / `ordering_paused` (`details.resumes_at`) /
`ordering_closed` → show `message` and do NOT retry; bring the button back
after `resumes_at` or when the menu says `ordering.state: "open"`
- 422 `item_unavailable` / `item_not_orderable` / `item_unknown` → refresh
the menu and let the guest fix the cart; `coupon_*` → show `message` at
the coupon field; `fulfillment_not_offered` / `payment_method_not_offered`
→ refresh the menu
- 422 `daily_menu_closed` → the daily menu's window is over (or it was
withdrawn): refresh the menu and remove the daily lines from the cart;
`daily_soup_required` → ask the guest for the soup (`details.reason`
is `choose` or `invalid`); `daily_soup_not_sold_alone` → that soup
comes only with a daily dish
- 422 `variant_required` / `variant_invalid` → the line of
`details.item_name` needs a size: refresh the menu and let the guest pick
one of that item's `variants` again
- 429 `rate_limited` → respect `Retry-After`, pass it through your endpoint
to the client, retry with the SAME Idempotency-Key
- 5xx → retry with exponential backoff and the same Idempotency-Key
## Test first
Ask the venue for an `sk_test_` key. It runs the same validation and pricing,
stores nothing and the counter stays silent; the response has
`id: "test_…"`, `number: 0`, `test: true`, and GET /api/v1/orders/test_…
walks through the statuses by age so you can test polling end to end. Switch
to the `sk_live_` key only when the whole flow works.
## Do not
- do not send a venue id anywhere. The venue comes from the key
- do not call anything outside /api/v1 with the key
- do not send `coupon: ""` when the field is empty — omit the field
- do not rely on the Vittles rate limit as your form protection: it sees the
key, not the guest's IP, and it is shared by the whole site. Add your own
throttle and a honeypot
## Steps
1. Tell me which path you chose.
2. Write the server endpoint that is the only place that knows the key.
3. Wire the existing form to it. If it e-mails today, keep the e-mail
running too — until the venue trusts the counter, the second path is the
safety net.
4. Show me exactly what body is sent and where the key is read.
5. Run it against the test key and show me the response.