# BipBip Merchant Integration API — Full LLM Context Integration API for merchants: an inbound REST API to manage orders and outbound HMAC-signed webhooks that deliver order and menu events to your POS. Base URLs: https://merchant-api.bipbip.dev (staging), https://merchant-api.bipbip.hn (production). Payload schema version is 1.0. This file concatenates every documentation section. Source of truth: OpenAPI spec v2.4.0. --- # BipBip Merchant Integration API — Quickstart ## Overview The BipBip Merchant Integration API has two surfaces that work together. The **REST inbound API** (POS calls BipBip) lets the merchant's point-of-sale system accept or reject orders, advance order status, query order data, and keep the menu catalog in sync — availability, prices, per-entity edits, and full catalog publishing. The **webhook outbound API** (BipBip calls POS) delivers new orders and lifecycle events to the merchant's server. Neither surface is optional: webhooks are how orders arrive; the REST API is how the POS responds to them. The REST API is split into `/api/v1/Orders` (5 endpoints) and `/api/v1/menu` (13 endpoints). API spec version: 2.4.0. Payload schema version: 1.0 (carried in `X-Bipbip-Schema-Version`). ## Base URLs | Environment | URL | |-------------|-----| | Staging | `https://merchant-api.bipbip.dev` | | Production | `https://merchant-api.bipbip.hn` | Both environments require TLS on port 443. BipBip does not publish a fixed IP allowlist. If your security policy requires a static allowlist or private network path (e.g., AWS PrivateLink), contact `support@cit.hn`. ## REST API Authentication Headers All requests from the POS to BipBip must include: ``` X-Bipbip-Api-Key: ``` Mutation requests (POST, PUT, PATCH) additionally require: ``` Idempotency-Key: X-Bipbip-Schema-Version: 1.0 Content-Type: application/json ``` - `Idempotency-Key` must be a UUID v4, unique per logical attempt. Missing key returns 422. - `X-Bipbip-Schema-Version` must be `1.0`. Missing or unsupported value returns 400. - The contract publishes `/api/v1/Orders` with a capital `O` and `/api/v1/menu` in lowercase. Use the exact casing documented per endpoint. ## End-to-End Happy Path 1. **Onboarding**: BipBip team provisions an API key and configures your webhook base URL and per-store `remoteId` in the Merchant Portal. 2. **Order arrives** (BipBip calls your server): ``` POST {yourBaseUrl}/v1/order/{remoteId} ``` Your server validates the HMAC signature, deduplicates on `X-Bipbip-Delivery-Id`, persists the order, and responds: ```json { "remoteOrderId": "" } ``` BipBip stores `remoteOrderId` and uses it to route all subsequent updates to the right order. 3. **Accept the order** (your POS calls BipBip): ``` POST https://merchant-api.bipbip.hn/api/v1/Orders/{orderKey}/accept Body: {} ``` BipBip atomically transitions `pending → accepted → preparing`. Response returns `status: "Preparing"`. Do not call `PUT /status` with `preparing` after this — it will return 409. 4. **Advance status as food is prepared** (your POS calls BipBip): ``` PUT https://merchant-api.bipbip.hn/api/v1/Orders/{orderKey}/status Body: { "status": "ready" } ``` Then again with `"handed_over"` when the driver or customer picks up. 5. **Order updates arrive** (BipBip calls your server): ``` PUT {yourBaseUrl}/v1/order/{remoteId}/{remoteOrderId}/events ``` Body field `event` is one of `driver_assigned`, `driver_released`, `delivered`, or `cancelled`. Always respond `200 OK`. `event` is not the order status: only `cancelled` and `delivered` change it. Driver events run in parallel with steps 3–4, so `driver_assigned` can arrive while the order is still `preparing` — do not assume it comes after `ready`. If you would rather resolve delivery by asking instead of waiting for the event, `GET /api/v1/Orders/{orderKey}` and the order listing both report `deliveredAt`. `status` alone cannot answer it: an order in transit and a delivered one both read `HandedOver`. ## Menu Sync Independent of the order flow. Writes are asynchronous: they return `202 Accepted` with a `changeId`, and the outcome is checked with `GET /api/v1/menu/changes/{changeId}` or the `menu.change.completed.v1` webhook. - Day-to-day: `PUT /api/v1/menu/availability` and `PUT /api/v1/menu/prices` (store scope). - Targeted edits: `PATCH /api/v1/menu/products/{code}`, `/modifier-options/{code}`, `/categories/{code}`, `/modifiers/{code}`. - Full catalog: `POST /api/v1/menu/preview` (dry-run) then `PUT /api/v1/menu` (synchronous; **omitted items are deactivated**). - Reads: `GET /api/v1/menu/stores/{storeRemoteId}`, `GET /api/v1/menu/capabilities`. A `202` does not mean the change was applied — the real outcome is per store, in `results[].applied`. Query `GET /api/v1/menu/capabilities` before building on a field: only shipped `(entity, field, scope)` combinations are accepted; the rest return 422. ## Support Email: `support@cit.hn` --- # 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 | --- # BipBip Webhook Outbound API Direction: BipBip calls the merchant's POS server. The merchant exposes these endpoints. BipBip calls them. ## Transport Contract All parameters below are fixed and are not negotiable per merchant. | Parameter | Value | |-----------|-------| | Order creation path | `POST {merchantBaseUrl}/v1/order/{remoteId}` | | Order updates path | `PUT {merchantBaseUrl}/v1/order/{remoteId}/{remoteOrderId}/events` | | Menu change path | `POST {merchantBaseUrl}/v1/menu/change/{changeKey}` | | Body format | JSON | | Timeout per attempt | 15 seconds | | ACK for creation | HTTP 200 with `{ "remoteOrderId": "" }` — required | | ACK for order update | HTTP 200 — response body is ignored | | ACK for menu change | HTTP 200 — response body is ignored | | Versioning | In path (`/v1/`) — future versions can coexist in parallel | | Forward compatibility | Tolerate unknown `event` values and unknown top-level fields; respond 200 | Path parameters: - `remoteId`: Store identifier configured during onboarding. BipBip includes it in the URL so the POS can route to the correct store without parsing the body. - `remoteOrderId`: The POS's own order ID, returned in the `200 OK` ACK of the original creation webhook. BipBip stores it and uses it to build the updates URL. - `changeKey`: Change request identifier. Format: `chg_` + 16 base62 characters. ## Signed Headers BipBip sends these headers on webhook requests. The creation and menu-change webhooks carry all five; the order updates endpoint declares only four — `X-Bipbip-Event-Type` is not part of its contract: | Header | Value | |--------|-------| | `X-Bipbip-Signature-256` | HMAC-SHA256 in format `sha256=` | | `X-Bipbip-Timestamp` | Unix epoch seconds (integer) | | `X-Bipbip-Event-Type` | `order.created` for creation; `menu.change.completed.v1` for menu. The spec does **not** declare this header on the order updates endpoint — route on `body.event` there and do not require the header to be present | | `X-Bipbip-Delivery-Id` | UUID, unique per dispatch attempt. Same value across retries. Use for deduplication. | | `X-Bipbip-Schema-Version` | `1.0` | ## HMAC Signature Verification HMAC algorithm: SHA-256. Message to sign: `{timestamp}.{raw-request-body}`, where `{timestamp}` is the value of `X-Bipbip-Timestamp` and `{raw-request-body}` is the exact raw bytes of the HTTP body (do not re-serialize). Verification procedure: 1. Extract `timestamp` from `X-Bipbip-Timestamp`. 2. Reject the request if `abs(now - timestamp) > 300` seconds (5 minutes). This mitigates replay attacks. 3. Construct the signed message: `.`. 4. Compute `HMAC-SHA256(message, sharedSecret)`. 5. Strip the `sha256=` prefix from `X-Bipbip-Signature-256`. 6. Compare the computed digest against the stripped header value using a constant-time comparison function. Do not use string equality. ```javascript // Node.js reference implementation import crypto from 'node:crypto'; app.post('/v1/order/:remoteId', async (req, res) => { const sig = req.headers['x-bipbip-signature-256']; const ts = req.headers['x-bipbip-timestamp']; const raw = req.rawBody; // must be the raw buffer, not re-serialized const expected = 'sha256=' + crypto .createHmac('sha256', process.env.BIPBIP_SECRET) .update(`${ts}.${raw}`) .digest('hex'); if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { return res.status(401).end(); } const order = JSON.parse(raw); const remoteOrderId = await pos.createOrder(order); res.status(200).json({ remoteOrderId }); }); ``` ## Authentication Strategies (BipBip to Merchant) Configurable per endpoint in the Merchant Portal. Controls whether BipBip adds an additional authentication header beyond the HMAC signature. **Critical rule: all three strategies always include the HMAC signature. The merchant must always validate HMAC regardless of strategy. The strategy only controls whether an additional auth header is added — it never replaces the signature.** | Strategy | What BipBip adds | |----------|-----------------| | `hmac_only` (default) | Only HMAC signature headers. No `Authorization` header. | | `static_headers` | Arbitrary additional HTTP headers (e.g., `X-Api-Key`, `Authorization: Bearer `). Sensitive values are encrypted with AWS KMS and decrypted at dispatch time. Supports 24-hour dual-window rotation: during the rotation window BipBip signs with the new secret but the merchant can accept both. | | `oauth_client_credentials` | BipBip obtains a Bearer token via OAuth 2.0 Client Credentials and injects `Authorization: Bearer `. Token is cached with a 60-second expiry buffer. If the merchant returns 401, BipBip invalidates the cache and retries once with a fresh token. Supports `client_secret_post` and `client_secret_basic`, scopes, and extra parameters. Supports 24-hour dual-window rotation for `client_secret`. | ## Retry Policy BipBip uses at-least-once delivery semantics. Retries are re-queued without exponential backoff — the next attempt arrives within seconds, not minutes. | Trigger | Behavior | |---------|----------| | 5xx or network error | BipBip retries (up to `maxRetries`, default 3 total attempts, configurable per endpoint) | | Timeout > 15s | Treated as a transient failure; counts as one attempt consumed | | 4xx | Terminal — BipBip does not retry | | HTTP 200 without `remoteOrderId` (creation only) | Treated as a failed attempt; BipBip retries | Deduplicate by `X-Bipbip-Delivery-Id`. All retries of the same delivery carry the same UUID. **Exhaustion consequences**: - `order.created` exhaustion: BipBip cancels the order internally (notifies the customer). No update webhooks are sent for that order. - Order update (`PUT /events`) exhaustion: delivery is silently dropped. BipBip does not expose a separate failure event to the POS. - Menu change webhook exhaustion: delivery is marked failed internally. No menu changes are reverted or cancelled. ## Order Update Events All updates arrive at `PUT {merchantBaseUrl}/v1/order/{remoteId}/{remoteOrderId}/events`. Discriminate by `body.event`. **`event` is not the order status.** It says what happened, not what state the resource ended up in. Only `cancelled` and `delivered` have a counterpart in the order status. `driver_assigned` and `driver_released` belong to the driver cycle, which runs in parallel with the merchant's progress and does not modify it — an order can receive a `driver_assigned` while in `preparing` and stay in `preparing`. Do not use `event` to overwrite the internal order status outside those two cases. Read the resource state with `GET /api/v1/Orders/{orderKey}`; it never takes a driver value. Updates are only sent if the creation webhook for that order was successfully delivered (merchant returned HTTP 200). ### `cancelled` Order has been cancelled. Process: cancel the order in the POS. Modifies the order status. This event **can arrive even after `delivered`**: a refund or an incident reverses an order that was already delivered. Do not treat delivery as a final state that blocks a later cancellation — charge reconciliation depends on it. The optional field `reason` is informative. Current values: - `customer_requested`: customer cancelled from the BipBip app. - `operator_action`: BipBip back-office operator cancelled. - `acceptance_timeout`: POS did not respond to the order within the acceptance window. - `delivery_failed`: BipBip exhausted retries delivering the creation webhook. New reason values may be added without a schema version bump. Treat `reason` as informational only. ### `driver_assigned` A driver has been assigned to pick up the order. The `driver` object contains `fullName` (required) and optionally `phone`. **Does not modify the order status.** This event can arrive **more than once** per order lifecycle. Reassignment occurs when a BipBip operator manually reassigns a driver (e.g., due to an incident). The POS must always display the most recently received driver. Do not deduplicate `driver_assigned` by `orderKey + event` — doing so leaves the POS showing a driver who is no longer assigned. Deduplicate by `X-Bipbip-Delivery-Id` instead. The origin of assignment (automatic system, operator manual assignment, or driver self-assignment in self-service cities) does not appear in the webhook payload. The shape is identical in all cases. ### `driver_released` A BipBip operator has manually released the previously assigned driver. This is an **informative event only** — the order remains logically active and its status is unchanged. Do not roll the order back to a prior state. What to do when this event arrives: 1. Clear the driver display from the POS UI. 2. Wait for the next `driver_assigned` event with the replacement driver. Additional fields present in this event: - `releasedDriver`: identity of the released driver (same shape as `driver`: `fullName` required, `phone` optional). - `reason`: currently always `manual_release`. - `expectedNext`: hint for the next expected event, currently always `driver_reassignment`. ### `delivered` Order has been delivered to the customer. Mark the order as delivered in the POS. Modifies the order status. Note that a `cancelled` event can still arrive afterwards. The webhook is not the only way to learn this. The REST order carries `deliveredAt` (both in `GET /api/v1/Orders/{orderKey}` and in the listing), so a POS that prefers polling can resolve delivery without depending on this event. `status` alone cannot: an order in transit and a delivered one both read `HandedOver`. See rest-api.md. ## Order Lifecycle (for reference) Two tracks run in parallel. The status the merchant reports advances linearly: ``` pending → accepted → preparing → ready → handed_over ``` Driver assignment is **not a step inside that cycle** — it runs alongside it and does not modify it. BipBip starts looking for a driver while the order is still in `preparing`: ``` driver_assigned ↓ driver_released (informative, no rollback) ↓ driver_assigned (new driver — reassignment) ↓ delivered ``` The `driver_assigned` → `driver_released` → `driver_assigned` sequence can repeat multiple times before final delivery. Practical consequence: `driver_assigned` can arrive at any point before `handed_over`, even before the merchant reports `ready`. The POS must not assume a fixed order between `ready` and `driver_assigned`. ## Menu Change Webhook BipBip calls `POST {merchantBaseUrl}/v1/menu/change/{changeKey}` after finishing application of a menu change. Event type: `X-Bipbip-Event-Type: menu.change.completed.v1`. This webhook fires in two cases: 1. The merchant requested the change via the REST API (`/api/v1/menu/...`). The `changeKey` matches the `changeId` returned in the original `202 Accepted` response. 2. The restaurant changed availability directly (not via the integration API). BipBip mints the `changeKey` automatically. There is no prior `202 Accepted` to correlate against. The payload and handling procedure are identical in both cases. This is a **result notification with no side effects**. Retry exhaustion marks the delivery as failed internally but does not revert, cancel, or modify any menu change or order. Applied changes remain applied. The merchant can query the change status via `GET /api/v1/menu/changes/{changeKey}`. Handling procedure: 1. Validate the HMAC signature. 2. Deduplicate by `X-Bipbip-Delivery-Id`. 3. Reconcile `results` per store with internal POS state (optional, informational only). 4. Respond `200 OK`. Note on `modifierOption` store-level overrides: when a change confirms `price` or `name` on a `modifierOption` at the store level with `applied: true`, BipBip has persisted the override — but the BipBip customer app does not yet reflect it to end customers (deferred integration in `BipBipAPICliente`). The REST API marks these feature combinations with `pendingCustomerRollout: true` in `GET /api/v1/menu/capabilities`. For schema details, see `schemas.md`. --- # BipBip API — JSON Payload Schemas Schema version: `1.0` (carried in `X-Bipbip-Schema-Version`). BipBip may add new optional fields and new enum values without incrementing the schema version. Implementations must tolerate unknown fields and unknown enum values gracefully (respond 200 and ignore). --- ## Order Payload delivered by BipBip to the POS in `POST /v1/order/{remoteId}` (`X-Bipbip-Event-Type: order.created`). Designed under a data-minimization principle: only fields the POS needs to cook, bill, and collect are included. BipBip internal IDs (storeId, customerId) are intentionally absent. | Field | Type | Required | Description | |-------|------|----------|-------------| | `orderKey` | string | yes | Opaque BipBip order identifier. Format: `ord_` + 16 base62 characters. Use this value in all REST API calls to accept, reject, or advance the order. | | `displayCode` | string | yes | Human-readable order code shown to the customer in the BipBip app (e.g., `"355156479"`). Useful for support references. Not globally unique — combine with `storeRemoteId` if global uniqueness is required. | | `storeRemoteId` | string | yes | Store identifier configured during onboarding. Same value as `{remoteId}` in the webhook URL path. Included in the body as a fallback if logs truncate the URL. | | `currency` | string | yes | ISO 4217 currency code (e.g., `"HNL"`). | | `createdAt` | ISO 8601 | yes | UTC timestamp of when the order was created in BipBip. | | `expiresAt` | ISO 8601 | yes | Acceptance deadline. If the order is not accepted before this timestamp, BipBip auto-cancels it and sends a cancellation webhook. | | `fulfillment` | Fulfillment | yes | Fulfillment type and timing. See Fulfillment schema below. | | `customer` | Customer \| null | no | Customer info. Populated only for `pickup` orders so staff can call the customer to the counter. `null` for `delivery` orders. | | `payment` | Payment | yes | Payment methods and amount to collect at handover. See Payment schema below. | | `summary` | Summary | yes | Aggregated totals for quick reconciliation. See Summary schema below. | | `charges` | Charge[] | yes | Itemized additional charges (delivery fee, express fee, etc.). Empty array if none. | | `discounts` | Discount[] | yes | Applied discounts. Empty array if none. | | `items` | Item[] | yes | Order line items. At least one item is always present. | | `customerNote` | string \| null | no | Free-text note from the customer for the entire order (e.g., "leave at reception"). `null` if absent. | | `invoice` | Invoice \| null | no | Tax credit data. Populated when the customer requested a fiscal invoice with their tax ID. `null` for consumer-final orders (majority case). | --- ## Fulfillment Nested inside `Order.fulfillment`. | Field | Type | Required | Description | |-------|------|----------|-------------| | `type` | enum | yes | `delivery` (BipBip driver picks up and delivers) or `pickup` (customer comes to the store). | | `isExpress` | boolean | yes | `true` if the customer paid for express delivery — prioritize in kitchen. Always `false` for `pickup` orders. | | `prepareBy` | ISO 8601 | yes | UTC timestamp by which the order must be ready. Kitchen schedules against this value. | | `driverPickupAt` | ISO 8601 \| null | no | UTC timestamp of when the BipBip driver is expected to arrive at the store. Required when `type=delivery`; `null` for `pickup`. | | `customerPickupAt` | ISO 8601 \| null | no | UTC timestamp of when the customer expects to arrive at the store. Required when `type=pickup`; `null` for `delivery`. | --- ## Customer Nested inside `Order.customer`. Only present for `pickup` orders. | Field | Type | Required | Description | |-------|------|----------|-------------| | `firstName` | string | yes | Customer's first name. Used by staff to call the customer to the counter ("Order for Ana"). Last name, phone, and email are intentionally excluded (data minimization). | --- ## Payment Nested inside `Order.payment`. Payment semantics: `cash` is collected by the merchant at handover. `card` and `bips` are pre-paid through the BipBip app — do not collect them again. | Field | Type | Required | Description | |-------|------|----------|-------------| | `methods` | PaymentMethod[] | yes | List of payment methods applied to the order. The sum of all `amount` values equals `summary.grandTotal`. At least one method is always present. | | `amountToCollect` | decimal | yes | Amount the merchant must collect from the customer at handover. Equals the sum of `methods[].amount` where `type = "cash"`. `0.00` if the order is fully pre-paid. | | `changeFor` | decimal \| null | no | Denomination of the bill the customer plans to use for cash payment — for change preparation. Present only when at least one method is `cash`; `null` for fully pre-paid orders. | ### PaymentMethod Nested inside `Payment.methods[]`. | Field | Type | Required | Description | |-------|------|----------|-------------| | `type` | enum | yes | `cash` (collect at handover), `card` (pre-paid via BipBip app), or `bips` (pre-paid via BipBip loyalty credits). | | `amount` | decimal | yes | Amount applied with this payment method. | --- ## Summary Nested inside `Order.summary`. All values are positive decimals. Formula: `grandTotal = subtotal + taxes - discounts + additionalCharges` | Field | Type | Required | Description | |-------|------|----------|-------------| | `subtotal` | decimal | yes | Sum of `unitPrice * quantity` across all items, before taxes. This is the merchant's revenue line. | | `taxes` | decimal | yes | Sum of `tax * quantity` across all items. | | `discounts` | decimal | yes | Sum of `discounts[].amount` (positive value). | | `additionalCharges` | decimal | yes | Sum of `charges[].amount`. | | `grandTotal` | decimal | yes | Total paid by the customer. Equals the sum of `payment.methods[].amount`. | --- ## Charge Elements of `Order.charges[]`. Represents additional charges added to the customer's total. | Field | Type | Required | Description | |-------|------|----------|-------------| | `code` | enum | yes | Charge type. Closed enum: `delivery_fee`, `express_fee`, `service_fee`, `driver_tip`, `small_order_fee`. New codes will be added only with a major schema bump and advance notice. | | `amount` | decimal | yes | Charge amount (positive value). | | `billedBy` | enum | yes | `bipbip` (billed by BipBip, not merchant revenue) or `merchant` (merchant revenue). | --- ## Discount Elements of `Order.discounts[]`. Represents discounts applied to the order. | Field | Type | Required | Description | |-------|------|----------|-------------| | `code` | string | yes | Discount identifier. In v1.0, BipBip emits a single generic entry with `code="GENERIC"` when `discounts > 0`. Future iterations will break this out into coupon codes, loyalty levels, etc. | | `name` | string | yes | Human-readable discount label. | | `amount` | decimal | yes | Discount amount (positive value). | | `fundedBy` | enum | yes | `bipbip` (BipBip funds the promotion; merchant receives full price) or `merchant` (merchant funds the promotion; impacts merchant revenue). | --- ## Invoice Nested inside `Order.invoice`. Only present when the customer requested a fiscal invoice (tax credit). | Field | Type | Required | Description | |-------|------|----------|-------------| | `taxId` | string | yes | Customer's RTN (Honduras tax ID). | | `businessName` | string | yes | Customer's business legal name. | When the `invoice` object is present, both `taxId` and `businessName` are always populated. --- ## Item Elements of `Order.items[]`. | Field | Type | Required | Description | |-------|------|----------|-------------| | `code` | string | yes | Product code in the BipBip catalog. Stable opaque identifier. May be empty if the product was registered without a code — treat an empty value as a data quality signal. | | `remoteCode` | string | yes | Product code in the POS catalog, as configured by the brand in BipBip. Falls back to `code` (BipBip catalog) if no `remoteCode` was configured for the product. | | `name` | string | yes | Visible product name. | | `quantity` | integer | yes | Ordered quantity (minimum 1). | | `unitPrice` | decimal | yes | Unit price before taxes. | | `tax` | decimal | yes | Tax amount per unit of the item. | | `lineTotal` | decimal | yes | Line total: `(unitPrice + tax) * quantity` plus modifier option contributions. Provided for quick reconciliation; the POS can recalculate it locally. | | `note` | string \| null | no | Customer instruction for this item (e.g., "no onion"). `null` if no note. | | `modifierGroups` | ItemModifierGroup[] | yes | Modifier groups applied to this item. Empty array if no modifiers were selected. | --- ## ItemModifierGroup Elements of `Item.modifierGroups[]`. | Field | Type | Required | Description | |-------|------|----------|-------------| | `code` | string | yes | Modifier group code in the BipBip catalog. | | `name` | string | yes | Visible group name (e.g., `"Extras"`). | | `options` | ModifierOption[] | yes | Selected options within this group. At least one option is always present. | ### ModifierOption Elements of `ItemModifierGroup.options[]`. | Field | Type | Required | Description | |-------|------|----------|-------------| | `code` | string | yes | Option code in the BipBip catalog. | | `remoteCode` | string | yes | Option code in the POS catalog. Falls back to `code` if no `remoteCode` was configured. | | `name` | string | yes | Visible option name. | | `quantity` | integer | yes | Number of units of this option selected (minimum 1). | | `unitPrice` | decimal | yes | Unit price of the option. `0.00` if the option is included in the base item price (no-cost upgrade). | --- ## OrderAck The response body the POS must return to BipBip in the `200 OK` of the creation webhook. ```json { "remoteOrderId": "POS-2026-04-11-00142" } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `remoteOrderId` | string | yes | The POS's internal order ID for this order. BipBip stores this and uses it to build the URL for all subsequent update webhooks: `PUT /v1/order/{remoteId}/{remoteOrderId}/events`. A `200 OK` response without this field is treated as a failed delivery and will be retried. | --- ## OrderUpdateEvent Delivered by BipBip to `PUT /v1/order/{remoteId}/{remoteOrderId}/events`. `event` describes what happened, not the state of the resource. Only `cancelled` and `delivered` have a counterpart in the order status; `driver_assigned` and `driver_released` belong to the driver cycle and do not modify it. | Field | Type | Required | Description | |-------|------|----------|-------------| | `orderKey` | string | yes | BipBip order identifier (`ord_` + 16 base62). Can be used to call the REST API if needed. | | `remoteOrderId` | string | yes | POS internal order ID (same value from the original `OrderAck`). Included for convenience; the path parameter is authoritative. | | `event` | string | yes | Event discriminator. Current values: `cancelled` (modifies the status), `driver_assigned` (does not), `driver_released` (does not), `delivered` (modifies the status). New values may be added without a schema version bump — respond 200 and ignore unknown values. | | `occurredAt` | ISO 8601 | yes | UTC timestamp of when the update occurred. | | `reason` | string \| null | no | Informative label contextualizing the update. Present when `event=cancelled` or `event=driver_released`; absent otherwise. See webhooks.md for known values. | | `driver` | OrderEventDriver \| null | no | Present and populated when `event=driver_assigned`; absent for all other events. | | `releasedDriver` | OrderEventDriver \| null | no | Present and populated when `event=driver_released`; absent for all other events. Same type as `driver`. | | `expectedNext` | string \| null | no | Hint for the next expected event. Present when `event=driver_released`. Current value: `driver_reassignment`. Tolerate unknown values. | ### OrderEventDriver Shape used for both `driver` (in `driver_assigned` events) and `releasedDriver` (in `driver_released` events). | Field | Type | Required | Description | |-------|------|----------|-------------| | `fullName` | string | yes | Driver's full name as registered in BipBip. | | `phone` | string \| null | no | Driver's phone number in international format. Absent or `null` if the driver has not registered a number or has marked it private. | --- ## MenuChangeCompletedWebhook Delivered by BipBip to `POST /v1/menu/change/{changeKey}`. | Field | Type | Required | Description | |-------|------|----------|-------------| | `changeKey` | string | yes | Opaque change request identifier. Format: `chg_` + 16 base62 characters. Matches the path parameter and the `changeId` from the original `202 Accepted` if the change was submitted via REST. | | `status` | enum | yes | Aggregate result: `applied` (all stores succeeded), `partial` (some stores failed), or `failed` (all stores failed). | | `submittedAt` | ISO 8601 | yes | UTC timestamp of when the change request was submitted. | | `completedAt` | ISO 8601 | yes | UTC timestamp of when BipBip finished processing the change. | | `results` | MenuChangeResult[] | yes | Per-store, per-entity results. One entry per (store, entity) pair attempted. | --- ## MenuChangeResult Elements of `MenuChangeCompletedWebhook.results[]`. | Field | Type | Required | Description | |-------|------|----------|-------------| | `storeRemoteId` | string | yes | Store identifier where the change was attempted. Same value as configured in the BipBip back-office. | | `targetEntity` | string | yes | Type of menu entity affected. Values in use: `product`, `modifierOption`, `localProduct`. | | `targetCode` | string | yes | Code of the affected entity in the catalog. | | `applied` | boolean | yes | `true` if the change was successfully applied at this store; `false` if it failed. | | `errorCode` | string \| null | no | Machine-readable error code. Present only when `applied=false`. **Omitted from JSON when `applied=true`** (not serialized, not `null`). | | `errorMessage` | string \| null | no | Human-readable error description. Present only when `applied=false`. **Omitted from JSON when `applied=true`** (not serialized, not `null`). | When `applied=true`, neither `errorCode` nor `errorMessage` appears in the JSON object at all. Do not assume their presence. --- ## MerchantErrorResponse Suggested shape for error responses from the merchant's webhook endpoints to BipBip. BipBip only uses the HTTP status code to determine retry behavior — the body is informative. Any JSON shape or an empty body is acceptable; this schema is a recommendation for consistency. | Field | Type | Required | Description | |-------|------|----------|-------------| | `error` | string | no | Short machine-readable error code (e.g., `"invalid_payload"`). | | `message` | string | no | Human-readable error description for debugging. | | `timestamp` | ISO 8601 | no | When the error was generated on the merchant's server. | --- # Changelog Every entry states whether the change requires an action on your side. The contract stays on version `1.0`; breaking changes are announced in advance and are never applied silently. Every entry is classified by change level: - **Breaking** — Requires a code change. - **Behavior** — The shape of the contract did not change, but its meaning did; review your POS assumptions. - **Additive** — Something was added; your current integration keeps working unchanged. ## August 12, 2026 ### [Breaking] The update webhook’s discriminator is renamed to `event` The unified envelope of the update webhooks called its discriminator `status`, but only two of its four values are order statuses: `cancelled` and `delivered`. `driver_assigned` and `driver_released` belong to the driver axis and never move the order, so a merchant reading `status: "driver_assigned"` was invited to overwrite its own status with a driver event. The field is now called `event` and the route moves from `/status` to `/events`, so the path stops promising a status the body does not carry. The accepted values do not change: `cancelled`, `driver_assigned`, `delivered` and `driver_released`. The set stays extensible — an unrecognized `event` value must be ignored, and adding a new one is not a breaking change. Before: ```http PUT {baseUrl}/v1/order/{remoteId}/{remoteOrderId}/status { "orderKey": "ord_oOR7xSbWz0QksS2I", "status": "driver_assigned", ... } ``` After: ```http PUT {baseUrl}/v1/order/{remoteId}/{remoteOrderId}/events { "orderKey": "ord_oOR7xSbWz0QksS2I", "event": "driver_assigned", ... } ``` **Action required.** Change the receiver’s route to `/events` and read the `event` field instead of `status`. There is no transition window and no duplicated field: the previous route stops being emitted immediately. ### [Behavior] `handedOverAt` stays `null` when the merchant never reported the handover If the merchant does not declare the handover and BipBip closes the order once delivery is confirmed, `handedOverAt` used to store the instant of the **delivery to the customer**. The column then asserted a moment nobody observed: read back, an order where the merchant stayed silent looked as if it had been handed to the driver exactly when the customer received it. That fact now lives in `deliveredAt`, so `handedOverAt` can say the honest thing and stay `null`. An order closed that way reads as `handedOverAt: null` with `deliveredAt` populated. It is not a hole in the data: it is the difference between “we do not know” and “it happened at this time”. **Review.** If your POS assumes an order in `handed_over` always carries `handedOverAt`, account for the `null`. An order can be in a terminal state without the handover moment being known. ### [Additive] `deliveredAt` states when the customer received the order `handed_over` answers “is the merchant done?” and cannot answer “does the customer have it?”. On a `delivery` order those two moments are separated by the driver’s trip: the merchant releases the food to the driver, and the driver reaches the customer later. `deliveredAt` answers the second one, both in the detail and in the listing, next to `driverAssignedAt`. On `pickup` the customer collects at the store, so the merchant’s handover **is** the delivery and the value is derived from the fulfillment type. Both channels get a value, so on an order created from August 12, 2026 onwards `null` means “there is no confirmed delivery yet” and never “this channel cannot have one”. Orders predating that date return `null` permanently, even if they were delivered. The field shipped without backfilling the earlier rows, because that moment had never been recorded anywhere: there was no value to copy. **Review.** New field in `GET /orders` and in `GET /orders/{orderKey}`. If you reconcile a period spanning August 12, 2026, do not read `null` as “not delivered” on orders predating that date: for those, `handed_over` remains the only sign of closure. ## August 11, 2026 ### [Breaking] The driver is no longer an order status The `driver_assigned` status was removed from the state machine. An order never reports that value in `status`, and `GET /orders` no longer accepts it as a filter. Driver assignment runs in parallel to the merchant’s progress and is now exposed in the `driverAssignedAt` field. **Action required.** If your POS filtered by `status=driver_assigned` or compared against that value, read `driverAssignedAt` instead. In exchange, it is now possible to mark `ready` with a driver already assigned, which was previously rejected with `409`. ### [Behavior] A handed-over order can still be cancelled An order in `handed_over` can move to `cancelled`: a refund or an incident after handover closes it in that state and the merchant receives the corresponding webhook. Previously those cancellations were not notified. **Review.** If your POS assumed `handed_over` was an absolute final state, account for receiving an `event: "cancelled"` webhook afterwards. ### [Behavior] The customer’s cancellation reason travels as text On cancellations originated by the customer, `history[].reason` carries the reason as text. It previously carried only its numeric identifier, which meant nothing outside BipBip: the merchant received the cancellation with no way to say why it happened. If the customer writes a comment, that comment is what travels; if they do not, the name of the reason they picked travels instead. Orders cancelled before this change keep the numeric identifier. **No action.** `reason` is descriptive text meant for humans and its content is not stable. Do not parse it or derive logic from it. ### [Behavior] `GET /orders` sorts by order number The previous criterion was the moment BipBip registered the order, which can differ from the real creation order and place an older order above a more recent one. The pagination cursor is now that same number. **No action.** Cursor pagination keeps working identically. ### [Behavior] An order can move to `ready` without the merchant asking The `ready` status stops depending exclusively on `PUT /orders/{orderKey}/status`. The driver marks the order as ready from their own app, and that record advances it too. As a result, a `PUT` with `status: "ready"` can answer `409` on an order the merchant had not advanced yet. That 409 does not describe a conflict: it describes a state already reached, and `meta` says so outright. 409 response: ```http PUT {baseUrl}/api/v1/Orders/{orderKey}/status → 409 { "status": 409, "meta": { "currentStatus": "Ready", "allowedTransitions": ["HandedOver"] } } ``` **Review.** If your POS assumes only it moves the order to `ready`, treat a 409 with `meta.currentStatus: "Ready"` as a state already reached and continue with `handed_over`, instead of considering it an error. ### [Additive] The listing states whether the order has a driver Every item in `GET /orders` includes `driverAssignedAt`, the moment a driver was assigned to the order, or `null` if it does not have one yet. With `status` alone it was not possible to tell a ready order waiting for a driver from a ready order with a driver on the way. **No action.** New field in the response. ### [Additive] The driver is identified by their code The `history[]` items concerning the driver now carry the `driver` object with a `code` field (for example `BIP-01424`), the identifier to use with BipBip support. The `driver_assigned`, `driver_reassigned` and `driver_released` events are distinguished through `driver.event`. When the driver has no code on file, `code` reports their numeric identifier instead. The field is always a string and its format is not stable, so it must not be validated against the `BIP-#####` pattern. **No action.** New field inside `history[]`. Treat it as opaque: it is a label, not a number. ## August 10, 2026 ### [Breaking] The menu is read with the same names it is written with On menu reads, `imageUrl` is now called `image` and `modifiers` is now called `modifierGroups` — the names the menu upload already used. With two names for the same concept, reusing the write model to deserialize the read left the fields as `null` without raising any error. **Action required.** Rename both fields in the `GET /menu/stores/{storeRemoteId}` deserializer. `image` returns the image re-hosted by BipBip in WebP, not the original URL that was sent. ### [Behavior] Local products receive a canonical code When a local product is created, BipBip derives its own internal code and preserves the merchant’s code in `remoteCode`. Previously the submitted code was used as the internal code and the merchant’s code was lost. Later calls accept either of the two codes, so the merchant can keep addressing the product with its own. **No action.** Local products created earlier keep their original code and keep resolving unchanged. ### [Additive] Local product creation accepts tax, limit and image `POST /menu/stores/{storeRemoteId}/products` accepts `tax`, `maxPerOrder`, `image` and `channels`. The accepted values for each field are published in `GET /menu/capabilities`. **No action.** Optional fields; omitting them preserves the previous defaults. ### [Additive] The order detail returns its full contents `GET /orders/{orderKey}` now carries the `order` object with the order’s commercial content: products, modifiers, payment methods, charges, discounts and billing data. Its structure is the same as the `order.created` webhook, so it can be read with the model already implemented for that webhook. **Optional, recommended.** Allows recovering an order whose webhook could not be processed, without depending on a redelivery. ## August 8, 2026 ### [Additive] The menu exposes per-store restrictions Every product and every modifier option now carries `orderable`, `resumesAt` and the `overrides[]` array, ordered by precedence: the first element determines the item’s current state. Overrides that change a value include `base`, the master-catalog value the item returns to if the restriction is lifted. `resumesAt` states when the restriction expires on its own; its absence means it stays in force until it is explicitly lifted. **No action.** Restricted items stay in the response instead of being omitted, which makes it possible to inspect them and lift the restrictions applied.