Two gaps in the earlier i18n scaffolding: the Mini App's UA/EN switcher existed but most screens and all menu content stayed hardcoded Ukrainian. Menu content (business data, not UI chrome): - Category.name_en, Product.name_en/description_en (nullable, opt-in per item — untranslated items fall back to Ukrainian rather than showing blank). Editable from the existing category/product admin forms. - Allergen labels (a small fixed EU-mandated set) get an English label directly in ALLERGENS, no DB column needed. - /api/categories and /api/products take an optional ?locale= param (no session on these unauthenticated routes to read a stored preference from). Full EN coverage for everything else: - Every remaining webapp screen: MenuPage/ProductCard, CartItemRow (fixes a pre-existing gap in the "already translated" cart screen), OrdersPage, ContactsPage, ClosedScreen, BottomNav, AnnouncementPopup, plus app-level status/error text. - Every reachable bot message: /menu, /contacts (including its locale-dependent reply-keyboard button, matched by text across all locales since the label itself varies), and the rating thank-you flow. - Every customer-facing checkout/order error message (empty cart, closed, min order amount, payment/promo validation, cancel/pay errors) via locale params threaded through workhours.closed_message() and promo.validate_promo_code(). New: PATCH /api/me/locale persists an explicit in-app language choice back to TelegramUser.locale (fire-and-forget from the webapp switcher), so it also applies to the bot's own push notifications, not just the Mini App session that made the choice. Fixed a real bug caught while testing: product_form.html's allergen checklist unpacked ALLERGENS values as 2-tuples, which broke (ValueError) once they became 3-tuples (uk_label, emoji, en_label) — admin's "add product" page 500'd until this was caught in browser testing.
42 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.
Most endpoints (everything except menu, settings, and the Monobank
webhook) are wrapped in the @require_telegram_auth decorator. It:
- 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. - If, after both steps, there's still no
dataor nodata["user"], the request is rejected with:401 Unauthorized {"error": "invalid or missing Telegram init data"} - 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) 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, but
also means the endpoint is not silent about failures server-side.
Note: there is no CSRF token, API key, or rate limiting on top of this —
X-Telegram-Init-Data 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 г", not locale-dependent),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_telegram_auth.
The cart is keyed by CartItem.user_id = g.telegram_user.telegram_id — one
shared cart per Telegram user (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: only the shared 401 from
require_telegram_auth.
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 401 {"error": "invalid or missing Telegram init data"}missing/invalid auth 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 401 {"error": "invalid or missing Telegram init data"}missing/invalid auth 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: only the shared 401.
DELETE /api/cart
Empties the entire cart.
- Auth: required.
- Request body: none.
- Success —
200 OK,{"items": [], "items_total": 0, "count": 0}. - Errors: only the shared 401.
Orders (orders_bp, prefix /api/orders)
Source: bober_bbq/api/orders.py. All routes require @require_telegram_auth
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: only the shared 401.
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 401 {"error": "invalid or missing Telegram init data"}missing/invalid auth 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 401 {"error": "invalid or missing Telegram init data"}missing/invalid auth 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 401 {"error": "invalid or missing Telegram init data"}missing/invalid auth 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 require @require_telegram_auth.
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). - Sends the customer a Telegram "thank you" message (translated) via
send_message. - If
payment_method == "card", calls_offer_card_payment(order, locale):- 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 — instead it sends the customer a translated Telegram message saying card payment is temporarily unavailable and a manager will contact them, andpay_urlin the response isnull. - On success, persists that provider's invoice id/page URL on the order (
Order.payment_providerrecords which one) and returns the pay URL.
- 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
}
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 401 {"error": "invalid or missing Telegram init data"}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 401 {"error": "invalid or missing Telegram init data"}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 401 {"error": "invalid or missing Telegram init data"}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"Цей промокод діє лише на перше замовлення"(shared) missing/invalid auth 401 {"error": "invalid or missing Telegram init data"}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: only the shared 401.
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 401 {"error": "invalid or missing Telegram init data"}missing/invalid auth 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.
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). - 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. 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 authenticated customer's own preferences —
currently just their interface language.
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:
Status Body Trigger 401 {"error": "invalid or missing Telegram init data"}missing/invalid auth
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/). - Generic 401 body is identical across every protected route:
{"error": "invalid or missing Telegram init data"}. There is no route-specific 403 (e.g. "not your order") — 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.