docs/API.md and docs/openapi.yaml still described every cart/orders/ checkout/me route as requiring @require_telegram_auth with a possible 401 - no longer accurate now that those routes use @require_customer_auth (falls back to a cookie-bound guest instead of rejecting the request, see the guest-ordering feature). Documents the new decorator, the guest identity scheme (GuestIdSequence/allocate_guest_telegram_id), the new unauthenticated GET /checkout/track/<token> endpoint, and the checkout response's new card_unavailable_message/track_url fields. Also adds the previously-undocumented /me/locale path to openapi.yaml and updates README's architecture notes accordingly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
48 KiB
Bober BBQ — /api/* Reference
This documents every route registered under the Flask blueprint api_bp
(bober_bbq/api/__init__.py, url_prefix="/api"), consumed by the React/Vite
Mini App in webapp/. It was produced by reading every route handler in
bober_bbq/api/*.py in full, plus the models, services and utility modules
each handler calls into. Every field, status code, and error body below is
traceable to a specific line of source; anywhere the code's behavior was not
100% certain, that's called out explicitly as a Note rather than guessed.
Blueprint composition (bober_bbq/api/__init__.py):
| Sub-blueprint | url_prefix |
Final path prefix |
|---|---|---|
menu_bp |
(none) | /api |
cart_bp |
/cart |
/api/cart |
settings_bp |
/settings |
/api/settings |
payments_bp |
/payments |
/api/payments |
orders_bp |
/orders |
/api/orders |
checkout_bp |
/checkout |
/api/checkout |
me_bp |
/me |
/api/me |
All request/response bodies are JSON. There is no flask_cors/CORS(...)
usage anywhere in bober_bbq/ (confirmed by search) and no custom
@app.errorhandler for 404/405/500 was found in bober_bbq/app.py — those
fall through to Flask's default (non-JSON) error pages. MAX_CONTENT_LENGTH
is 8 MB (bober_bbq/config.py).
Authentication
Source: bober_bbq/api/auth.py, bober_bbq/utils/telegram_auth.py.
There are two decorators. menu, settings, and the Monobank webhook use
neither (no auth at all). Every other route in this document —
cart_bp, orders_bp, checkout_bp, me_bp — uses @require_customer_auth,
which wraps the Telegram-validation logic below with a cookie-based guest
fallback and never returns 401.
Telegram validation (shared core logic)
- Reads the raw Telegram Mini App
initDatastring from theX-Telegram-Init-Datarequest header (not a query param, not the body). - Validates it via
validate_init_data(), which implements Telegram's official Mini App validation algorithm (docs):- Parses the string as a query string (
parse_qsl(..., strict_parsing=True)); a malformed string fails closed (returnsNone). - Pops out the
hashfield; if absent, fails. - Rebuilds the
data_check_stringby joining all remainingkey=valuepairs sorted by key with\n. - Computes
secret_key = HMAC_SHA256(key=b"WebAppData", msg=bot_token), thencomputed_hash = HMAC_SHA256(key=secret_key, msg=data_check_string), and compares it to the receivedhashwithhmac.compare_digest(constant-time). - Rejects (
None) ifauth_dateis more than 24 hours old (MAX_AGE_SECONDS = 24 * 60 * 60). - Parses the
userfield's JSON string into a dict. bot_tokencomes fromconfig.BOT_TOKEN; if it's unset, validation always fails.
- Parses the string as a query string (
- Dev-mode bypass: if validation failed (
data is None) andcurrent_app.debugisTrue(i.e. Flask debug mode is on), it reads a?dev_user_id=<int>query parameter. If present, it fabricates{"user": {"id": <int>, "first_name": "Dev"}}and treats it as authenticated — completely skipping signature verification. This bypass is unavailable whendebugis off (e.g. production), and does nothing ifdev_user_idisn't supplied. - On success, the Telegram user is upserted into
TelegramUser(_upsert_user): looked up bytelegram_id, created if missing (seedinglocalefrom the token payload'slanguage_code, normalized touk/en— never touched again automatically after that, seedocs/I18N.md), andusername/first_nameare overwritten from the token payload on every request. This runs (and commits) on every authenticated call, even read-only ones likeGET /api/cart. - The resulting
TelegramUserrow is stashed ong.telegram_userfor the view function to use.
A failed validation attempt (but not a dev-mode bypass, and not one that
then falls through to the guest path below) is logged at WARNING with the
request path, initData length, a 120-char preview of it, and the
User-Agent header — useful for diagnosing integration issues.
require_telegram_auth (Telegram-only, currently unused by any route)
The original decorator: runs the Telegram validation above and, if it
fails (no valid data/data["user"]), rejects the request outright:
401 Unauthorized
{"error": "invalid or missing Telegram init data"}
Still defined in auth.py and still exercised indirectly (its logic is
reused by require_customer_auth below), but as of the guest-ordering
feature no route is wrapped in it directly anymore — every former caller
switched to require_customer_auth.
require_customer_auth (Telegram, falling back to a guest)
Added so someone with no Telegram account at all can still use the
Mini App's cart/checkout/order-history/locale endpoints if they open the
plain webapp URL directly (outside Telegram) — the same webapp/dist
bundle, no separate app. It:
- Runs the same Telegram validation as above (including the dev-mode bypass).
- If that succeeds, behaves identically to
require_telegram_auth(g.telegram_user= the realTelegramUser). - If it fails, looks up a guest identity from the Flask session
cookie (
session["guest_telegram_id"]). If the cookie has none yet, it mints a fresh one viaallocate_guest_telegram_id()(bober_bbq/models.py): a smallGuestIdSequencetable exists purely to hand out a collision-proof integer per guest, turned intotelegram_id = -(1_000_000 + sequence_id)— i.e. always negative, and always pastGUEST_ID_BASE = 1_000_000so it's visually distinct in logs/DB browsing from the pre-existing walk-in sentinel user,WALKIN_TELEGRAM_ID = -1(used for orders created manually from the admin panel). ATelegramUser(telegram_id=<that>, first_name="Гість")row is created and the id is saved back into the session cookie (session.permanent = True, ~31 days) so the same guest is recognized on their next request/page reload. - Never returns 401 — by the time the view function runs,
g.telegram_useris always set, to either a real Telegram user or a guest.
Consequences for every route below that lists @require_customer_auth:
there is no 401 error case to document per-route (removed from the
tables below accordingly), and a guest's cart/orders are scoped to their
own TelegramUser.telegram_id exactly the same way a real Telegram
user's are — nothing else in cart.py/checkout.py/orders.py needed to
change to support guests, since none of it assumed telegram_id > 0.
Note: there is no CSRF token, API key, or rate limiting on top of any of this — the Telegram init-data header (or, for a guest, the session cookie Flask itself signs) is the sole credential for every protected route.
Menu (menu_bp, no extra prefix → /api/...)
Source: bober_bbq/api/menu.py. No auth required on either route.
GET /api/categories
Lists active menu categories, ordered by sort_order.
- Auth: none.
- Query params:
locale(optional,"uk"|"en", defaults to"uk"for anything missing/unrecognized — seenormalize_locale()inbober_bbq/utils/i18n.py). No session exists on this unauthenticated route to read a stored preference from, so the caller must pass it explicitly to get English names. - Request body: none.
- Success —
200 OK, a JSON array (not wrapped in an object):
Fields per[ { "id": 1, "name": "Бургери", "slug": "burgers", "sort_order": 0 } ]Category.to_dict(locale):id(int),name(str —name_enwhenlocale=enand it's been filled in for that category, otherwise the Ukrainianname),slug(str, always the original slug regardless of locale),sort_order(int). Only rows withis_active = trueare returned. - Errors: none defined — DB errors would surface as an unhandled Flask 500 (no custom error body).
GET /api/products
Lists active products, ordered by sort_order.
- Auth: none.
- Query params:
locale(optional, same as/api/categoriesabove). - Request body: none.
- Success —
200 OK, JSON array:
Fields per[ { "id": 10, "category_id": 1, "name": "Чізбургер", "description": "Соковита котлета, сир чеддер, соус.", "weight": "250 г", "price": 149, "image_url": "/static/products/10.jpg", "allergens": [ { "code": "gluten", "label": "Глютен (зернові)", "emoji": "🌾" }, { "code": "milk", "label": "Молоко", "emoji": "🥛" } ] } ]Product.to_dict(locale):id,category_id(int),name(str —name_enwhenlocale=enand set, else Ukrainianname),description(str — same fallback viadescription_en),weight(str, e.g."300 г"—weight_enwhenlocale=enand set, else Ukrainianweight),price(int, whole UAH — not cents),image_url(str, may be a relative/static/...path),allergens(array of{code, label, emoji}—labelis one of the 14 EU-mandated allergen names, translated perlocalefrom a fixed dict inbober_bbq/models.py, not from a database column; unknown codes silently dropped). Onlyis_active = trueproducts are returned; there's no per-category filter query param — the client filters client-side bycategory_id. - Errors: none defined; unhandled DB errors → default Flask 500.
Settings (settings_bp, prefix /api/settings)
Source: bober_bbq/api/settings.py. No auth required.
GET /api/settings
Public cafe configuration used to render the Mini App shell (name, hours, theme, delivery fee, etc.) before the user is even authenticated.
- Auth: none.
- Request body: none.
- Success —
200 OK:
All values come straight from the{ "cafe_name": "Bober BBQ", "logo_url": "", "phone": "+380 XX XXX XX XX", "address": "м. Київ, вул. Прикладна, 1", "work_hours_from": "11:00", "work_hours_to": "21:00", "is_open": true, "instagram_url": null, "maps_url": null, "delivery_fee": 60, "free_delivery_threshold": 500, "min_delivery_order_amount": 0, "pickup_discount_percent": 15, "force_closed": false, "force_closed_message": "", "announcement_enabled": false, "announcement_message": "", "theme": { "accent": "#ff7a3d", "accent_2": "#ffa374", "accent_grad": "linear-gradient(135deg, #ffa374 0%, #ff7a3d 55%, #e06c36 100%)", "accent_ink": "#2a0f00" }, "has_active_promos": false }Settingkey/value table viaSetting.get/get_int/get_bool, except:is_open: computed byworkhours.is_open()fromwork_hours_from/work_hours_toin the configured timezone (config.TIMEZONE), including overnight-schedule handling (e.g.20:00–02:00).force_closed:Setting.get_bool("force_closed", False)— an admin kill switch independent of working hours.theme: derived from the single hex color inwebapp_theme_accent(default#ff7a3d) viaderive_palette(), which lightens/darkens it and picks a readable ink color based on perceived luminance.has_active_promos:Trueif at least onePromoCodeis active, not expired, and not pastmax_uses— ignores per-user restrictions likefirst_order_only/min_order_amount(those need cart/user context), so it can betrueeven for a code a specific user couldn't actually use.
- Errors: none defined; unhandled DB errors → default Flask 500.
Cart (cart_bp, prefix /api/cart)
Source: bober_bbq/api/cart.py. All routes require @require_customer_auth
(see Authentication above) — a caller with no valid Telegram init data still
succeeds, as a cookie-bound guest, so none of these routes ever return 401.
The cart is keyed by CartItem.user_id = g.telegram_user.telegram_id — one
shared cart per Telegram user or guest (also used by the Telegram bot itself,
per the module's own comment on CartItem).
Every route below returns the same cart envelope, built by _serialize_cart():
{
"items": [
{
"product_id": 10,
"name": "Чізбургер",
"weight": "250 г",
"image_url": "/static/products/10.jpg",
"price": 149,
"quantity": 2,
"line_total": 298
}
],
"items_total": 298,
"count": 2
}
items_total = sum of line_total across items (int, UAH). count = sum of
quantity across items (int; NOT number of distinct line items).
GET /api/cart
- Auth: required.
- Request body: none.
- Success:
200 OK, cart envelope above (empty cart →{"items": [], "items_total": 0, "count": 0}). - Errors: none (see
require_customer_authabove).
POST /api/cart/items
Adds a product to the cart, or increments its quantity if already present.
-
Auth: required.
-
Request body (JSON):
Field Type Required Notes product_idint yes (effectively) No product matches None/missing →404.quantityint no, default 1Coerced with int(...); a non-numeric value raises an unhandledValueError→ default Flask 500 (see Note below).Example:
{ "product_id": 10, "quantity": 2 } -
Success —
201 Created, the updated cart envelope (see above). If the product is already in the cart,quantityis added to the existing line's quantity (not replaced). -
Errors:
Status Body Trigger 404 {"error": "product not found"}no active Productwith thatid(Product.query.filter_by(id=product_id, is_active=True))400 {"error": "quantity must be >= 1"}quantity < 1Note: the product-existence check runs before the quantity check, so a nonexistent
product_idwithquantity: 0returns 404, not 400. Also,request.get_json(silent=True) or {}means a non-JSON or empty body is treated as{}rather than erroring, soproduct_iddefaults toNoneand the request always 404s in that case (no distinct "missing product_id" error). Note:int(body.get("quantity", 1))will raiseValueError/TypeError(uncaught → 500) ifquantityis present but not int-coercible (e.g."abc"); this is not guarded against, unlike the delivery checkout'scash_change_forparsing which does catch it.
PATCH /api/cart/items/<int:product_id>
Sets an existing cart line's quantity (or removes it if the new quantity is <= 0).
-
Auth: required.
-
Path param:
product_id(int). -
Request body:
Field Type Required Notes quantityint no, default 1Same int(...)coercion caveat as above.Example:
{ "quantity": 3 } -
Success —
200 OK, updated cart envelope. Ifquantity <= 0, the line item is deleted entirely rather than set to zero. -
Errors:
Status Body Trigger 404 {"error": "item not in cart"}no CartItemfor this user +product_id
DELETE /api/cart/items/<int:product_id>
Removes one line item if present; idempotent if not.
- Auth: required.
- Path param:
product_id(int). - Request body: none.
- Success —
200 OK, updated cart envelope, even if the item wasn't in the cart (no 404 in that case — this route silently no-ops). - Errors: none.
DELETE /api/cart
Empties the entire cart.
- Auth: required.
- Request body: none.
- Success —
200 OK,{"items": [], "items_total": 0, "count": 0}. - Errors: none.
Orders (orders_bp, prefix /api/orders)
Source: bober_bbq/api/orders.py. All routes require @require_customer_auth
(see Authentication above — never returns 401) and are scoped to the caller's own orders (Order.query.filter_by(..., user_id=g.telegram_user.telegram_id)) — there is no way for a user to view or act on another user's order via this blueprint.
Order object shape (_serialize_order), used by every route in this section:
{
"id": 42,
"number": "№1042",
"order_type": "delivery",
"status": "cooking",
"status_label": "Готується",
"payment_method": "cash",
"payment_status": "unpaid",
"items_total": 298,
"delivery_fee": 60,
"discount_amount": 0,
"promo_code": null,
"promo_discount_amount": 0,
"total": 358,
"created_at": "2026-08-27T10:15:00",
"pickup_time": null,
"address_street": "вул. Прикладна",
"address_house": "1",
"table_number": null,
"can_cancel": false,
"can_pay": false,
"items": [
{ "name": "Чізбургер", "quantity": 2, "price": 149 }
]
}
Notes on fields:
number:order.display_number()—"№{number}"if a sequence number has been assigned, else"#{id}"as a fallback. It's a string, not the raw int.status: one ofnew | confirmed | cooking | ready | courier | completed | cancelled(ORDER_STATUSESinmodels.py);status_labelis translated per the order's own customer'sTelegramUser.locale(viat("order.status_label.<status>", locale)inbober_bbq/utils/i18n.py— a separate translation namespace from the Ukrainian-onlyORDER_STATUS_LABELSthe admin panel uses; falls back to Ukrainian, then to the raw key, if somehow unrecognized).can_cancel:trueonly whilestatusisneworconfirmed(CANCELLABLE_STATUSES).can_pay:trueonly ifpayment_method == "card",payment_status == "unpaid", andstatusnot in("cancelled", "completed").items: a snapshot taken at order-creation time (OrderItem.product_name/product_price), so it reflects prices/names as they were when ordered, not current catalog values; note there's noproduct_idorline_totalfield in this list (unlike the cart envelope).- Delivery-only fields (
address_street,address_house) are always present in the JSON butnullfor pickup/dine-in orders; likewisepickup_timeis only non-null for pickup orders,table_numberonly for dine-in. (address_apartment/address_comment/address_cityare not included in this serialization at all, even though they exist on the model.)
GET /api/orders
Lists the caller's most recent orders.
- Auth: required.
- Request body: none.
- Success —
200 OK, JSON array of up to 50 orders (ORDER BY created_at DESC LIMIT 50), each shaped as above. No pagination beyond this fixed limit. - Errors: none.
POST /api/orders/<int:order_id>/cancel
Customer self-service cancellation.
- Auth: required.
- Path param:
order_id(int). - Request body: none.
- Success —
200 OK, the updated order object (status: "cancelled"). - Side effects: sets
cancel_reason = "Скасовано клієнтом"; callsrestore_for_order(order)(restores deducted inventory, perbober_bbq/utils/inventory.py); callsnotify_manager_order_cancelled(order)which notifies staff. - Errors:
Status Body Trigger 404 {"error": "not_found"}no such order for this user 400 {"error": "not_cancellable", "message": "..."}statusnot in{"new", "confirmed"}—messagetext is translated per the order's own customer'sTelegramUser.locale(seedocs/I18N.md), e.g."This order can no longer be cancelled on your own — it's already being prepared. Please contact a manager."in English
POST /api/orders/<int:order_id>/repeat
Re-adds a past order's items to the current cart (a "reorder" convenience feature).
-
Auth: required.
-
Path param:
order_id(int). -
Request body: none.
-
Success —
200 OK:{ "cart": { "items": [...], "items_total": 0, "count": 0 }, "skipped": ["Старий бургер"] }cartis the same envelope as the cart endpoints.skippedis an array of product names (strings) for order-items whoseproduct_idno longer resolves to an activeProduct(deleted, deactivated, or the historic item had noproduct_idat all) — those are silently omitted from the cart instead of erroring. -
Errors:
Status Body Trigger 404 {"error": "not_found"}no such order for this user Note: this endpoint has no cart-empty or product-active-count validation beyond the per-item skip logic above — it will happily merge quantities into an already-non-empty cart.
POST /api/orders/<int:order_id>/pay
(Re-)generates a Monobank payment link for an existing card order — e.g. if the customer navigated away from the original checkout payment page.
- Auth: required.
- Path param:
order_id(int). - Request body: none.
- Success —
200 OK:{ "pay_url": "https://pay.mbnk.biz/..." }(or a LiqPay checkout URL — whichever provider is currently active, seebober_bbq/payments/registry.py; the response shape is identical either way). - Side effects: calls the active payment provider's invoice-create API (
get_active_provider().create_invoice(order)), then persists that provider's invoice id/page URL on the order (Order.payment_providerrecords which one, so later polling/reconciliation always talks to the same gateway that created the invoice even if the admin switches the active provider afterward). - All
messagetext below is translated per the order's own customer'sTelegramUser.locale(seedocs/I18N.md); Ukrainian shown here for brevity. - Errors:
Status Body Trigger 404 {"error": "not_found"}no such order for this user 400 {"error": "not_payable", "message": "Це замовлення не передбачає оплату карткою"}order.payment_method != "card"400 {"error": "already_paid", "message": "Замовлення вже оплачено"}order.payment_status == "paid"400 {"error": "not_payable", "message": "Це замовлення вже неактивне"}order.status in ("cancelled", "completed")400 {"error": "not_payable", "message": "Оплата карткою тимчасово недоступна"}active provider has no usable credentials ( ProviderNotConfigured)502 {"error": "payment_provider_error", "message": "Не вдалося створити платіж, спробуйте ще раз"}provider API call failed ( ProviderError, e.g. non-200 response) — note this key changed frommonobank_erroronce a second provider (LiqPay) was added
Checkout (checkout_bp, prefix /api/checkout)
Source: bober_bbq/api/checkout.py. All routes except GET /track/<token>
require @require_customer_auth (see Authentication above — never
returns 401; GET /track/<token> has no auth at all, see below). This
blueprint replaced a multi-step bot chat checkout flow — order details
(name/phone/address/payment/pickup time) are now submitted as a single form
from the Mini App, per the module's own docstring.
Shared validation across the three order-creation routes
Every message string below is translated per the caller's own
TelegramUser.locale (via t() in bober_bbq/utils/i18n.py — see
docs/I18N.md); Ukrainian is shown here for brevity, but an English-locale
customer gets the English variant of the same text.
Before any field-level validation, POST /delivery, POST /pickup, and
POST /dine-in all call _validate_cart(), which:
- Loads the caller's cart via
get_cart_items()(same shared cart as/api/cart). If empty:400 { "error": "cart_empty", "message": "Кошик порожній" } - Checks
orders_allowed()(working hours and theforce_closedadmin kill switch, or theallow_orders_outside_hourssetting). If orders aren't currently allowed:400 { "error": "closed", "message": "<closed_message(locale)>" }closed_message(locale)returns the admin's customforce_closed_messageverbatim if force-closed (business content, always shown as the admin typed it, regardless of locale), else a translated message stating the cafe name and working hours.
All three also validate name/phone via _contact_field_error():
name: must be at least 2 characters (after.strip()), else"Вкажіть ім'я (мінімум 2 символи)".phone: after stripping all non-digit characters, must have between 9 and 13 digits inclusive, else"Перевірте номер телефону — здається, він неповний".
Both return as:
400 { "error": "validation", "message": "<one of the two messages above>" }
All three also share promo-code resolution via _resolve_promo(): if
promo_code is present and non-blank in the body, it's validated with
validate_promo_code(code, user_id, items_total, locale); on failure:
400 { "error": "promo_invalid", "message": "<see validate_promo_code error messages, listed under /checkout/promo/validate>" }
If promo_code is omitted/blank, no promo is applied and no error occurs.
All three, on success, call _finalize(order), which:
- Calls
notify_manager_new_order(order)— notifies the staff/manager Telegram chat (always Ukrainian; staff-facing, not translated). If the caller is a guest (user_id <= 0, no real Telegram chat to notify), this and the "thank you" message below are silently skipped rather than attempted — seeallow_negativeinbober_bbq/utils/telegram_notify.py. - Sends the customer a Telegram "thank you" message (translated) via
send_message(real Telegram customers only, per the note above — a guest never gets a Telegram message at all, only the HTTP response itself). - If
payment_method == "card", calls_offer_card_payment(order, locale), which now returns both a pay URL and a possible unavailability message ((pay_url, card_unavailable_message)):- Attempts
get_active_provider().create_invoice(order)— whichever payment provider (Monobank or LiqPay) is currently active, seebober_bbq/payments/registry.py. - If it isn't configured (
ProviderNotConfigured) or the call fails (ProviderError), it does not fail the request —pay_urlin the response isnull, and the same translated "card payment temporarily unavailable, a manager will contact you" text that's sent to a real Telegram customer is also returned in the response ascard_unavailable_message, so a guest (who never gets that Telegram message) still sees it on-screen. - On success, persists that provider's invoice id/page URL on the order (
Order.payment_providerrecords which one), returns the pay URL, andcard_unavailable_messageisnull.
- Attempts
The finalize response shape (used by all three creation routes) is:
{
"order_id": 42,
"order_number": "№1042",
"total": 358,
"payment_method": "cash",
"pay_url": null,
"card_unavailable_message": null,
"track_url": "/webapp/?track=yT9UOofwEmb-dFdD2BVSGA"
}
card_unavailable_message(string, translated, ornull): see point 3 above — only ever non-null whenpayment_method == "card"and the active provider was unreachable/unconfigured.track_url(string, ornullif the order somehow has notracking_token): a same-origin path into the Mini App's own SPA (/webapp/?track=<token>), not an absolute URL — see "Order tracking" below.tokenisOrder.tracking_token, asecrets.token_urlsafe(16)value generated for every order (Telegram customers included, not just guests — seebot/services/order_service.py), so the same link mechanism works regardless of how the order was placed.
POST /api/checkout/delivery
-
Auth: required.
-
Request body (JSON):
Field Type Required Notes namestring yes min 2 chars after strip phonestring yes 9–13 digits after stripping non-digits address_citystring yes address_streetstring yes address_housestring yes address_apartmentstring no blank/absent → nulladdress_commentstring no blank/absent → nullpayment_methodstring yes must be "cash"or"card"cash_change_forint no only meaningful when payment_method == "cash"; must be numeric if provided and non-blankpromo_codestring no see promo resolution above Example:
{ "name": "Олена", "phone": "+380971234567", "address_city": "Київ", "address_street": "вул. Хрещатик", "address_house": "10", "address_apartment": "5", "payment_method": "cash", "cash_change_for": 500 } -
Order-specific validation, in this exact sequence:
_validate_cart()(cart-empty / closed checks, see above).- Minimum order amount: if
Setting.get_int("min_delivery_order_amount") > 0and the cart'sitems_totalis below it:400 { "error": "min_order_amount", "message": "Мінімальна сума замовлення на доставку — {min_amount} грн. Додайте ще товарів у кошик." } - Promo resolution (
_resolve_promo). - Required-field presence check over
["name", "phone", "address_city", "address_street", "address_house", "payment_method"]— any missing/blank (afterstr(...).strip()) field:
Note: this is the one error shape in the whole API that uses400 { "error": "validation", "missing": ["<field1>", "<field2>", ...] }"missing"(a list of field names) instead of"message". payment_methodmust be"cash"or"card":400 { "error": "validation", "message": "invalid payment_method" }- If
payment_method == "cash"andcash_change_foris present/non-blank, it must parse as an int:400 { "error": "validation", "message": "cash_change_for must be a number" } name/phonecontact validation (_contact_field_error, see above).
-
Success —
201 Created, the finalize response shape above. -
Side effects: creates the
Order+OrderItemrows, deducts ingredient inventory (deduct_for_order), clears the cart, increments the promo code'sused_countif one was applied, notifies manager + customer over Telegram, and optionally creates a Monobank invoice (see_finalizeabove). -
Full error table:
Status Body 400 {"error": "cart_empty", "message": "Кошик порожній"}400 {"error": "closed", "message": "<closed_message()>"}400 {"error": "min_order_amount", "message": "Мінімальна сума замовлення на доставку — {N} грн. Додайте ще товарів у кошик."}400 {"error": "promo_invalid", "message": "<see /checkout/promo/validate>"}400 {"error": "validation", "missing": [...]}400 {"error": "validation", "message": "invalid payment_method"}400 {"error": "validation", "message": "cash_change_for must be a number"}400 {"error": "validation", "message": "Вкажіть ім'я (мінімум 2 символи)"}400 {"error": "validation", "message": "Перевірте номер телефону — здається, він неповний"}
POST /api/checkout/pickup
-
Auth: required.
-
Request body (JSON):
Field Type Required Notes namestring yes min 2 chars after strip phonestring yes 9–13 digits after stripping non-digits pickup_timestring yes must match `^([01]?\d payment_methodstring yes must be "cash"or"card"promo_codestring no Example:
{ "name": "Ігор", "phone": "0971234567", "pickup_time": "18:30", "payment_method": "card" } -
Validation order:
_validate_cart()→ required-field presence check (["name", "phone", "pickup_time", "payment_method"], error shape{"error": "validation", "missing": [...]}) →payment_methodin("cash", "card")→ promo resolution → contact-field validation →pickup_timeregex format check:400 { "error": "validation", "message": "Невірний формат часу (використайте ГГ:ХХ)" }Note: unlike
/delivery, the required-field and payment-method checks here run before promo resolution (the delivery route resolves the promo before its required-field check) — the order in which multiple simultaneous errors get reported can therefore differ slightly between the two routes. Also note there is no minimum-order-amount check on pickup (that setting,min_delivery_order_amount, is delivery-only per its name and the code). -
Success —
201 Created, the finalize response shape.pickup_timeis stored as the raw stripped string, with no cross-check against actual working hours (e.g. nothing stops picking"03:00"when the cafe is closed then, beyond the generalorders_allowed()gate at request time). -
Side effects: same category as
/delivery(order/items created, inventory deducted, cart cleared, promo use counted, notifications sent, optional Monobank invoice) but with no delivery fee. -
Full error table:
Status Body 400 {"error": "cart_empty", "message": "Кошик порожній"}400 {"error": "closed", "message": "<closed_message()>"}400 {"error": "validation", "missing": [...]}400 {"error": "validation", "message": "invalid payment_method"}400 {"error": "promo_invalid", "message": "<see /checkout/promo/validate>"}400 {"error": "validation", "message": "Вкажіть ім'я (мінімум 2 символи)"}400 {"error": "validation", "message": "Перевірте номер телефону — здається, він неповний"}400 {"error": "validation", "message": "Невірний формат часу (використайте ГГ:ХХ)"}
POST /api/checkout/dine-in
-
Auth: required.
-
Request body (JSON):
Field Type Required Notes namestring yes min 2 chars after strip phonestring yes 9–13 digits after stripping non-digits table_numberstring no blank/absent → nullpayment_methodstring yes must be "cash"or"card"promo_codestring no Example:
{ "name": "Марія", "phone": "380501112233", "table_number": "7", "payment_method": "cash" } -
Validation order:
_validate_cart()→ required-field presence check (["name", "phone", "payment_method"]) →payment_methodin("cash", "card")→ promo resolution → contact-field validation. No pickup-time or address fields apply. -
Success —
201 Created, the finalize response shape. No delivery fee, no pickup discount (discount_amountis0for dine-in orders perorder_service.create_dine_in_order). -
Side effects: same as above (order created, inventory deducted, cart cleared, promo counted, notifications, optional Monobank invoice).
-
Full error table:
Status Body 400 {"error": "cart_empty", "message": "Кошик порожній"}400 {"error": "closed", "message": "<closed_message()>"}400 {"error": "validation", "missing": [...]}400 {"error": "validation", "message": "invalid payment_method"}400 {"error": "promo_invalid", "message": "<see /checkout/promo/validate>"}400 {"error": "validation", "message": "Вкажіть ім'я (мінімум 2 символи)"}400 {"error": "validation", "message": "Перевірте номер телефону — здається, він неповний"}
POST /api/checkout/promo/validate
Preview-validates a promo code against the caller's current cart, without placing an order. Used by the Mini App to show a live discount preview as the customer types a code.
-
Auth: required.
-
Request body:
{ "code": "<string>" }—coderequired (non-blank after strip). -
Success —
200 OK:{ "valid": true, "code": "WELCOME10", "discount_percent": 10, "discount_amount": 30 }codeis echoed back as stored on thePromoCoderow (its canonical casing, not necessarily what the user typed — lookup is case-insensitive viadb.func.upper(...)).discount_amountis computed viacalc_promo_discount(promo, items_total)=round(items_total * discount_percent / 100), or0ifitems_total <= 0. -
Errors — note this endpoint returns
valid: falsewith a400status (not200) for every rejection reason, all sharing the shape{ "valid": false, "message": "<reason>" }. Everymessageis translated per the caller'sTelegramUser.locale(Ukrainian shown below for brevity):Trigger messagecodeblank/missing"Введіть промокод"no matching code, or matched but is_active = false"Промокод не знайдено або він більше не діє"expires_atin the past"Термін дії промокоду закінчився"used_count >= max_uses"Ліміт використань цього промокоду вичерпано"cart items_totalbelowmin_order_amount"Мінімальна сума замовлення для цього промокоду — {N} грн"first_order_onlyand caller already has a non-cancelled prior order"Цей промокод діє лише на перше замовлення"Note: the checks run in the order listed (not-found/inactive → expired → usage-cap → min-amount → first-order-only), so if a code fails multiple checks at once, only the first one's message is returned.
GET /api/checkout/prefill
Returns the customer's most recent name/phone and latest delivery address, to pre-fill the checkout form for returning customers.
- Auth: required.
- Request body: none.
- Success —
200 OK. If the caller has never placed an order:
Otherwise:{ "has_orders": false, "name": null, "phone": null, "delivery": null }{ "has_orders": true, "name": "Олена", "phone": "+380971234567", "delivery": { "address_city": "Київ", "address_street": "вул. Хрещатик", "address_house": "10", "address_apartment": "5" } }name/phonecome from the single most recent order of any type (ORDER BY created_at DESC LIMIT 1, noorder_typefilter).deliveryis populated from the most recent order specifically oforder_type == "delivery"(a separate query) — it will benullif the customer has orders but none of them were deliveries, even ifhas_ordersistrue. - Errors: none.
GET /api/checkout/payment-status/<int:order_id>
Polling endpoint the Mini App uses (its own "Перевірити оплату" / "Check payment" button) to find out if a card payment has cleared, as an alternative/backstop to the Monobank webhook.
-
Auth: required.
-
Path param:
order_id(int). -
Request body: none.
-
Success —
200 OK, one of:{"paid": true}— already marked paid, or the check with Monobank confirmed a paid status.{"paid": false}— not paid, no Monobank invoice on file yet, or the Monobank status check failed/errored, or Monobank's returned status isn't in the "paid" set.
-
Side effects when payment is newly confirmed: calls
claim_order_paid(order), an atomicUPDATE ... WHERE payment_status != 'paid'used specifically to avoid double-processing when this poll races the webhook (POST /api/payments/monobank/webhook) or the periodic reconciliation job. Only the caller that actually wins the transition (i.e.claim_order_paidreturnsTrue) will: notify the staff chat (💳 Замовлення {number} оплачено онлайн (✅).) and trigger fiscalization (checkbox_prro.fiscalize_in_background(order.id)). It does not re-send the customer-facing "payment received" Telegram message that the webhook path sends (seebober_bbq/api/payments.py) — only the staff notification and fiscalization are shared between the two paths, per the code's own comment. -
Errors:
Status Body Trigger 404 {"error": "not_found"}no such order for this user Note: if the Monobank status check itself throws
MonobankError(e.g. Monobank API down), the response is{"paid": false}with a200— indistinguishable from a genuinely-still-unpaid order. Callers polling this endpoint can't tell "not paid yet" from "we couldn't check right now" from the response alone.
GET /api/checkout/track/<token>
Order tracking. Added alongside guest ordering: a guest has no login and
may lose their session cookie (private browsing, cleared site data), so
every order also gets a tracking_token (see the finalize response shape
above) as a second, independent way to look up its status — deliberately
generated for Telegram orders too, not just guest ones, so the mechanism
and the webapp code path are identical either way.
-
Auth: none — the only
checkout_bproute without@require_customer_auth. The random, 16-byte-entropytokenitself (secrets.token_urlsafe(16), set once at order creation inbot/services/order_service.py, never reused) is the sole credential — the same trust model as a payment provider's "view your receipt" email link. There is no rate limiting on guesses. -
Path param:
token(string) — matched againstOrder.tracking_tokenexactly; not scoped to any particularTelegramUser, unlike every other route in this document. -
Request body: none.
-
Success —
200 OK, the same order object shape documented under the "Orders" section above (_serialize_order, reused directly frombober_bbq/api/orders.py). -
Errors:
Status Body Trigger 404 {"error": "not_found"}no Orderwith thattracking_tokenThe webapp surfaces this as a same-origin SPA deep-link rather than a server-rendered page:
GET /webapp/?track=<token>is handled entirely inwebapp/src/App.tsx(checked before the normal menu/settings load), which calls this endpoint and renders the result via a dedicatedTrackOrderPagecomponent — a read-only view (no cancel/repeat/pay actions), since this route has no concept of "is this caller the order's owner", only "does the token match".
Payments (payments_bp, prefix /api/payments)
Source: bober_bbq/api/payments.py. This is a server-to-server webhook, not
part of the Mini App's own API surface, but it lives under /api/* so it's
documented here for completeness.
POST /api/payments/monobank/webhook
Monobank's server calls this when an invoice's status changes.
-
Auth: not
@require_telegram_auth. Instead it verifies theX-Signheader as an ECDSA/SHA256 signature over the raw request body, using Monobank's own published public key (fetched from Monobank and cached in-process —verify_webhook_signature()inbober_bbq/payments/monobank.py). There is no Telegram user context for this route at all. -
Request body (JSON, as sent by Monobank): consumed fields are
invoiceId(string) andstatus(string); other fields Monobank sends are ignored. -
Success responses:
200 { "ok": true }— for a genuinely-processed status change, or for aninvoiceIdthat doesn't match any known order (logged as a warning server-side, but Monobank still gets acked so it doesn't retry indefinitely).
-
Behavior by
status:- If
statusis inPAID_STATUSES = {"success"}and the order isn't alreadypaid: callsclaim_order_paid(order)(same atomic "first-caller-wins" transition used by/api/checkout/payment-status); if this call wins the race, sends the customer a "payment received" Telegram message, notifies the staff chat, and triggerscheckbox_prro.fiscalize_in_background(order.id). Iforder.user_idisn't a real Telegram chat (<= 0— a guest or the admin-panel walk-in sentinel, see Authentication above), the customer message is silently skipped (send_message'sallow_negativeguard,bober_bbq/utils/telegram_notify.py) — only the staff notification and fiscalization still happen. - If
statusis inFAILED_STATUSES = {"failure", "reversed", "expired"}and the order isn't already paid: sends the customer a "payment failed, try again or pay on delivery/pickup" Telegram message (same guest/walk-in skip as above applies). No staff notification, no state change on the order recorded here beyond the message. - Any other
statusvalue: no action taken (still acked200).
- If
-
Errors:
Status Body Trigger 400 {"error": "invalid signature"}X-Signheader missing/doesn't verify against Monobank's public key400 {"error": "missing invoiceId"}signature OK but body has no invoiceIdNote: this endpoint has no idempotency guard against replayed/duplicate valid webhooks beyond
claim_order_paid's own "already paid" short-circuit — a replayed failure webhook, however, would re-send the "payment failed" Telegram message to the customer every time, since that branch doesn't check the currentpayment_statusagainst being already-failed (there's nopayment_status = "failed"state — onlyunpaid/paidperPAYMENT_STATUSES— so this can't be fully deduped from database state alone).
Me (me_bp, prefix /api/me)
Source: bober_bbq/api/me.py. The customer's own preferences — currently
just their interface language. Uses @require_customer_auth (see
Authentication above), so this works for guests too, e.g. flipping the
UA/EN switcher persists it to their guest TelegramUser row exactly like a
real Telegram customer's.
PATCH /api/me/locale
Persists the customer's explicit language choice (made via the Mini App's
UA/EN switcher) to TelegramUser.locale, so it also applies outside the
Mini App session — e.g. the Telegram bot's own order-status push
notifications, which have no other way to learn about an in-app-only
preference. Called fire-and-forget by the webapp whenever the switcher
changes (webapp/src/i18n/index.tsx's setLocale); its own success/failure
is never surfaced to the customer, since the choice already applies locally
via localStorage regardless.
- Auth: required.
- Request body:
{ "locale": "<string>" }. - Success —
200 OK:{ "locale": "uk" }— the normalized locale actually stored, not necessarily what was sent: any code not inSUPPORTED_LOCALES(uk/en) is silently normalized to the default (uk) rather than rejected (normalize_locale()inbober_bbq/utils/i18n.py— same never-crash convention used throughout the i18n layer). - Errors: none.
Cross-cutting notes for integrators
- Currency: all monetary values (
price,items_total,delivery_fee,total,discount_amount, etc.) are integers in whole UAH (hryvnia), not cents/kopecks. The one exception is insidebober_bbq/payments/monobank.pywhere amounts are multiplied by 100 when talking to the Monobank API — that conversion happens server-side and is invisible to/api/*consumers. - No pagination anywhere except
GET /api/orders's fixed limit of 50. - No API versioning — all routes are directly under
/api/, no/v1/. - No rate limiting was found on any of these routes (the sibling admin
panel's
/admin/loginhas rate limiting per recent commit history, but that's outsidebober_bbq/api/). - No 401s in
cart_bp/orders_bp/checkout_bp/me_bp: every route in those four blueprints uses@require_customer_auth(see Authentication), which falls back to a cookie-bound guest instead of rejecting the request — so{"error": "invalid or missing Telegram init data"},401is now only a theoretical body (still returned by the unusedrequire_telegram_auth), not something any documented route above actually sends. There is also no route-specific 403 (e.g. "not your order") anywhere — those cases are folded into a plain404instead, so from the client's perspective "doesn't exist" and "exists but isn't yours" are indistinguishable. - Malformed JSON bodies are generally not a hard error: every POST/PATCH
handler in
cart.pyandcheckout.pyusesrequest.get_json(silent=True) or {}, so a missing/invalid JSON body is treated as an empty object and falls through to the normal missing-required-field validation rather than raising a JSON-parse error.