# BipBip REST Inbound API Direction: POS calls BipBip. Base URLs: production `https://merchant-api.bipbip.hn`, staging `https://merchant-api.bipbip.dev`. Path prefix: `/api/v1/`. The contract publishes `/api/v1/Orders` with a capital O and `/api/v1/menu` in lowercase — use the exact casing documented per endpoint. Two surfaces: - **Orders** (`/api/v1/Orders`) — 5 endpoints. Lifecycle of orders BipBip delivers via webhook. - **Menu** (`/api/v1/menu`) — 13 endpoints. Catalog sync: availability, prices, per-entity edits, full catalog publishing. ## Required Headers Every request: ``` X-Bipbip-Api-Key: ``` Every mutation (POST, PUT, PATCH) additionally: ``` Idempotency-Key: X-Bipbip-Schema-Version: 1.0 Content-Type: application/json ``` ## Response Envelope All 2xx responses share the same envelope: ```json { "message": "human-readable, may change without notice", "data": { } } ``` Use the HTTP status code and `data` for business logic. Never branch on `message`. ## Status Vocabulary — read vs. write Reads (`GET`, and the `status` field of every response) return **PascalCase**: `Pending`, `Accepted`, `Preparing`, `Ready`, `HandedOver`, `Rejected`, `Cancelled`. **The driver is not a status.** There is no `DriverAssigned` state. When a driver is assigned, the order's `status` does not move — it stays where it was until the merchant advances it. The driver cycle runs in parallel with the merchant's progress, so assignment can happen while the order is still `Preparing`, before `ready` is reported. Assignment is reported by the `driverAssignedAt` timestamp and by the `driver_assigned` event of the `PUT /events` webhook. The `PUT /status` request body accepts **snake_case**: `preparing`, `ready`, `handed_over`. These are not the same vocabulary. Normalize before comparing a status you read against one you write. --- # Orders ## POST /api/v1/Orders/{orderKey}/accept Accepts a pending order. Atomically runs the cascade `Pending → Accepted → Preparing` in a single operation. The POS never observes the `Accepted` intermediate state — the response `status` is `"Preparing"`. **Path parameters** - `orderKey` (string, required): Order identifier from the `order.created` webhook. Format: `ord_` + 16 base62 characters. **Request body**: Empty JSON object `{}`. Do not include `remoteOrderId` — it was bound when the POS responded to the creation webhook. **Success response**: `202 Accepted` ```json { "message": "Operación completada exitosamente", "data": { "orderKey": "ord_oOR7xSbWz0QksS2I", "status": "Preparing", "acceptedAt": "2026-08-07T17:12:44Z", "remoteOrderId": "POS_001_ORDER_42" } } ``` **Behavior notes**: - The acceptance deadline is validated against the `expiresAt` published in the creation webhook. Past that instant the call returns `409` with a `type` ending in `/acceptance-timeout` — **even if the order still reads `Pending`**. - After `/accept`, do not call `PUT /status` with `preparing` — the order is already in that state and the call returns 409. - For **auto-accept** stores, BipBip runs the same cascade when the POS responds 200 to the `order.created` webhook, and `expiresAt` arrives as `null`. - Publishes the `order.accepted_by_external_merchant.v1` event. The internal cascade to `Preparing` emits no extra event. - The response does **not** include `preparingAt`. Read it from `GET /api/v1/Orders/{orderKey}`. **Error responses**: 400, 401, 404, 409, 422, 429. --- ## POST /api/v1/Orders/{orderKey}/reject Rejects a pending order. `Rejected` is terminal. **Path parameters** - `orderKey` (string, required): Order identifier. **Request body** - `reasonCode` (string, required): One of the values below. - `message` (string, optional): Free-text detail for support. **Not shown to the end customer.** | reasonCode | When to use | |------------|-------------| | `STORE_CLOSED` | Store is closed for the day or not yet open | | `OUT_OF_OPERATING_HOURS` | Outside operating hours for this order | | `ITEM_NOT_OFFERED` | Item is not on the store's menu | | `ITEM_OUT_OF_STOCK` | Item is sold out | | `PRICE_MISMATCH` | POS price differs from the order price | | `SYSTEM_ISSUE` | Generic POS failure or other cause | **A `reasonCode` outside this catalog does NOT fail the request** — it is normalized to `SYSTEM_ISSUE` and returned that way in `data.reason`. A typo surfaces as a misclassified rejection metric, not as a 422. Compare `data.reason` against what you sent. **Success response**: `202 Accepted` ```json { "message": "Operación completada exitosamente", "data": { "orderKey": "ord_oOR7xSbWz0QksS2I", "status": "Rejected", "rejectedAt": "2026-08-07T17:12:44Z", "reason": "ITEM_OUT_OF_STOCK" } } ``` Publishes the `order.rejected_by_external_merchant.v1` event. --- ## PUT /api/v1/Orders/{orderKey}/status Advances an already-accepted order to the next valid state. Forward transitions only. **Path parameters** - `orderKey` (string, required): Order identifier. **Request body** - `status` (string, required): One of `preparing`, `ready`, `handed_over`. `cancelled` is not accepted. - `occurredAt` (string, optional): ISO 8601 timestamp of when the change happened in your system. If omitted, BipBip uses its own clock. When supplied, it is clamped to `[now − 24h, now + 5min]`. **Valid transitions the merchant drives**: - `preparing → ready` - `ready → handed_over` `preparing` remains in the enum for forward compatibility, but calling it after `/accept` returns 409. `ready` is not driven by the merchant alone: the driver can mark the order ready from their own app, and that record advances it too. So a `PUT` with `status: "ready"` can return 409 with `meta.currentStatus: "Ready"` and `meta.allowedTransitions: ["HandedOver"]`. That is not an error — it is a state already reached. Continue with `handed_over` instead of retrying. Post-acceptance cancellations are not exposed to the merchant in v1.0 — they originate inside BipBip (customer, operator, timeout, failed webhook) and arrive via the `cancelled` event of the `PUT /events` webhook. **Success response**: `202 Accepted` ```json { "message": "Operación completada exitosamente", "data": { "orderKey": "ord_oOR7xSbWz0QksS2I", "status": "Ready", "changedAt": "2026-08-07T17:12:51Z" } } ``` `changedAt` is when BipBip recorded the change; it may differ from the `occurredAt` you sent. Publishes the `order.status_changed_by_external_merchant.v1` event. --- ## GET /api/v1/Orders/{orderKey} Returns the full order: current status, every transition timestamp, the change history, and the commercial content. Read-only; no `Idempotency-Key` required. **Path parameters** - `orderKey` (string, required): Order identifier. **Success response**: `200 OK` Fields under `data`: - `orderKey` (string): Public opaque identifier. The only id that travels in URLs and payloads. - `storeId` (integer): BipBip internal store id. To address a store, use your own `storeRemoteId`. - `brandId` (integer): BipBip internal id of the brand that owns the store. - `status` (string): Current status, PascalCase. - `merchantStatusReason` (string | null): Reason for the last status change, when one was declared. - `remoteOrderId` (string | null): The order reference in your POS. - `receivedAt` (ISO 8601): When BipBip received the order. Anchors the acceptance window. - `acceptedAt`, `rejectedAt`, `preparingAt`, `readyAt` (ISO 8601 | null): Merchant transition timestamps. `preparingAt` usually matches `acceptedAt` because of the accept cascade. - `driverAssignedAt` (ISO 8601 | null): When a driver was assigned. **Cleared** if the driver is released. - `handedOverAt` (ISO 8601 | null): When you released the food — to the driver on `delivery`, to the customer on `pickup`. It can be `null` with the order **already in `HandedOver`**: that happens when you never reported the handover and BipBip closed the order once delivery was confirmed, so nobody knows when you released it. Do not use this field to detect that the order left — use `status`. - `deliveredAt` (ISO 8601 | null): Since when the customer has the order. On orders created from 2026-08-12 onwards, `null` = no confirmed delivery yet; orders predating that date return `null` permanently even if they were delivered, because the field shipped without backfilling earlier rows. Do not read that `null` as "not delivered" when reconciling a period spanning that date — for those orders `HandedOver` is the only sign of closure. This is the field that tells "the driver picked it up, it is in transit" apart from "it arrived": both look like `HandedOver`, because merchant progress ends when the food is released. On `delivery` the driver confirms it and it is later than `handedOverAt`; on `pickup` it matches `handedOverAt`, because the merchant handed the order to the customer. - `cancelledAt` (ISO 8601 | null): When it was cancelled. Can be populated **after** a delivery, because of a refund or an incident. - `history` (array): Chronological trace. See below. - `order` (object | null): Commercial content. See below. ### data.history[] Each entry: `fromStatus` (null on the first item), `toStatus`, `changedAt`, `actorType`, `actorId` (null for `system`), `reason` (free text — do not parse), `driver` (object | null). `actorType` values: `merchant` (you, via the API), `customer`, `operator` (BipBip operator), `driver`, `system` (acceptance timeout, delivery failure). `driver` is present **only** on driver changes, and is where the driver info must be read — not in `reason`: - `event`: `driver_assigned` (there is a driver), `driver_reassigned` (replaced, there is a new one), `driver_released` (**no driver anymore**, wait for reassignment). - `code` (string | null): Driver identifier, the same one BipBip uses when talking about them with people (e.g. `BIP-01424`). Use it to refer to a specific driver when contacting support. Falls back to the internal id when the driver has no code on file, and on rows predating the code travelling in this payload. Treat it as opaque — it is a label, not a number. - `fullName` (string | null) - `previousCode` (string | null): Who they replace, by the same criteria. Only on `driver_reassigned`. The driver does not move the merchant's status, so assign, reassign and release all produce rows where `fromStatus == toStatus` (the order stays where it was, typically `Ready`). Without reading `driver.event`, a release is indistinguishable from an assignment. ### data.order Same shape as the `order.created` webhook payload — the same parser works for both channels, so this endpoint recovers an order whose webhook you could not process. - `displayCode` (string): Human-readable order code for support. - `storeRemoteId` (string): The store code in your POS. - `currency` (string): ISO 4217. - `createdAt` (ISO 8601) - `expiresAt` (ISO 8601 | null): Acceptance deadline. `null` for auto-accept stores. - `fulfillment` (object): `type` (`delivery` | `pickup`), `isExpress`, `prepareBy`, and `driverPickupAt` or `customerPickupAt` depending on `type`. - `customer` (object | null): `firstName`. Only on pickup; `null` on delivery (PII minimization). - `payment` (object): `methods[]` (`type`, `amount` — supports combining cash, card, bips), `amountToCollect`, `changeFor`. - `summary` (object): `subtotal`, `taxes`, `discounts`, `additionalCharges`, `grandTotal`. - `charges[]` (array): `code`, `amount`, `billedBy`. - `discounts[]` (array): `code`, `name`, `amount`, `fundedBy`. - `items[]` (array): `code`, `remoteCode`, `name`, `quantity`, `unitPrice`, `tax`, `lineTotal`, `note`, `modifierGroups[]`. - `customerNote` (string | null) - `invoice` (object | null): `taxId`, `businessName`. `null` for final consumers. --- ## GET /api/v1/Orders Lists orders for the authenticated client, sorted by order number descending. Opaque cursor pagination — the cursor is that same number. Not sorted by `receivedAt`: the moment BipBip registered the order can differ from the real creation order. **Query parameters** (all optional) - `Cursor` (string): Opaque cursor from `nextCursor` in the previous response. Do not interpret or hand-build it. - `PageSize` (integer): Max 100, default 20. - `Status` (string): Filter by status, in the PascalCase read vocabulary. - `FromDate` (ISO 8601) - `ToDate` (ISO 8601) **Success response**: `200 OK` Fields under `data`: - `items[]`: `orderKey`, `storeId`, `status`, `receivedAt`, `acceptedAt` (null if pending or rejected), `remoteOrderId`, `driverAssignedAt`, `deliveredAt`. The last two let you tell an in-transit order from a delivered one without fetching the detail — both report `status: "HandedOver"`. - `nextCursor` (string | null): `null` on the last page. - `hasMore` (boolean): The stop condition for the pagination loop. --- # Menu ## Scopes Every menu write declares a scope: | Scope | What it touches | `stores` field | |-------|-----------------|----------------| | `store` | Per-store override on top of the master catalog. Reversible, visible in `GET /menu/stores/{storeRemoteId}`. | **Required.** Default when `scope` is omitted. | | `brand` | The brand's master catalog. Affects every store of that brand. | **Must be omitted.** | ## Async model The ten per-entity write endpoints return `202 Accepted` with a `changeId` (`chg_` + 16 characters). Poll `GET /menu/changes/{changeId}` or listen for the `menu.change.completed.v1` webhook. Two exceptions: `POST /menu/preview` is read-only, and `PUT /menu` is **synchronous** — its 200 already reflects what was applied. **A 202 does not mean the change was applied.** The real outcome is per store. A global `status: "Applied"` can coexist with failed rows. Walk `results[]` and check `applied` on each one. ## Capability matrix Only `(entity, field, scope)` combinations actually shipped are accepted. A deferred combo does not appear in `GET /menu/capabilities` and the validator rejects it with 422. Query it before building on new fields. Current restrictions: - `available` is deferred in `brand` scope for `product` and `modifierOption` — it is a per-store override concept. - `category` and `modifierGroup` are brand-only (no per-store override). - `brand` scope applies to Domicilio only (`delivery`/`pickup`). - `price` and `name` of `modifierOption` in `store` scope carry `pendingCustomerRollout: true` — accepted and persisted, but not yet reflected in the customer app. --- ## PUT /api/v1/menu/availability Write shortcut over `product` or `modifierOption` in store scope. Each item applies to every store in `stores`. **Request body** - `stores[]` (array of string, required): Target stores by your `remoteId`. Must belong to a single brand. - `items[]` (array, required): Each item has `targetEntity` (`product` | `modifierOption`), `targetCode` (your `remoteCode`), `available` (boolean), `categoryCode` (optional — disambiguates when the code is not unique; for `modifierOption` it is the parent group code). ```json { "stores": ["POS_TGU_001", "POS_SPS_004"], "items": [ { "targetEntity": "product", "targetCode": "BURG-DOBLE", "available": false }, { "targetEntity": "modifierOption", "targetCode": "OPT-QUESO-EXTRA", "available": true, "categoryCode": "GRP-EXTRAS" } ] } ``` **Success response**: `202 Accepted` with `{ "data": { "changeId": "chg_9fA2kQ7pLm3XbT10" } }`. --- ## PUT /api/v1/menu/prices Price write shortcut for `product` or `modifierOption`. Store scope only. **Request body** - `stores[]` (array of string, required) - `items[]` (array, required): `targetEntity`, `targetCode`, `price` (number, >= 0), `categoryCode` (optional). **Success response**: `202 Accepted` with a `changeId`. --- ## PATCH /api/v1/menu/products/{code} Edits product fields by code. **Path parameters** - `code` (string, required): Product code as returned by the store menu read. **Request body** - `scope` (string, optional): `store` (default) or `brand`. - `stores[]` (array of string): Required in `store` scope, must be omitted in `brand` scope. - `categoryCode` (string, optional): Disambiguates when the same product code repeats across categories. - `set` (object, required): Declarative field set. Absent or `null` = no change. Editable per scope: - `store`: `available`, `price`, `name`, `description`, `schedule`, `channels`. - `brand`: `price`, `name`, `description`, `tax`, `maxPerOrder`, `image`. `set` field details: - `available` (boolean): store scope only. - `price` (number): >= 0. - `name`, `description` (string) - `schedule` (object): `days[]` as three-letter codes (`mon`…`sun`, case-insensitive, at least one), `startTime` and `endTime` as `HH:mm`. Outside the window the item is unavailable. - `channels[]` (array of string): `delivery` and/or `pickup`, case-insensitive. Unknown value → 422. Empty list = no channel. - `tax` (number): Fraction between 0 and 1. Brand scope only. - `maxPerOrder` (integer): > 0. Brand scope only. - `image` (string): Absolute http(s) URL. Brand scope only. **BipBip downloads it, converts it to WebP and re-hosts it in its own bucket** — the read returns a different URL. The 202 does not mean the final image is ready. An unreachable URL, a non-image, or an oversized file is rejected with 422 before emission. **Success response**: `202 Accepted` with a `changeId`. --- ## PATCH /api/v1/menu/modifier-options/{code} Edits modifier option fields by code. **Request body** - `scope` (string, optional): `store` (default) or `brand`. In `brand` scope it applies to Domicilio only. - `stores[]`: Required in `store` scope, omitted in `brand`. - `categoryCode` (string): Code of the **parent modifier group** — NOT a menu category. Optional in `store` scope, **required in `brand` scope**, because option codes are not unique across groups. - `set`: `available` (store only), `price`, `name`, `isDefault` (brand only), `maxPerOrder` (brand only). **Success response**: `202 Accepted` with a `changeId`. --- ## PATCH /api/v1/menu/categories/{code} Edits a category in the brand's master catalog. Brand scope only for now — `stores` must be omitted. The `code` in the path is the one BipBip assigns and the menu read returns. The `remoteCode` you sent when publishing the full menu is **not preserved** for categories; keep the mapping on your side. **Request body**: `scope: "brand"`, `set` with `name` (string) and/or `position` (integer, >= 0). **Success response**: `202 Accepted` with a `changeId`. --- ## PATCH /api/v1/menu/modifiers/{code} Edits a modifier group in the brand's master catalog. Brand scope only for now. Same code rule as categories: the path `code` is BipBip's; `remoteCode` is not preserved for groups. **Request body**: `scope: "brand"`, `set` with `name` (string), `min` (integer, >= 0), `max` (integer, >= 1 and >= `min`), `type` (`radio` | `checkbox`, case-insensitive; unknown value → 422). **Success response**: `202 Accepted` with a `changeId`. --- ## POST /api/v1/menu/changes Sends a heterogeneous set of menu changes in a single operation. The shortcuts and the per-entity `PATCH` endpoints compile internally to this same shape. **Request body** - `stores[]` (array of string): Target stores applying to every change in the set. All changes must resolve to stores of a single brand. - `changes[]` (array, required): Each change has `targetEntity` (`product` | `modifierOption` | `category` | `modifierGroup` | `localProduct`), `targetCode`, `scope`, `categoryCode` (parent category or group; required for `modifierOption` in `brand` scope), and `set` (same fields as the `PATCH` endpoints). **Success response**: `202 Accepted` with a single `changeId` for the whole batch. --- ## POST /api/v1/menu/stores/{storeRemoteId}/products Creates a product that belongs to one store, outside the brand's master catalog. **Path parameters** - `storeRemoteId` (string, required): Store code defined by you. **Request body** - `code` (string, required): Your product code, unique within the store. BipBip derives its own internal code and keeps yours as `remoteCode` — you keep addressing the product with your code. - `name` (string, required) - `price` (number, required): >= 0. - `categoryCode` (string, required): Must be an existing category in the store menu (list them with `GET /menu/stores/{storeRemoteId}`). **The customer-facing menu is built by grouping on category: a product without a valid category is created but never displayed.** - `description` (string, optional) - `channels[]` (array of string, optional): `delivery`, `pickup`. Omitted = `delivery` only. - `tax` (number, optional): `0`, `0.15` or `0.18`. Omitted = 0. - `maxPerOrder` (integer, optional): > 0. Omitted = 25. - `image` (string, optional): Absolute http(s) URL, re-hosted as WebP. - `available` (boolean, optional): **Not applied on creation** — the product is always created active. To make it unavailable, create it and then call `PUT /menu/availability`. Position is not accepted: BackOffice places the product at the end of its category. **Success response**: `202 Accepted` with a `changeId`. --- ## POST /api/v1/menu/preview Dry-run of a full-menu submission. Brand scope only. BipBip resolves the authenticated client's brand and delegates to BackOffice the diff calculation (what would be created, updated or deactivated) against the current master catalog. **Applies no changes and emits no events.** **Request body**: The full menu as a NESTED tree — categories with their products, each product with its modifier groups inside. You identify every entity with your own stable `remoteCode`; BipBip generates the opaque internal code. ```json { "categories": [ { "remoteCode": "CAT-HAMBURGUESAS", "name": "Burgers", "position": 1, "products": [ { "remoteCode": "BURG-DOBLE", "name": "Double BBQ Burger", "description": "Double patty, bacon and house BBQ sauce", "price": 185, "tax": 0.15, "maxPerOrder": 10, "image": "https://cdn.your-merchant.com/burg-doble.jpg", "modifierGroups": [ { "remoteCode": "GRP-EXTRAS", "name": "Extras", "min": 0, "max": 3, "type": "checkbox", "options": [ { "remoteCode": "OPT-QUESO-EXTRA", "name": "Extra cheese", "price": 22, "isDefault": false, "maxPerOrder": 2 } ] } ] } ] } ] } ``` **`remoteCode` rules**: - Required on every entity. Safe format: letters, digits and `_ . - :`, no spaces, up to 100 characters. - Unique across categories; unique across the whole menu for products; unique within a product for groups; unique within a group for options. - Two products declaring a group with the **same** `remoteCode` share that group — it is defined once at brand level. **`image` is required** on products in a full-menu submission: a product without an image is rejected with 422. **Success response**: `200 OK`. `data` carries BackOffice's diff plus `warnings[]`. Deeper semantic validations ("does this code already exist?") come back as warnings, **not** as 422 — a 200 preview does not mean everything is fine. **Error responses**: 400, 401, 404, 422, 429, 502, 503. --- ## PUT /api/v1/menu Applies the full menu to the brand's master catalog. Same body as `POST /menu/preview`. Brand scope only. **Synchronous**: unlike every other menu write, this does not return 202 + polling. The 200 reflects the result already applied by BackOffice. **The menu you send becomes the complete source of truth for the brand**: items present in the master catalog but omitted from the submission are **deactivated**. This is a replacement, not a partial merge. **Guardrail**: if the resulting deactivation exceeds a threshold of the active catalog, BackOffice rejects with `409 Conflict`, reporting the affected counts in `detail`, unless you confirm explicitly with `?confirmLargeDeactivation=true`. Recommended: run `POST /menu/preview` with the same body first, review the diff (including the deactivation), then apply. **Query parameters** - `confirmLargeDeactivation` (boolean, optional): Confirms a bulk deactivation the guardrail would otherwise block. **Error responses**: 400, 401, 404, 409, 422, 429, 502, 503. --- ## GET /api/v1/menu/stores/{storeRemoteId} Returns the store's full current catalog. A restricted item **still appears** in the response instead of disappearing. Not cached — the menu is live state. Two fields resolve the common case without interpreting rules: - `orderable` (boolean): Whether the item can be ordered right now. Already computed. - `resumesAt` (ISO 8601 | null): When it becomes available again on its own. `null` means it does **not** come back on its own and a merchant action is required (for example `PUT /menu/availability`). **Success response**: `200 OK` `data.categories[]`: `code` (BipBip's), `name`, `position`, `products[]`. Each product: `code`, `categoryCode`, `name`, `price`, `tax`, `position`, `maxPerOrder`, `origin` (`brand` = master catalog, `store` = local product), `orderable`, `channels[]` (empty = blocked everywhere), `remoteCode` (your POS code), `description`, `image` (already re-hosted by BipBip as WebP — **not** the URL you sent), `resumesAt`, `overrides[]`, `modifierGroups[]`. Each modifier group: `code`, `name`, `min`, `max`, `type`, `position`, `options[]`. Each option: `code`, `name`, `price`, `isDefault`, `orderable`, `remoteCode`, `maxPerOrder` (null = the group's `max` governs), `resumesAt`, `overrides[]`. ### overrides[] Store overrides on top of the master catalog, **sorted by precedence** — `overrides[0]` is the one driving `orderable` and `resumesAt`. Items with nothing on top omit this field. - `type`: `substituted`, `unavailable`, `scheduled`, `channel`, `value`. Discriminates the shape — only the fields that apply to that type are populated. - `reason` (string | null) - `replacedBy` (string | null): Only on `substituted`. - `window` (object | null): `days[]`, `startTime`, `endTime`. Only on `scheduled`, and informational — use `resumesAt` to decide. - `fields[]` (array): On `channel` and `value`. Each entry: `field`, `value` (in effect), `base` (the master catalog value it reverts to if the override is removed). --- ## GET /api/v1/menu/changes/{changeId} Returns the status and per-store results of a menu change request. **Path parameters** - `changeId` (string, required): The identifier returned by the write's 202. Format: `chg_` + 16 characters. **Success response**: `200 OK` Fields under `data`: - `changeId` (string) - `status` (string): `Pending` while BackOffice applies, `Applied` once finished. **This is not the result** — it only means processing completed. - `schemaVersion` (string) - `submittedAt` (ISO 8601) - `completedAt` (ISO 8601 | null): `null` while pending. - `results[]` (array): One row per (store, entity) affected. Best-effort per store. - `storeRemoteId` (string | null), `storeId` (integer | null): `null` on `brand` scope results. - `targetEntity`, `targetCode`: As you sent them. - `applied` (boolean): Whether it was applied at this store. `false` does not invalidate the other rows. - `errorCode` (string | null): Stable failure code, e.g. `UNKNOWN_CATEGORY`. Safe for retry logic. - `errorMessage` (string | null): Human-readable. Do not parse. - `appliedAt` (ISO 8601 | null): `null` if it failed. --- ## GET /api/v1/menu/capabilities Returns the capability matrix: for each `(entity, field, scope)` combination, whether it is editable in the current version. Only shipped combinations are listed; deferred ones are absent and the validator rejects them with 422. **Success response**: `200 OK` Fields under `data`: - `capabilities[]` (array): `entity` (`product` | `modifierOption` | `category` | `modifierGroup`), `field` (`available`, `price`, `name`, `description`, `schedule`, `channels`, `tax`, `maxPerOrder`, `image`, `isDefault`, `position`, `min`, `max`, `type`), `scope` (`store` | `brand`), `pendingCustomerRollout` (boolean). - `notes` (string | null): Clarifications on current limitations. `pendingCustomerRollout: true` means the change is accepted and persisted but the customer app does not reflect it yet. Currently applies to store-level `price` and `name` on `modifierOption`. --- # Cross-cutting ## Idempotency-Key Rules - UUID v4, generated fresh per **logical attempt** — not per retry of the same attempt. - Server caches responses for 24 hours. - Same key + same body within 24h: server returns the cached response without re-executing. - Same key + different body: `422` with a `type` ending in `/idempotency-conflict`. - Missing key: `422 Unprocessable Entity`. Reuse the same key when retrying the same logical attempt (timeout, 5xx, dropped connection). Mint a new key when a different logical attempt starts. ## Rate Limits Limits apply per API key across all endpoints. | Window | Limit | Strategy | |--------|-------|----------| | Fixed | 100 requests/min | Resets every 60s | | Sliding | 1,000 requests/hr | Evaluated continuously | On limit exceeded: `429 Too Many Requests`. Respect the `Retry-After` header and use exponential backoff with jitter. ## Error Envelope (RFC 7807) All error responses follow RFC 7807 Problem Details: ```json { "type": "https://bipbip.app/probs/not-found", "title": "Order not found", "detail": "Order ord_xxx does not exist or does not belong to the authenticated client.", "status": 404, "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", "meta": null } ``` **Discriminate on the `type` suffix, not on `detail`.** `detail` is human-facing and may change between versions; the `type` suffix is stable. Match with `type.endsWith('/invalid-state-transition')` so you do not couple to the host, which varies across environments. `meta` shape depends on the error: - Transition 409: `{ "currentStatus": "Preparing", "allowedTransitions": ["Ready"] }`. An **empty** `allowedTransitions` means a terminal state — do not retry. - Validation 422: `{ "errors": { "items[0].targetCode": ["The targetCode field is required."] } }` — field path to list of broken rules. | Status | Cause | What to do | |--------|-------|------------| | 400 | `X-Bipbip-Schema-Version` missing or unsupported | Add the header with value `1.0` to every mutation | | 401 | API key missing, invalid or revoked | Check `X-Bipbip-Api-Key`; contact support if revoked | | 404 | Resource does not exist or does not belong to the authenticated client | Confirm the exact identifier | | 409 | Invalid state transition, acceptance window expired (`/acceptance-timeout`), or bulk deactivation blocked on `PUT /menu` | Read `meta.allowedTransitions[]` and pivot | | 422 | Invalid body, missing `Idempotency-Key`, idempotency conflict, or a menu combo that is not shipped | Inspect `meta.errors`; do not retry without fixing the body | | 429 | Rate limit exceeded | Respect `Retry-After`, backoff with jitter | | 502 / 503 | BackOffice did not respond or is unavailable (menu endpoints that delegate to it) | Retry with the **same** `Idempotency-Key` and backoff |