bober-bbq-bot/docs/API.md
byrsapty ff232d1a0f Bilingual menu content + finish EN coverage across bot and Mini App
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.
2026-08-27 16:38:10 +03:00

42 KiB
Raw Blame History

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:

  1. Reads the raw Telegram Mini App initData string from the X-Telegram-Init-Data request header (not a query param, not the body).
  2. 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 (returns None).
    • Pops out the hash field; if absent, fails.
    • Rebuilds the data_check_string by joining all remaining key=value pairs sorted by key with \n.
    • Computes secret_key = HMAC_SHA256(key=b"WebAppData", msg=bot_token), then computed_hash = HMAC_SHA256(key=secret_key, msg=data_check_string), and compares it to the received hash with hmac.compare_digest (constant-time).
    • Rejects (None) if auth_date is more than 24 hours old (MAX_AGE_SECONDS = 24 * 60 * 60).
    • Parses the user field's JSON string into a dict.
    • bot_token comes from config.BOT_TOKEN; if it's unset, validation always fails.
  3. Dev-mode bypass: if validation failed (data is None) and current_app.debug is True (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 when debug is off (e.g. production), and does nothing if dev_user_id isn't supplied.
  4. If, after both steps, there's still no data or no data["user"], the request is rejected with:
    401 Unauthorized
    {"error": "invalid or missing Telegram init data"}
    
  5. On success, the Telegram user is upserted into TelegramUser (_upsert_user): looked up by telegram_id, created if missing (seeding locale from the token payload's language_code, normalized to uk/en — never touched again automatically after that, see docs/I18N.md), and username/first_name are overwritten from the token payload on every request. This runs (and commits) on every authenticated call, even read-only ones like GET /api/cart.
  6. The resulting TelegramUser row is stashed on g.telegram_user for 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 — see normalize_locale() in bober_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):
    [
      { "id": 1, "name": "Бургери", "slug": "burgers", "sort_order": 0 }
    ]
    
    Fields per Category.to_dict(locale): id (int), name (str — name_en when locale=en and it's been filled in for that category, otherwise the Ukrainian name), slug (str, always the original slug regardless of locale), sort_order (int). Only rows with is_active = true are 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/categories above).
  • Request body: none.
  • Success — 200 OK, JSON array:
    [
      {
        "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": "🥛" }
        ]
      }
    ]
    
    Fields per Product.to_dict(locale): id, category_id (int), name (str — name_en when locale=en and set, else Ukrainian name), description (str — same fallback via description_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}label is one of the 14 EU-mandated allergen names, translated per locale from a fixed dict in bober_bbq/models.py, not from a database column; unknown codes silently dropped). Only is_active = true products are returned; there's no per-category filter query param — the client filters client-side by category_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:
    {
      "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
    }
    
    All values come straight from the Setting key/value table via Setting.get / get_int / get_bool, except:
    • is_open: computed by workhours.is_open() from work_hours_from/work_hours_to in the configured timezone (config.TIMEZONE), including overnight-schedule handling (e.g. 20:0002:00).
    • force_closed: Setting.get_bool("force_closed", False) — an admin kill switch independent of working hours.
    • theme: derived from the single hex color in webapp_theme_accent (default #ff7a3d) via derive_palette(), which lightens/darkens it and picks a readable ink color based on perceived luminance.
    • has_active_promos: True if at least one PromoCode is active, not expired, and not past max_usesignores per-user restrictions like first_order_only/min_order_amount (those need cart/user context), so it can be true even 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_id int yes (effectively) No product matches None/missing → 404.
    quantity int no, default 1 Coerced with int(...); a non-numeric value raises an unhandled ValueError → 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, quantity is 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 Product with that id (Product.query.filter_by(id=product_id, is_active=True))
    400 {"error": "quantity must be >= 1"} quantity < 1

    Note: the product-existence check runs before the quantity check, so a nonexistent product_id with quantity: 0 returns 404, not 400. Also, request.get_json(silent=True) or {} means a non-JSON or empty body is treated as {} rather than erroring, so product_id defaults to None and the request always 404s in that case (no distinct "missing product_id" error). Note: int(body.get("quantity", 1)) will raise ValueError/TypeError (uncaught → 500) if quantity is present but not int-coercible (e.g. "abc"); this is not guarded against, unlike the delivery checkout's cash_change_for parsing 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
    quantity int no, default 1 Same int(...) coercion caveat as above.

    Example: { "quantity": 3 }

  • Success — 200 OK, updated cart envelope. If quantity <= 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 CartItem for 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 of new | confirmed | cooking | ready | courier | completed | cancelled (ORDER_STATUSES in models.py); status_label is translated per the order's own customer's TelegramUser.locale (via t("order.status_label.<status>", locale) in bober_bbq/utils/i18n.py — a separate translation namespace from the Ukrainian-only ORDER_STATUS_LABELS the admin panel uses; falls back to Ukrainian, then to the raw key, if somehow unrecognized).
  • can_cancel: true only while status is new or confirmed (CANCELLABLE_STATUSES).
  • can_pay: true only if payment_method == "card", payment_status == "unpaid", and status not 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 no product_id or line_total field in this list (unlike the cart envelope).
  • Delivery-only fields (address_street, address_house) are always present in the JSON but null for pickup/dine-in orders; likewise pickup_time is only non-null for pickup orders, table_number only for dine-in. (address_apartment/address_comment/address_city are 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 = "Скасовано клієнтом"; calls restore_for_order(order) (restores deducted inventory, per bober_bbq/utils/inventory.py); calls notify_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": "..."} status not in {"new", "confirmed"}message text is translated per the order's own customer's TelegramUser.locale (see docs/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": ["Старий бургер"] }
    

    cart is the same envelope as the cart endpoints. skipped is an array of product names (strings) for order-items whose product_id no longer resolves to an active Product (deleted, deactivated, or the historic item had no product_id at 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, see bober_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_provider records 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 message text below is translated per the order's own customer's TelegramUser.locale (see docs/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 from monobank_error once 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:

  1. Loads the caller's cart via get_cart_items() (same shared cart as /api/cart). If empty:
    400 { "error": "cart_empty", "message": "Кошик порожній" }
    
  2. Checks orders_allowed() (working hours and the force_closed admin kill switch, or the allow_orders_outside_hours setting). If orders aren't currently allowed:
    400 { "error": "closed", "message": "<closed_message(locale)>" }
    
    closed_message(locale) returns the admin's custom force_closed_message verbatim 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:

  1. Calls notify_manager_new_order(order) — notifies the staff/manager Telegram chat (always Ukrainian; staff-facing, not translated).
  2. Sends the customer a Telegram "thank you" message (translated) via send_message.
  3. 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, see bober_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, and pay_url in the response is null.
    • On success, persists that provider's invoice id/page URL on the order (Order.payment_provider records which one) and returns the pay URL.

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
    name string yes min 2 chars after strip
    phone string yes 913 digits after stripping non-digits
    address_city string yes
    address_street string yes
    address_house string yes
    address_apartment string no blank/absent → null
    address_comment string no blank/absent → null
    payment_method string yes must be "cash" or "card"
    cash_change_for int no only meaningful when payment_method == "cash"; must be numeric if provided and non-blank
    promo_code string 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:

    1. _validate_cart() (cart-empty / closed checks, see above).
    2. Minimum order amount: if Setting.get_int("min_delivery_order_amount") > 0 and the cart's items_total is below it:
      400 { "error": "min_order_amount", "message": "Мінімальна сума замовлення на доставку — {min_amount} грн. Додайте ще товарів у кошик." }
      
    3. Promo resolution (_resolve_promo).
    4. Required-field presence check over ["name", "phone", "address_city", "address_street", "address_house", "payment_method"] — any missing/blank (after str(...).strip()) field:
      400 { "error": "validation", "missing": ["<field1>", "<field2>", ...] }
      
      Note: this is the one error shape in the whole API that uses "missing" (a list of field names) instead of "message".
    5. payment_method must be "cash" or "card":
      400 { "error": "validation", "message": "invalid payment_method" }
      
    6. If payment_method == "cash" and cash_change_for is present/non-blank, it must parse as an int:
      400 { "error": "validation", "message": "cash_change_for must be a number" }
      
    7. name/phone contact validation (_contact_field_error, see above).
  • Success — 201 Created, the finalize response shape above.

  • Side effects: creates the Order + OrderItem rows, deducts ingredient inventory (deduct_for_order), clears the cart, increments the promo code's used_count if one was applied, notifies manager + customer over Telegram, and optionally creates a Monobank invoice (see _finalize above).

  • 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
    name string yes min 2 chars after strip
    phone string yes 913 digits after stripping non-digits
    pickup_time string yes must match `^([01]?\d
    payment_method string yes must be "cash" or "card"
    promo_code string 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_method in ("cash", "card") → promo resolution → contact-field validation → pickup_time regex 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_time is 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 general orders_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
    name string yes min 2 chars after strip
    phone string yes 913 digits after stripping non-digits
    table_number string no blank/absent → null
    payment_method string yes must be "cash" or "card"
    promo_code string no

    Example:

    { "name": "Марія", "phone": "380501112233", "table_number": "7", "payment_method": "cash" }
    
  • Validation order: _validate_cart() → required-field presence check (["name", "phone", "payment_method"]) → payment_method in ("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_amount is 0 for dine-in orders per order_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>" }code required (non-blank after strip).

  • Success — 200 OK:

    { "valid": true, "code": "WELCOME10", "discount_percent": 10, "discount_amount": 30 }
    

    code is echoed back as stored on the PromoCode row (its canonical casing, not necessarily what the user typed — lookup is case-insensitive via db.func.upper(...)). discount_amount is computed via calc_promo_discount(promo, items_total) = round(items_total * discount_percent / 100), or 0 if items_total <= 0.

  • Errors — note this endpoint returns valid: false with a 400 status (not 200) for every rejection reason, all sharing the shape { "valid": false, "message": "<reason>" }. Every message is translated per the caller's TelegramUser.locale (Ukrainian shown below for brevity):

    Trigger message
    code blank/missing "Введіть промокод"
    no matching code, or matched but is_active = false "Промокод не знайдено або він більше не діє"
    expires_at in the past "Термін дії промокоду закінчився"
    used_count >= max_uses "Ліміт використань цього промокоду вичерпано"
    cart items_total below min_order_amount "Мінімальна сума замовлення для цього промокоду — {N} грн"
    first_order_only and 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:
    { "has_orders": false, "name": null, "phone": null, "delivery": null }
    
    Otherwise:
    {
      "has_orders": true,
      "name": "Олена",
      "phone": "+380971234567",
      "delivery": {
        "address_city": "Київ",
        "address_street": "вул. Хрещатик",
        "address_house": "10",
        "address_apartment": "5"
      }
    }
    
    name/phone come from the single most recent order of any type (ORDER BY created_at DESC LIMIT 1, no order_type filter). delivery is populated from the most recent order specifically of order_type == "delivery" (a separate query) — it will be null if the customer has orders but none of them were deliveries, even if has_orders is true.
  • 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 atomic UPDATE ... 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_paid returns True) 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 (see bober_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 a 200 — 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 the X-Sign header 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() in bober_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) and status (string); other fields Monobank sends are ignored.

  • Success responses:

    • 200 { "ok": true } — for a genuinely-processed status change, or for an invoiceId that 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 status is in PAID_STATUSES = {"success"} and the order isn't already paid: calls claim_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 triggers checkbox_prro.fiscalize_in_background(order.id).
    • If status is in FAILED_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 status value: no action taken (still acked 200).
  • Errors:

    Status Body Trigger
    400 {"error": "invalid signature"} X-Sign header missing/doesn't verify against Monobank's public key
    400 {"error": "missing invoiceId"} signature OK but body has no invoiceId

    Note: 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 current payment_status against being already-failed (there's no payment_status = "failed" state — only unpaid/paid per PAYMENT_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 in SUPPORTED_LOCALES (uk/en) is silently normalized to the default (uk) rather than rejected (normalize_locale() in bober_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 inside bober_bbq/payments/monobank.py where 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/login has rate limiting per recent commit history, but that's outside bober_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 plain 404 instead, 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.py and checkout.py uses request.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.