Partner API
HTTP /api/v1 — menu, shop config, checkout, slots, reservations, inquiries, catering, ETA quotes and orders. One API description, matching OpenAPI 3.1.
Partner API v1 is the HTTP interface at https://delieta.pl/api/v1. The API key Delieta issued to one company identifies that company: no request has a company field. This is the only description for people; the machine description is GET /api/v1/openapi (OpenAPI 3.1, no key).
Published operations: GET /menu, GET /shop-config, POST /shop-heartbeat, GET /checkout/slots, POST /checkout, POST /checkout/quote, GET /checkout/result, GET /events, POST /quotes, POST /orders, GET /orders/{id}, GET /orders/{id}/status, GET /floor-plan, GET /reservations/availability, POST /reservations, POST /inquiries, POST /catering-orders, GET /catering-orders and GET /catalog-images/{id}/{filename}.
flowchart LR
bearer["Klucz API"] --> verify["Klucz jest firma"]
verify --> scope{"scope"}
scope -->|"catalog:read"| menu["GET /menu"]
scope -->|"checkout:create"| checkout["POST /checkout"]
scope -->|"orders:write"| handoff["POST /orders"]
handoff --> same["to samo zamowienie co panel"]Key and scopes
Two equivalent headers. The first one found wins; a key in the query string is never accepted.
Authorization: Bearer dlt_live_…
x-delieta-api-key: dlt_live_…
Order of checks: key → scope → test key (writes only) → rate limit. A missing or wrong key gives 401 unauthorized.
| Scope | Key kind | Operations |
|---|---|---|
catalog:read | publishable | GET /api/v1/menu, GET /api/v1/shop-config, GET /api/v1/checkout/slots, GET /api/v1/events, GET /api/v1/orders/{id}/status, GET /api/v1/floor-plan, GET /api/v1/reservations/availability and POST /api/v1/shop-heartbeat. menu:read is an alias for older keys. |
checkout:create | publishable | POST /api/v1/checkout, POST /api/v1/checkout/quote, GET /api/v1/checkout/result, POST /api/v1/inquiries and POST /api/v1/reservations (a web booking). |
eta:quote | secret | POST /api/v1/quotes |
orders:write | secret | POST /api/v1/orders and GET /api/v1/orders/{id} |
reservations:write | secret | POST /api/v1/reservations from the company's own system (an api booking) |
catering:write | secret | POST /api/v1/catering-orders and GET /api/v1/catering-orders |
A publishable key is exactly catalog:read + checkout:create and nothing more: the shop holds it, and the shop keeps it on the server side, never in the browser. A publishable key cannot get orders:write, eta:quote, reservations:write or catering:write. An empty scope list opens nothing — 403 scope_required. A reservation counts the scope by key kind: publishable needs checkout:create, secret needs reservations:write.
dlt_test_… reads the company's real products and computes quotes. Every write is refused with 403 test_key_readonly: POST /api/v1/checkout, /orders, /reservations, /inquiries, /catering-orders and /shop-heartbeat. There is no separate test data set.
Rate limits
Limits are per key, in a 60 s window. By default 60 writes and 600 reads per minute, counted separately so frequent polling does not use up the write limit. Writes are order creation, checkout, reservation, inquiry, catering order and quote. Reads are everything else. Over the limit: 429 rate_limited; honour Retry-After and RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset.
A publishable key is used by many visitors, so its limit is counted per visitor. Send the guest's address in the x-delieta-visitor header; without it the Origin counts. A shared upper limit of 20 times the limit also applies. A secret key ignores this header.
Errors
Every v1 refusal is application/problem+json (RFC 9457). Identify the error by code, not by the text.
{
"type": "https://delieta.pl/problems/unauthorized",
"title": "Unauthorized",
"status": 401,
"code": "unauthorized",
"detail": "Provide a valid API key as `Authorization: Bearer dlt_live_…`."
}
Exception: GET /api/v1/catalog-images/{id}/{filename} over its limit answers a plain { "error": "rate_limited" } with 429, not problem+json.
| code | status | when |
|---|---|---|
unauthorized | 401 | missing or wrong key |
scope_required | 403 | key without the required scope |
test_key_readonly | 403 | dlt_test_… tried a write |
module_disabled | 403 | the company's module is off (plan or owner switch): ordering on POST /checkout and GET /checkout/slots, reservations, catering. The status of a placed order keeps working |
invalid_request | 400 | body is not JSON or fails validation; details in errors[] (for example unknown_field on POST /orders) |
idempotency_key_required | 400 | write without Idempotency-Key (8–255 characters) |
line_price_missing / line_name_missing / total_out_of_range | 400 | an order line or the total cannot be priced |
payload_too_large | 413 | inquiry or catering-order body over 64 KiB |
not_found | 404 | no such order for this key (a foreign id answers identically) |
tenant_not_found | 404 | the key's company no longer exists |
idempotency_key_conflict | 409 | same idempotency key, different body |
conflict | 409 | for example the inquiry is already linked to another job |
venue_not_configured | 409 | quote, but the company has no venue coordinates |
out_of_range | 409 | quote or checkout with delivery: the address is outside the company's delivery range (errors[] on the address field). On a reservation the same code is 422 (date out of range) |
delivery_unconfigured | 409 | checkout with delivery: the venue has km bands but no address or delivery set. Pickup works |
total_changed | 409 | checkout: the given expectedTotalGrosze does not match the amount computed now. The server did not use it for pricing |
product_outside_hours | 409 | checkout: an item is outside its availability hours at the chosen time; lines in errors[] on items.N.itemId |
shop_closed | 409 | the shop is closed now and will not take an "as soon as possible" order |
shop_unconfigured | 409 | no saved shop origin for the payment return addresses |
connect_not_ready | 409 | the online-payments account cannot take payments yet |
sample_mode | 409 | online payment in a shop on sample data while payments are live |
slot_unavailable | 409 | requestedFor is not one of the times from GET /checkout/slots; on a reservation: the time is taken |
table_taken | 409 | reservation: the table is taken (also when someone else booked it a moment earlier) |
fulfilment_unavailable | 409 | the shop does not offer that fulfilment (delivery or pickup) |
payment_unavailable | 409 | the shop does not accept that payment (online or cash) |
duplicate_external_ref | 409 | this externalRef already created an order for the company |
unprocessable | 422 | unknown or unavailable item (errors[].code: product_unknown, product_unavailable); unknown catering format |
below_minimum | 422 | cart below the shop's minimum order value (dishes only, without packaging and delivery; field items) |
product_menu_only | 422 | checkout: an item is "Tylko w karcie" (menu only) and cannot be ordered online; lines in errors[] on items.N.itemId. When both cases apply, this one wins |
address_not_found | 422 | checkout with delivery: the address could not be resolved (field address) |
headcount_out_of_range | 422 | catering inquiry: the number of people is outside the format's range (catering.headcount) |
venue_not_offered | 422 | catering inquiry: the format does not serve the chosen place (catering.venue) |
unknown_add_on | 422 | catering inquiry: the add-on does not belong to the chosen format (catering.addOns.N.key) |
unknown_event, event_not_bookable, event_past, event_phone_only | 422 | event inquiry: no such event, it does not take inquiries, it already took place, or sign-up is by phone only (event.eventId) |
unknown_occurrence, event_in_past | 422 | event inquiry: the date is outside the list or in the past (event.occurrenceId, event.preferredDate) |
participants_out_of_range | 422 | event inquiry: the number of participants is outside the event's range (event.participants) |
option_invalid | 422 | unknown option, missing required group or too many choices in a group |
validation_failed / party_too_large | 422 | reservation: bad content, party larger than the room holds |
rate_limited | 429 | per-minute limit exceeded; see rate limits |
internal | 500 | a fault on our side; retry |
create_failed | 500 | POST /orders: the order could not be saved; retry with the same Idempotency-Key |
idempotency_unavailable | 503 | the idempotency store is down; retry with the same key |
delivery_quote_unavailable | 503 | checkout or quote with delivery: the distance could not be computed right now; retry or choose pickup |
module_unavailable | 503 | the company's modules could not be read (the module is not off); retry shortly (Retry-After) |
Money
Whole grosze only. 3000 is 30.00 PLN. No floats, no currency field on amounts. currency in responses is the constant "PLN".
GET /api/v1/menu
The products of the company the key belongs to. Scope catalog:read. Unavailable items are omitted — there is no available flag in the DTO for the client to handle. Items "Tylko w karcie" (menu only, orderable: false) are in menuOnlyItems, not in items; availability hours are in availability. The response has no "available now" flag: the shop computes it from availability. The structure does not change; new fields can only be added.
GET /api/v1/menu
Authorization: Bearer dlt_live_…
{
"currency": "PLN",
"generatedAt": "2026-08-26T12:00:00.000Z",
"locale": "pl",
"categories": [
{ "id": "c1", "slug": "pizza", "name": "Pizza", "sort": 0, "orderable": true, "availability": null }
],
"items": [
{
"id": "i1",
"slug": "margherita",
"categoryId": "c1",
"name": "Margherita",
"description": null,
"basePriceGrosze": 3200,
"allergens": [],
"modifierGroups": [],
"imageUrl": null,
"featured": false,
"sort": 0,
"orderable": true,
"availability": null
}
],
"menuOnlyCategories": [],
"menuOnlyItems": []
}
An empty array of categories and items is a valid answer, not an error. Cache-Control: no-store. The response includes an x-delieta-cache-tag header; a shop with its own cache uses it as the key.
The optional ?locale= (pl, en, de, uk) returns names and descriptions in that language, field by field, and in Polish when a translation is missing. For a language the company has not enabled (or an unknown one) you get the content in Polish; locale in the response says which language was actually returned. GET /api/v1/shop-config (catering format names; it also returns the enabled locales, Polish first) and GET /api/v1/floor-plan (zone names) read the same ?locale=.
imageUrl is the absolute address of the dish photo (the 800 px version) or null when the dish has none. featured: true means the owner marked the dish as "Featured".
orderable: false means „Tylko w karcie” (menu only): show the item with its price, without the „Do koszyka” button. Such items are not in items but in menuOnlyItems, and a category in which every item is menu only is in menuOnlyCategories, not in categories — a client that does not know these keys simply does not show them and never offers something that cannot be ordered. @delieta/shop-runtime from 1.8.0 merges them back (withMenuOnly). The value on the item already takes its category into account (a menu-only category = every item in it). availability holds the availability hours: { "windows": [{ "days": [1,2,3,4], "from": "12:00", "to": "16:30" }], "fromDate": null, "toDate": null } — up to 7 windows, days from 0 = Monday, both edges inclusive, Europe/Warsaw time. The item has its own rule and the category has its own; both apply at once. An item past its season (toDate has passed) does not reach the menu at all. The menu is cached, so it does not say whether an item is available now — compute that from the rule (@delieta/shop-runtime does it from 1.8.0: itemSaleState, liveMenu). Checkout checks the rule for the fulfilment time: requestedFor, or now + preparation time for „jak najszybciej”.
GET /api/v1/catalog-images//
A photo of a dish or catering format. No key: the browser of the shop's customer opens the address from imageUrl, so a secret must not be required. You must know the id and the exact file name — a pair that does not match is a 404. ?size=card returns the 800 px wide version (a narrower photo is returned unchanged); without the parameter you get the original.
GET /api/v1/catalog-images/{id}/{filename}?size=card
The response is a 302 to the file store or the image bytes (image/jpeg, image/png, image/webp or image/avif). A photo address never changes content — a replaced photo gets a new address — so the response carries Cache-Control: public, max-age=31536000, immutable. Limit: 600 requests per minute from one IP (rate_limited).
GET /api/v1/shop-config
The brand and rules of the shop of the company the key belongs to, as its shop shows them. Scope catalog:read — the same as the menu, so a publishable key already has it. The shop's own brand settings take priority, field by field; where they are missing, the company's name, logo and colour apply. The response includes x-delieta-cache-tag.
GET /api/v1/shop-config
Authorization: Bearer dlt_live_…
{
"name": "Bella Napoli",
"tagline": "Pizza z pieca opalanego drewnem",
"brandColor": "#e2574c",
"logo": "https://delieta.pl/api/v1/catalog-images/{id}/{filename}",
"banner": "https://delieta.pl/api/v1/catalog-images/{id}/{filename}",
"icon": "https://delieta.pl/api/v1/catalog-images/{id}/{filename}",
"currency": "PLN",
"locale": "pl",
"locales": ["pl", "en"],
"layout": "grid",
"links": {
"phone": "+48000000000",
"email": "kontakt@bella.example",
"instagram": "https://instagram.com/bella.example",
"facebook": "https://facebook.com/bella.example",
"address": "Kwiatowa 5, Bydgoszcz",
"mapUrl": "https://www.google.com/maps/dir/?api=1&destination=Kwiatowa%205%2C%20Bydgoszcz"
},
"modules": ["ordering", "reservations", "catering"],
"ordering": {
"fulfilmentModes": ["delivery", "pickup"],
"paymentModes": ["online", "cash"],
"onlineMethods": ["card", "blik"],
"deliveryFeeGrosze": 500,
"minimumOrderGrosze": 4000,
"prepMinutes": 30
},
"hours": {
"days": [
{ "weekday": 0, "closed": false, "open": "10:00", "close": "22:00" },
{ "weekday": 6, "closed": true, "open": "10:00", "close": "22:00" }
]
},
"closures": [
{ "from": "2026-12-24", "to": "2026-12-26", "note": "Święta" }
],
"cateringFormats": [
{
"id": "f1",
"name": "Lunch firmowy",
"description": "Pizza i napoje dla zespołu.",
"fromPriceGrosze": 3500,
"minHeadcount": 10,
"imageUrl": null,
"items": [
{ "productId": "i1", "name": "Margherita", "quantity": 1, "perPerson": true, "note": null }
]
}
],
"generatedAt": "2026-08-26T12:00:00.000Z"
}
| Field | Meaning |
|---|---|
banner | the company's optional 3:1 banner, or null |
icon | the company's square icon (at least 512×512, PNG) for the favicon and app icon, or null: the shop then keeps its own. Runtime 1.9: shopIcons(config) |
locales | languages the company enabled, Polish first |
layout | grid or list |
links | only what the venue filled in; mapUrl is built from the address (a Google Maps route) when the venue gave none of its own |
modules | the shop modules the company has on: ordering, reservations, catering, events. What is not here, the shop does not show |
ordering | how the shop takes orders: fulfilmentModes, paymentModes (online only when the shop can charge a card; the list can be empty), onlineMethods (card, blik, p24: the methods active on the Stripe account; empty without online; name only these. May be empty while online is offered (Delieta has not yet recorded which methods are active): show a generic «Płatność online» and name no method), deliveryFeeGrosze, minimumOrderGrosze, prepMinutes. null when the ordering module is off: do not show a checkout form then |
hours | weekly opening hours, or null when the venue has not set them — do not assume any hours |
closures | upcoming closed days (from, to, note or null), dates in Europe/Warsaw. A missing field means no closed days |
cateringFormats | catering formats with a product list (items) and a photo (imageUrl); only when the catering module is on. Only formats with venue delivery or both and priceBasis from are here — the ones a shop on package 1.7 renders correctly |
cateringPackages | every active format with all package fields; only when the catering module is on. Fields: slug, venue (onsite, delivery, both), priceBasis (from, per_person, from_per_person), minHeadcount/maxHeadcount, sections (lines with a product or a custom name), included („W cenie”), terms („Warto wiedzieć”) and addOns (key, name, amount, unit: szt, kg, g, l, ml or porcja, priceGrosze, product?). Shop package 1.8 reads this field instead of cateringFormats |
hours.days has exactly seven entries, one per day (weekday 0 = Monday … 6 = Sunday); shortened to two above. open and close are HH:MM in the venue's local time and only matter for closed: false. Modules, ordering and links are read on every request. A company the key belonged to that no longer exists → 404 tenant_not_found.
POST /api/v1/shop-heartbeat
A deployed shop reports which runtime it was built with. Once per build, never per request. Scope catalog:read, no scope of its own. The write only affects the fleet columns in that company's shop row. Always 204, also when the company has no shop row or the body is unreadable. A dlt_test_… key is refused with 403 test_key_readonly. delieta-shop-assert sends this for you; you do not have to call it yourself.
POST /api/v1/shop-heartbeat
Authorization: Bearer dlt_live_…
Content-Type: application/json
{ "runtime": "1.6.0", "contract": "v3", "commit": null, "modified": false }
POST /api/v1/checkout
The shop's cart. Scope checkout:create (publishable key). Needs the ordering module. Idempotency-Key is required (8–255 characters). The client sends what, never how much: { items: [{ itemId, quantity, options? }] }, where quantity is 1–99, at most 50 lines, and options is [{ group, option }] (group and option keys from the menu). Delieta computes prices, option surcharges and the total from the products of the company the key belongs to, in whole PLN grosze. A unitPrice / total / companyId field in the body is ignored. The addresses after a successful and a cancelled payment come from that company's shop configuration, never from the request.
A shop that collects the guest's details itself adds: fulfilment (delivery or pickup, default delivery), requestedFor (a time from GET /api/v1/checkout/slots as an ISO date with offset, or null = as soon as possible), customer (name, phone, email?), address (street, number, flat?, postalCode in the 00-000 format, city, note?), invoice (nip with checksum, companyName?, address?), note (up to 500 characters), locale and payment (blik, card, p24 or cash). Rules: pickup, cash and an address require customer; delivery with customer requires address; a pickup order drops the address. The optional expectedTotalGrosze is the amount the client saw on screen. The server never prices from it, it only compares it with the amount computed now: when they differ, it answers 409 total_changed. items alone is the old format: online payment, delivery, and Stripe asks for the phone and address itself.
POST /api/v1/checkout
Authorization: Bearer dlt_live_…
Content-Type: application/json
Idempotency-Key: chk-2026-08-26-42
{ "items": [{ "itemId": "i1", "quantity": 2 }] }
201 response (checkout)
{
"url": "https://checkout.stripe.com/c/pay/cs_test_…",
"totalGrosze": 6400,
"currency": "PLN",
"pricingSource": "catalog"
}
An online payment answers with url: redirect the guest to the payment page. The order is created when they pay; you read the outcome in GET /api/v1/checkout/result. A cash payment (payment: "cash") answers at once with a placed, still unpaid order that waits for the kitchen to accept it:
{
"orderShortId": "a1b2c3d4",
"trackingUrl": "https://delieta.pl/o/a1b2c3d4",
"totalGrosze": 6400,
"currency": "PLN",
"pricingSource": "catalog"
}
Pass the guest the trackingUrl and poll the status through GET /api/v1/orders/{id}/status. Cash does not need an online-payments account but needs customer. The same Idempotency-Key returns the same order; a retried placed cash order answers with that order before any condition is checked.
Refusals: ordering module off → 403 module_disabled. Unknown or unavailable item → 422 unprocessable (errors[].code: product_unknown, product_unavailable). Unknown option, missing required group or too many choices → 422 option_invalid. Cart below the shop minimum → 422 below_minimum. An item "Tylko w karcie" → 422 product_menu_only. An item outside its availability hours → 409 product_outside_hours. Delivery to an address out of range → 409 out_of_range. An address that was not found → 422 address_not_found. A temporary lack of a delivery quote → 503 delivery_quote_unavailable. Delivery not set at the venue → 409 delivery_unconfigured. A changed amount → 409 total_changed. Closed hours and no "as soon as possible" → 409 shop_closed. Time outside the list → 409 slot_unavailable. A fulfilment the shop does not offer → 409 fulfilment_unavailable. A payment the shop does not accept → 409 payment_unavailable. Online payment in a shop on sample data while payments are live → 409 sample_mode (a preview pays in test mode only). No shop origin → 409 shop_unconfigured. Online-payments account not ready → 409 connect_not_ready (there is no fake payment).
GET /api/v1/checkout/slots
When the shop will take an order: "as soon as possible" and the times a guest can pick. Scope catalog:read. The fulfilment parameter is delivery (default) or pickup. Times are computed from the shop's opening hours, closed days, prep time, step and horizon in Europe/Warsaw time and returned as ISO instants. Checkout checks the same times a moment later, so a time outside the list ends in 409 slot_unavailable. Never cached. Without hours set the shop takes "as soon as possible" but has no time grid.
GET /api/v1/checkout/slots?fulfilment=pickup
Authorization: Bearer dlt_live_…
{
"asap": { "available": true, "readyInMinutes": 30 },
"slots": [
{ "startsAt": "2026-08-26T08:30:00.000Z" },
{ "startsAt": "2026-08-26T08:45:00.000Z" }
]
}
A bad fulfilment → 400 invalid_request. A shop that does not offer that fulfilment → 409 fulfilment_unavailable. Ordering module off → 403 module_disabled.
GET /api/v1/checkout/result
The verified outcome of one online payment, for the shop's confirmation page. Scope checkout:create. Pass the session_id from the return address (cs_test_… or cs_live_…; the payment adds it to the success address). The answer comes from the order and payment in Delieta, not from the browser: paid, processing (the money is on its way or the order is still being saved; ask again in a few seconds), unpaid (the guest left or the session expired) or unknown (this is not a checkout of this shop). statementDescriptor is the text the customer will see on the statement; it is null when it is not a card payment. method is blik, card, p24, cash, other or null. A missing or bad session_id → 400 invalid_request. Never cached.
GET /api/v1/checkout/result?session_id=cs_live_…
Authorization: Bearer dlt_live_…
{
"status": "paid",
"orderShortId": "K7M2QX",
"trackingUrl": "https://bella.delieta.pl/o/K7M2QX",
"totalGrosze": 5400,
"merchantName": "Bella Pizza",
"statementDescriptor": "BELLA* 3F9A12CD",
"method": "blik",
"livemode": true
}
POST /api/v1/checkout/quote
A cart quote without creating an order: dishes, packaging, delivery and the total. Scope checkout:create (publishable key). Body: { items, fulfilment?, address? }, as in POST /checkout, without guest and payment; money fields are ignored. Limit: 60 quotes a minute per visitor (rate_limited), separate from menu reads: every new address is a paid geocode. The shop does not compute fees itself, it shows what the quote returns.
Answer 200:
{
"quote": {
"currency": "PLN",
"itemsGrosze": 4500,
"packagingGrosze": 200,
"packaging": [{ "kind": "item", "label": "Opakowanie", "quantity": 2, "unitGrosze": 100, "totalGrosze": 200 }],
"delivery": { "status": "priced", "feeGrosze": 500, "free": false, "freeFromGrosze": 9000, "missingForFreeGrosze": 4500, "distanceMeters": 3200 },
"totalGrosze": 5200,
"minimum": { "minimumOrderGrosze": 3000, "missingGrosze": 0 }
}
}
delivery.status is none (pickup), needs_address (km bands and no address yet) or priced. With priced come feeGrosze and, when the price depends on distance, distanceMeters. The fields fromGrosze and toGrosze describe the range of the fee when the price depends on distance. The field packaging[].kind is item (fee per piece) or order (fee per order). freeFromGrosze and missingForFreeGrosze serve the hint "… left to free delivery". totalGrosze is null while an address is needed.
Below the minimum the quote is not an error: the gap shows in minimum.missingGrosze. The error appears only in POST /checkout as below_minimum. Distance is a straight line from the venue, rounded up to 100 m; free delivery and the minimum count the dishes only, without packaging or delivery (exactly the threshold is free). Quote refusals: out_of_range, address_not_found, delivery_quote_unavailable (pickup still quotes), delivery_unconfigured, fulfilment_unavailable. The quote does not check "Menu only" or availability hours (product_menu_only, product_outside_hours): checkout does, at the fulfilment time. The codes are in the error table. Pricing rules: Shop.
POST /api/v1/quotes
An estimate of the trip before an order exists. Scope eta:quote. The start is the company's venue; you give only the destination. The result is always a range (minMinutes / maxMinutes). The guaranteed field is the constant false — this is not an SLA. After expiresAt ask again.
{ "destination": { "lat": 53.123456, "lng": 18.012345 } }
{
"quote": {
"currency": "PLN",
"minMinutes": 35,
"maxMinutes": 50,
"guaranteed": false,
"breakdown": {
"prepMinutes": 15,
"loadMinutes": 4,
"travelMinutes": 12,
"padMinutes": 8
},
"distanceMeters": 4200,
"computedAt": "2026-08-26T12:00:00.000Z",
"expiresAt": "2026-08-26T12:05:00.000Z"
}
}
Show both ends of the range. A single number in the partner's UI is misleading. No venue coordinates → 409 venue_not_configured. Destination outside the company's delivery radius → 409 out_of_range. Bad body → 400 invalid_request. The company the key belonged to does not exist → 404 tenant_not_found.
POST /api/v1/orders
Creates an order for the company the key belongs to. Scope orders:write. The server sums the lines and discards a total you pass. Unit prices come from the caller: pricingSource is "caller". This is an order from a partner channel, not a shop cart; a cart priced from the catalogue is POST /api/v1/checkout.
Idempotency-Key
A required header, 8–255 characters after trimming. One key per logical attempt. Retry with the same key and the same body after a network error, 500 create_failed or 503 idempotency_unavailable. A successful 201 response is stored for 24 hours: a repeat returns the same response and Idempotent-Replayed: true. The same key with a different body → 409 idempotency_key_conflict. When the idempotency store is unavailable you get 503, never a duplicate.
Body
There is no company field. A typical call with the caller's prices:
{
"customerName": "Jan Kowalski",
"customerPhone": "+48123456789",
"destinationAddress": "ul. Przykładowa 1, 85-000 Bydgoszcz",
"destinationPoint": { "lat": 53.123456, "lng": 18.012345 },
"items": [
{ "name": "Margherita 32cm", "qty": 1, "unitPrice": 3200 },
{ "name": "Cola 0,5l", "qty": 2, "unitPrice": 800 }
],
"paymentMethod": "cash",
"channel": "web",
"externalRef": "your-storefront-order-42"
}
| Field | Required | Notes |
|---|---|---|
customerName | yes | 1–200 characters |
customerPhone | yes | 1–40 characters |
destinationAddress | yes | 1–500 characters |
destinationPoint | no | { lat, lng } WGS84. Not destination or destinationCoordinates — that is 400 invalid_request with errors[].code unknown_field, because an order without coordinates gets no ETA |
items | yes | 1–50 lines; qty 1–99 |
items[].name / items[].unitPrice | yes | grosze; the server recomputes totalGrosze |
items[].menuItemId | no | product id; options is ignored when pricing |
details | no | up to 4000 characters |
customerNote | no | up to 2000 characters |
paymentMethod | no | cash | card | online (default cash) |
channel | no | phone | web | pos | integration (default web) |
scheduledFor | no | a time as an ISO date |
sourceVariant | no | up to 64 characters |
externalRef | no | up to 64 characters; dedup on your side, a collision → duplicate_external_ref |
POST /api/v1/orders
Authorization: Bearer dlt_live_…
Content-Type: application/json
Idempotency-Key: ord-2026-08-26-storefront-42
201 response (order)
{
"order": {
"id": "1",
"shortId": "a1b2c3d4",
"status": "accepted",
"createdAt": "2026-08-26T12:00:00.000Z",
"tenantSlug": "bella-pizza",
"externalRef": "your-storefront-order-42",
"currency": "PLN",
"totalGrosze": 4800,
"lines": [
{ "name": "Margherita 32cm", "qty": 1, "unitPrice": 3200 },
{ "name": "Cola 0,5l", "qty": 2, "unitPrice": 800 }
],
"pricingSource": "caller",
"trackingUrl": "https://delieta.pl/o/a1b2c3d4"
}
}
status is accepted or pending_acceptance, depending on the company's acceptance mode. You pass the trackingUrl to the customer. It is the only public tracking address. Write errors: 404 tenant_not_found, 409 duplicate_external_ref, 500 create_failed.
GET /api/v1/orders/
Reads one order of the company the key belongs to. The scope is the same: orders:write — the scope set is closed and already issued on working keys; a new orders:read would break existing integrations. Polling counts towards the read limit, not the create one.
id is the order.id from the 201 response. A foreign or nonexistent id → 404 not_found, so the response does not reveal whether such an order exists. The response has fewer fields than the one on creation. The partner gets the order's progress, not internal columns:
{
"order": {
"id": "1",
"shortId": "a1b2c3d4",
"status": "pending_acceptance",
"externalRef": "your-storefront-order-42",
"createdAt": "2026-08-26T12:00:00.000Z",
"promisedAt": null,
"acceptedAt": null,
"rejectedAt": null,
"rejectionReason": null,
"acceptanceDeadlineAt": 1756200000000
}
}
acceptedAt, rejectedAt and acceptanceDeadlineAt are unix ms or null. The statuses you will see are the order's successive statuses: pending_acceptance, accepted, preparing, ready, on_the_way, delivered and rejected. Delivery skips ready, pickup skips on_the_way.
GET /api/v1/orders//status
Where the order is, for the guest's tracking page. Scope catalog:read, so a publishable key can poll it from a shop. {id} is the short order code (orderShortId from the checkout response, case does not matter), not order.id. The response contains no customer data: only status, fulfilment and time. A nonexistent, misspelled or foreign code answers the same: 404 not_found.
GET /api/v1/orders/a1b2c3d4/status
Authorization: Bearer dlt_live_…
{
"status": "preparing",
"fulfilment": "pickup",
"etaMinutes": 12,
"promisedAt": "2026-08-26T08:45:00.000Z"
}
status is one of: pending_acceptance, accepted, preparing, ready (pickup: "Ready for pickup"), on_the_way, delivered, rejected. etaMinutes is whole minutes to promisedAt, never negative, and appears only while the kitchen has the order (accepted, preparing, on_the_way). promisedAt is the time the staff promised, and without it the time the guest asked for. readyAt appears when a pickup order is ready.
Table reservations
They need the reservations module on (plan or grant, and the owner's switch). Module off: 403 module_disabled.
GET /api/v1/floor-plan
GET /api/v1/floor-plan
The company's zones and tables with geometry (grid is a unitless { width, height } layout that the shop scales to its drawing). Scope catalog:read. ?locale= translates zone names. The response includes x-delieta-cache-tag.
GET /api/v1/reservations/availability
GET /api/v1/reservations/availability?date=2030-06-14&partySize=2
Free start times and, for each, the free tables that fit the party: date, partySize, durationMinutes and slots[] with startsAt, time and tableIds. time is local Europe/Warsaw time, startsAt is the instant to book. When there are no times, the closedReason field says why: closed_day, closure or hours_not_configured. A bad date format or partySize outside 1–100: 422 validation_failed. Scope catalog:read. Never cached.
POST /api/v1/reservations
POST /api/v1/reservations
Authorization: Bearer dlt_live_…
Idempotency-Key: 4b0b3f0e-6d21-4f0a-9c1c-0d6f0e0a7a11
Content-Type: application/json
{ "startsAt": "2030-06-14T16:00:00.000Z", "partySize": 2, "tableId": "…",
"guest": { "name": "Ala Kowalska", "phone": "600100200", "email": "ala@example.pl" },
"occasion": "birthday", "note": "przy oknie" }
Fields: startsAt (an ISO date with offset), partySize (1–100), tableId?, guest (name up to 80 characters, phone, email?), occasion? (none, birthday, anniversary, date, business), note? (up to 500 characters), locale? (pl, en, de, uk — the confirmation to the guest goes out in that language). A body over 8 KiB → 422 validation_failed.
Two key kinds. A publishable key with checkout:create makes a web booking: the start must be an offered time, and the status is requested, or confirmed with automatic confirmation. A secret key with reservations:write makes an api booking from your own system: at any hour, the table just has to be free, and the status is always confirmed. Idempotency-Key (8–100 characters) is optional but honoured: the same key returns the first booking. The website field is a bot trap, leave it empty.
201 returns id, reference, status, startsAt, durationMinutes, tableIds. status is requested, confirmed, seated, completed, cancelled or no_show. Refusals are problem+json with a stable code:
| Status | code |
|---|---|
409 | slot_unavailable, table_taken |
422 | validation_failed, party_too_large, out_of_range |
403 | module_disabled, scope_required |
GET /api/v1/events
The company's events: workshops, tastings, evenings. Scope catalog:read. Needs the Events module: off → 403 module_disabled, unreadable → 503 module_unavailable. Optional ?locale=.
Answer 200: { "events": [...], "locale": "pl", "generatedAt": "..." }. The list holds only published events that have an upcoming date, in sort order, then by the nearest date. Each event:
| Field | Meaning |
|---|---|
id, slug, name, description, audience, scheduleNote, imageUrl | the event description; description is plain text, lines with - are bullet points |
priceMode, pricePerPersonGrosze | per_person, free or on_request; the price only with per_person |
participants | { min, max } per inquiry |
scheduleMode, occurrences | dated or on_request ("termin do uzgodnienia", empty occurrences); dates { id, date, time, endTime, note } |
booking, phone | inquiry or phone; the event phone or the company phone. With phone, show the number: the API refuses sign-ups (event_phone_only) |
The answer includes the x-delieta-cache-tag header (tag tenant:{id}:events, refreshed every 5 minutes), and the time filter runs after the cache, so past dates vanish without waiting.
POST /api/v1/inquiries
A guest inquiry: contact, catering or an event. Scope checkout:create (publishable key), so call it from the shop's server, not from the browser. The limit is per visitor (pass their address in x-delieta-visitor). The website field is a bot trap: leave it empty. The required module depends on the kind: catering and contact need the catering module, event the Events module. When the required module is off, the answer is 403 module_disabled. A body over 64 KiB → 413 payload_too_large. An unknown field → 400 invalid_request.
Fields: kind (contact, catering or event), name, phone and/or email (one of them is required, phone for catering), message (required for contact), topic? (reservation, catering, order, other), locale? and, for catering, a catering object: formatId?, headcount, eventDate (YYYY-MM-DD), eventTime? (HH:MM), deliveryAddress?, dietary? (vegetarian, gluten_free, lactose_free), dietaryNote?, company?, nip?, venue? (onsite or delivery) and addOns? ([{ key, quantity }], up to 30 items, quantity 1–99, each key once). The body never contains the price or the name of an add-on: they come from the format. Add-ons may be sent only with formatId.
For event the fields are an event object: eventId, occurrenceId? (a date from GET /events), preferredDate? and preferredTime? (for an event "Do uzgodnienia"; preferredDate is then required), participants and organization?. phone is required, message optional. The body is strict: an unknown field is 400.
POST /api/v1/inquiries
Authorization: Bearer dlt_live_…
Content-Type: application/json
{
"kind": "catering",
"name": "Anna Nowak",
"phone": "+48600100200",
"catering": { "formatId": "f1", "headcount": 40, "eventDate": "2026-12-12", "company": "Acme", "nip": "5260250995" }
}
Success: 201 { "inquiry": { "reference": "AB12CD", "status": "new" } }. A bad NIP (checksum), a missing phone or an unknown field → 400 invalid_request with an errors list. An unknown format → 422 unprocessable. An on-site inquiry for a day the venue has marked closed → 422 venue_closed („W tym dniu lokal jest zamknięty.”).
Event sign-ups (kind: "event")
A sign-up for one of the venue's events (Wydarzenia module) is an inquiry too: kind: "event" with an event block. A phone is required. A dated event takes occurrenceId (one of the dates from GET /api/v1/events); an event with "termin do uzgodnienia" takes preferredDate (and optionally preferredTime). When the company has the Wydarzenia module off, the answer is 403 module_disabled. Refusals are 422 unprocessable with the code in errors[].code and a Polish detail on the field: unknown_event, event_not_bookable, event_past, event_phone_only, unknown_occurrence, event_in_past, preferred_date_required, participants_out_of_range.
POST /api/v1/catering-orders
The company records a catering job from its own system. Scope catering:write (secret key only). Delieta records the sale: the body carries the agreed amount in grosze (agreedAmountGrosze); payment is optional (paid: true marks it paid). The lines (lines) point at the company's products by menuItemId; a foreign or unknown id → 422 unprocessable. The job has source: api. Optional Idempotency-Key: a retry returns the first job (200, Idempotent-Replayed: true). Module off → 403 module_disabled. An inquiry link that is taken → 409 conflict. A body over 64 KiB → 413 payload_too_large.
Fields: customer (name, phone?, email?, company?, nip?), eventDate (YYYY-MM-DD), eventTime?, headcount?, deliveryAddress?, formatId?, lines[] (menuItemId, quantity, unitPriceGrosze?), agreedAmountGrosze, note?, status? (planned, confirmed, delivered, cancelled), paid?, inquiryId?.
{
"customer": { "name": "Acme sp. z o.o.", "nip": "5260250995" },
"eventDate": "2026-12-12",
"headcount": 40,
"lines": [{ "menuItemId": "i1", "quantity": 40 }],
"agreedAmountGrosze": 240000
}
Success: 201 { "order": { "id", "reference", "status": "planned", "source": "api", "paymentStatus": "unpaid", … } }. paymentStatus is unpaid or paid.
GET /api/v1/catering-orders
GET /api/v1/catering-orders?page=1&limit=50 returns the list { orders, page, limit, total }, newest first (limit up to 100). The same scope, catering:write.
The company's catering formats are in the shop configuration response, only when the module is on: cateringFormats (delivery formats priced „from”) and cateringPackages (every format with its package fields). Each format has imageUrl (a photo URL or null).
OpenAPI
GET /api/v1/openapi
The public OpenAPI 3.1 document, no key. Generate a client from it when you do not want hand-written curl. This page is kept in line with it — when they differ, OpenAPI and the test in the repository are right.
There is no webhook operation from Delieta for order status. You read the status here, with a GET. The only communication the other way (from Delieta to the shop) is described in Webhooks.

