# REST API v1 — for connectors

Base: `{base_url}`, which already includes `/api/v1`. JSON in UTF-8. Everything is scoped to the store that owns the key.

| Environment | `{base_url}` |
|---|---|
| Public server (VPS) | `https://retail-s.online/api/v1` |
| Local | `http://127.0.0.1:8000/api/v1` |

The paths in this document are appended to that base: `GET /products/` is `GET {base_url}/products/`. Plain http requests to the
public server receive a 301 to https.

Online documentation, OpenAPI 3.1, Postman collection and AI prompts: `/docs/?lang=en` on the platform server. The full contract
(paths, parameters, bodies, permissions) is in [`openapi.en.json`](openapi.en.json) and the collection in
[`postman/`](postman/); this page has the business rules of each resource.

> **Error messages are in Spanish.** The text of error messages (`detail`, validation messages, order event texts) is currently
> returned in Spanish. Rely on the HTTP status code and on field names, never on the message text. The examples below show the
> messages as the API returns them, with a translation in parentheses where it helps.

> **Local environment with its own CA (test server on the LAN only).** The home server uses a certificate from a homemade CA:
> the connector must trust `casa-ca.crt` (with curl, `--cacert casa-ca.crt`). It does not apply to the public server.

## Authentication

Generate a key in the back office (**Settings → API keys**) and send it like this:

```
Authorization: Bearer rk_1a2b3c4d_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
```

- No key, or an invalid or deactivated key: `401`.
- A back office session does **not** work for the API.

`GET /api/v1/` returns the store, the key name and its `scopes` (useful as a connection test).

### Permissions (scopes)

Each key has per-area permissions, chosen when it is created: no access, read, or read and write.
A `GET` request requires `read_<area>`; `POST`, `PUT`, `PATCH` and `DELETE` require `write_<area>`. Write includes read.
Without the permission: `403` with `{"detail": "La llave no tiene el permiso «write_orders»."}` (“The key does not have the «write_orders» permission.”).

| Area | Scopes | Resources |
|---|---|---|
| Catalog | `read_products`, `write_products` | products, variants, images, categories, brands, collections, attributes, variant templates, translations |
| Inventory | `read_inventory`, `write_inventory` | inventory, locations |
| Prices | `read_prices`, `write_prices` | prices, price lists |
| Customers | `read_customers`, `write_customers` | customers, customer groups |
| Orders | `read_orders`, `write_orders` | orders, status changes, returns, refunds |
| Integrations | `read_integrations`, `write_integrations` | connections, external references |

Keys created before 15 Oct 2026 have all permissions.

## Conventions

- Records are identified by **code** (as in BC): categories, attributes, locations,
  price lists and products. Variants are identified by **SKU**.
- Codes are stored in uppercase: `ply-001` and `PLY-001` are the same product.
- Anything without a code (customers, orders and their lines and returns, images, connections and external
  references) is identified by its **public `id`**: a UUID (v7, time-ordered) such as
  `"0192f3a4-6b1c-7d2e-9f00-1a2b3c4d5e6f"`. The API never exposes or accepts internal (numeric) IDs.
  Store the `id` as a string.
- **`PUT /<resource>/<code>/` is an upsert:** it creates the record (`201`) or replaces it (`200`).
  `PATCH` updates only the fields sent and returns `404` if it does not exist.
- Amounts and quantities travel as decimal strings (`"199.0000"`) so no precision is lost.
- Lists are paginated **by cursor**: `?limit=100` (1 to 500; default 100). Response:
  `{"next": url, "results": [...]}`. For the next page, request the `next` URL as is (it carries an opaque
  `cursor`); on the last page `next` is `null`. Records come out in creation order, so a record created
  while you are reading shows up at the end and none is repeated or skipped. There is no `count` or `previous`, and `offset`
  returns `400`.
- Incremental sync: `?updated_since=2026-10-14T00:00:00Z` on the code-based lists.
- Validation errors: `400` with details per field.

## Resources

| Resource | Paths |
|---|---|
| Variant templates | `GET/POST /variant-templates/` · `GET/PUT/PATCH/DELETE /variant-templates/{code}/` |
| Brands | `GET/POST /brands/` · `GET/PUT/PATCH/DELETE /brands/{code}/` |
| Collections | `GET/POST /collections/` · `GET/PUT/PATCH/DELETE /collections/{code}/` (`products`: list of codes, declarative; `is_automatic` and `rules` are read only: rules are edited in the back office) |
| Categories | `GET/POST /categories/` · `GET/PUT/PATCH/DELETE /categories/{code}/` |
| Modifier groups | `GET/POST /modifier-groups/` · `GET/PUT/PATCH/DELETE /modifier-groups/{code}/` |
| Attributes | `GET/POST /attributes/` · `GET/PUT/PATCH/DELETE /attributes/{code}/` |
| Locations | `GET/POST /locations/` · `GET/PUT/PATCH/DELETE /locations/{code}/` |
| Price lists | `GET/POST /price-lists/` · `GET/PUT/PATCH/DELETE /price-lists/{code}/` |
| Customer groups | `GET/POST /customer-groups/` · `GET/PUT/PATCH/DELETE /customer-groups/{code}/` |
| Customers | `GET/POST /customers/` · `GET/PUT/PATCH/DELETE /customers/{id}/` |
| Carriers | `GET /carriers/` · `GET /carriers/{code}/` (read only, with their services; permission `read_orders`) |
| Payment methods | `GET /payment-methods/` · `GET /payment-methods/{code}/` (read only; permission `read_orders`) |
| Orders | `GET/POST /orders/` · `GET /orders/{id}/` · `POST /orders/{id}/{pay,prepare,ship,deliver,cancel,returns,refunds}/` |
| Products | `GET/POST /products/` · `GET/PUT/PATCH/DELETE /products/{code}/` |
| Catalog import | `POST /products/import/` (NDJSON file) · `GET /products/import/{id}/` · `GET /products/import/{id}/rows/` |
| Duplicate product | `POST /products/{code}/duplicate/` `{"code", "name", "copy_images", "copy_prices"}` |
| Images | `POST /products/{code}/images/` · `DELETE /products/{code}/images/?external_key=` · `DELETE /products/{code}/images/{id}/` |
| Variants | `GET /variants/` · `GET/PATCH/DELETE /variants/{sku}/` |
| Prices | `GET /prices/` · `POST /prices/bulk/` (`?partial=true`) |
| Inventory | `GET /inventory/` · `POST /inventory/bulk/` (`?partial=true`) |
| Connections | `GET /connections/` · `GET /connections/{id}/` (read only) · `GET/POST /connections/{id}/sync-runs/` (sync runs) |
| Translations | `GET /translations/?object_type=product&object_key=CODE[&language=en]` · `PUT /translations/` `{"object_type", "object_key", "language", "fields": {...}}` |
| External references | `GET/POST /external-references/` · `DELETE /external-references/{id}/` |

### Categories

```json
PUT /categories/TSHIRTS/
{"name": "T-shirts", "parent": "CLOTHING", "is_active": true}
```

Fields: `name`, `parent` (code or `null`), `slug` (generated if missing), `description`,
`position`, `is_active`.

### Attributes

```json
PUT /attributes/MATERIAL/
{"name": "Material", "type": "option", "options": ["Cotton", "Polyester"], "is_filterable": true}
```

`type`: `text`, `integer`, `decimal`, `date`, `boolean`, `option` (the same as in BC).
`options` leaves the list exactly as sent: if you remove a value, it is deleted from the products.
The `type` cannot be changed if any product or variant already has a value.

`scope` (#78): `product` (default) or `variant`. A product attribute carries one value per product and is sent in the
product's `attributes`; a variant attribute carries one value per variant (season, weight…) and is sent in the
variant's `attributes`. Each code is only accepted at its own level (otherwise `400`). The `scope` cannot be changed
once it has values.

### Modifiers

```json
PUT /modifier-groups/EXTRAS/
{"name": "Extras", "min_select": 0, "max_select": 2,
 "modifiers": [{"code": "CHEESE", "name": "Cheese", "price": "0.75"}, {"code": "BACON", "name": "Bacon", "price": "1.25"}]}
```

Options chosen at purchase time that are not variants (“Extras”, “Meat doneness”, “Engraving”). A `min_select` of 0 is
optional; a `max_select` of `null` means no limit. `modifiers` is declarative (the group ends up with exactly those, by code); modifier
codes are unique in the store. `price` is the extra price, with the same tax convention as the
store. On the product, `modifier_groups` is the list of group codes, in order (declarative). In `POST /orders/`,
each line accepts `"modifiers": ["CHEESE", ...]`; they are validated against the product's groups (minimum, maximum and that they
belong to it) and each one goes in as a child line with `parent_line`. External references: `object_type` `modifier_group` and
`modifier` (key: the code).

### Locations, type and hours

- `kind`: `store` (default) or `warehouse`.
- `allows_pickup`: pickup orders (`pickup`, `takeout`, `dine_in`) are fulfilled from this location.
- `ships_orders`: shipping orders (`shipping`, `delivery_app`) are fulfilled from this location.
  Without `location` in `POST /orders/`, the first active location with the flag for the order type that can cover
  everything is chosen (if none can, the first one with the flag; if there is none, `400`). An explicit `location` is respected.
- `fulfills_online_orders` is kept for compatibility and is the union (`allows_pickup` or `ships_orders`): it is what the
  storefront uses (visible stock, hours). A connector that only sends `fulfills_online_orders` sets both
  flags to that value; if it sends either flag, `fulfills_online_orders` is recomputed and the one sent is ignored.
  Earlier locations were migrated with both flags equal to `fulfills_online_orders`.

`hours` (optional, declarative) is the location's opening hours:
`[{"weekday": 0, "opens": "11:00", "closes": "15:00"}, {"weekday": 0, "opens": "18:00", "closes": "02:00"}]`
(`weekday` 0 = Monday … 6 = Sunday, store local time; if `closes` is earlier than `opens`, it crosses midnight).
Without ranges, the location has no hours (always open). If the store has “do not accept orders outside
opening hours” enabled and no location that fulfills online orders is open, `POST /orders/` returns `400` with a message saying
when it opens again (back office orders are accepted).

### Price lists by group

`customer_groups` (list of codes) limits the list to customers of those groups. Without groups,
it applies to everyone. The default list cannot have groups.

### Prices and taxes

The price the API returns is the one entered in each list, with its `prices_include_tax` flag (whether that
price already includes tax). Conversion to the store's convention (with or without tax) is done by
the online store and also by order creation; the API does not convert. The default list's flag
follows the store configuration: the API does not allow changing it on that list.

### Customers

```json
POST /customers/
{
  "code": "C-100",
  "first_name": "Carlos", "last_name": "Hernández", "company": "Bajío Uniforms",
  "email": "purchasing@example.com", "phone": "477 555 0101", "tax_id": "UBA010101AB1",
  "group": "WHOLESALE", "accepts_marketing": false, "notes": "",
  "addresses": [
    {"label": "Headquarters", "line1": "Blvd. López Mateos 3401", "line2": "Col. Jardines del Moral",
     "city": "León", "state": "Guanajuato", "postal_code": "37160", "country": "MX"}
  ]
}
```

- They are identified by `id` (UUID). `code` and `email` are optional, but unique in the store if used.
  To search: `GET /customers/?email=...`, `?code=...`, `?group=WHOLESALE`, `?q=text`,
  `?updated_since=...`.
- `addresses` is declarative: if you send it, it replaces all addresses; if not, they are left alone.
  The first one becomes the default for shipping and billing, unless you mark another with
  `is_default_shipping` / `is_default_billing` (only one of each).
- To map with another system: external references with `object_type` `customer` (key: `code`)
  or `customer_group`.

### Orders

```json
POST /orders/
{
  "customer_email": "purchasing@example.com",
  "lines": [
    {"sku": "PLY-002-BLACK-M", "quantity": 6},
    {"sku": "COC-002", "quantity": 1, "unit_price": "14.50"},
    {"sku": "COC-001-BLACK", "quantity": 2, "unit": "BOX6"}
  ],
  "shipping_address": {"line1": "Blvd. Los Próceres 12", "city": "Antiguo Cuscatlán",
                       "state": "La Libertad", "country": "SV"},
  "location": "DC",
  "discount": "0", "shipping": "8.00", "notes": ""
}
```

- `delivery_type`: `shipping` (default), `pickup` (the customer collects it; no shipping address) and, if the
  store has them enabled, `takeout` and `dine_in` (at the table, with `table_number`). A type the store
  does not accept returns `400`.
  `carrier_code` and `carrier_service_code` (optional, only on `shipping` orders): the store's carrier and service
  (`GET /carriers/`); a code that does not exist returns `400`. They are a copy for the ERP: **`shipping` must already include the
  service charge** (the API does not add it); the online store does add it to shipping and stores it in `carrier_fee`.
  `payment_method` (optional): `code` of one of the store's payment methods (`GET /payment-methods/`); one that does not exist
  in the store returns `400`. If the method has reservation hours, the order is created with `reservation_expires_at`.
- The customer is given with `customer` (their `id`) or `customer_email`; without a customer it is a guest sale
  (`customer_name`, `email`, `phone` optional). Without `shipping_address` the customer's default is used;
  without `billing_address`, the shipping one. Without `location`, the first one that fulfills that order type
  (see “Locations, type and hours”) and can cover everything.
- Without `unit_price`, each line takes the price that applies to the customer. The order is rejected (400) if
  there is not enough stock or a price is missing.
- **Safe retries:** `POST /orders/`, `POST /orders/{id}/pay/` and `POST /orders/{id}/returns/` accept the optional
  `Idempotency-Key` header (up to 100 characters and no control characters; otherwise it returns `400`), like refunds. The key is per
  store. With the same key and the same data, `POST /orders/` returns `201` the first time and `200` with the same
  order when repeated, without creating another; `pay` returns `200` both times and does not record another payment; `returns` returns
  `201` and then `200` without recording another return. The same key with different data (or, in `pay` and `returns`, on another
  order) returns `409` and changes nothing. With simultaneous calls and the same key only one operation is performed.
  When repeating with the key, only the key and the data are compared: the store's opening hours are not validated again
  (repeating an order already created returns `200` even if the store has closed); a new key is fully validated.
  Without the header nothing changes: a repeated `pay` still returns `409` (“already paid”). The original
  response is not stored and does not expire: the repetition returns the order as it is at that moment.
- They are identified by `id` (UUID). `number` is the number the store sees (`#1001`): to search by it,
  `GET /orders/?number=1001`. Filters: `?number=`, `?status=`, `?payment_status=`, `?fulfillment_status=`, `?delivery_type=`,
  `?customer={customer id}`, `?placed_since=...`, `?updated_since=...`.
- Each line and each return has its own `id` (UUID). A line with `parent_line` is a modifier of that
  line (e.g. “extra cheese”): it has its own price and amount and the same `quantity` as the parent line (one for
  each unit), and the order totals add up all lines equally. When the parent line is returned, its
  children are returned in the same proportion.
- **Statuses**, each separate:
  - `status`: `open`, `cancelled`, `archived`. An order that is cancelled and then archived keeps its `cancelled_at`.
  - `payment_status`: `pending`, `authorized`, `paid`, `partially_refunded`, `refunded`, `failed`.
  - `fulfillment_status`: `unfulfilled`, `prepared`, `ready_for_pickup` (`pickup` only), `shipped` (`shipping` only),
    `delivered`.
  - `delivery_type`: `shipping`, `pickup` (and, for restaurants, `dine_in`, `takeout`, `delivery_app`).
  - `payment_method`: `code` of the payment method (or empty) and `payment_method_name`, its name when the order was placed.
    `reservation_expires_at`: until when the reservation lasts if the order is not paid (according to the method's
    reservation hours; `null` = no expiry for back office and API orders; online store orders use
    `STOREFRONT_UNPAID_MAX_HOURS`, 72 h by default). When it expires without payment, the order moves to `cancelled` with `cancel_reason`
    “El apartado venció sin pago.” (“The reservation expired without payment.”), stock is released and `order.cancelled` is emitted. An `authorized` order
    does not expire. An online payment that arrives later stays on the payment with `needs_review` and `review_reason` `late`.
- **Carrier:** the order carries `carrier` (name), `carrier_code`, `carrier_service_code`, `carrier_service_name`,
  `carrier_fee` (the part of `shipping_total` that belongs to the service), `tracking_number` and `tracking_url`.
  `GET /carriers/` lists the store's carriers (deactivated ones too, because old orders keep their code):
  `code`, `name`, `tracking_url_template` (with `{tracking}`), `is_active`, `position` and `services` (`code`, `name`,
  `level` `standard` or `express`, `fee`, `is_active`, `position`). The `order.shipped` and other events carry
  `carrier`, `carrier_code`, `carrier_service_code`, `carrier_fee`, `tracking_number` and `tracking_url`.
- **Payment methods:** `GET /payment-methods/` (read only; permission `read_orders`) lists the store's: `code`,
  `name`, `kind` (`card`, `payment_link`, `transfer`, `cash_on_delivery`, `in_store`, `other`), `is_active`,
  `position` and `reservation_hours` (`null` = no expiry for back office and API orders; online store orders
  use `STOREFRONT_UNPAID_MAX_HOURS`, 72 h by default). Deactivated ones are also returned: old orders
  keep their code. Each store comes with `card`, `transfer`, `cash_on_delivery`, `in_store` and `other`; the merchant
  edits them in Settings > Payments.
- Status changes (they return the full order, or 409 if the step does not apply):
  - `POST /orders/{id}/pay/` `{"method": "transfer", "reference": "T-123"}`
  - `POST /orders/{id}/prepare/`: besides preparing, it sets the informational stamp `erp_accepted_at` (see “Inventory with an ERP”).
  - `POST /orders/{id}/ship/` `{"carrier_code": "C807", "tracking_number": "..."}`: deducts stock. With
    `carrier_code` (one of the store's carriers; `400` if it does not exist) its name and code are stored and
    `tracking_url` is computed from its template; with `carrier` (text) it is first looked up by code or name among the store's
    carriers and, if it matches, it is equivalent to sending its `carrier_code`; if it does not match it is free text and there is no link. If neither is sent, the
    carrier the order was placed with is kept.
  - `POST /orders/{id}/ready/`: ready for pickup (`pickup` orders); deducts stock the same way as `ship`.
  - `POST /orders/{id}/deliver/`: from `shipped` or `ready_for_pickup`.
  - `POST /orders/{id}/cancel/` `{"reason": "..."}`: releases reserved stock. It does not apply if it already left inventory.
  - `POST /orders/{id}/archive/`: only cancelled, delivered or refunded orders. `POST /orders/{id}/unarchive/`
    restores it to how it was. An archived order accepts no other changes.
  - `POST /orders/{id}/returns/` `{"lines": [{"line_id": "<line id>", "quantity": 1}], "reason": "Size",
    "restock": true, "refund_amount": null}`. Without `refund_amount` the suggested one is used.
  - `POST /orders/{id}/refunds/` `{"amount": "25.00", "reason": "Damaged product", "payment": "<payment id>"}`:
    refunds part or all of a charged payment (see “Payments”). `payment` is optional: without it the order's charged payment is
    used; a `needs_review` payment can only be refunded through the API by passing `payment`. It returns `201` with the updated order; `400` if the amount is not positive, has more than 2 decimals or
    the reason is missing; `404` if the order or the payment are not in your store, or if the payment belongs to another order of the same store; `409` if the
    order is archived (it accepts no refunds), if the amount exceeds what is available on the payment,
    the payment is not charged or the gateway does not process it (in that case nothing changes). Optional
    `Idempotency-Key` header (up to 100 characters): repeating the call with the same key does not refund again and returns
    `200` with the order as it is at that moment; using the same key on another order, or on the same one with a different amount or
    payment, returns `409` and changes nothing. With simultaneous calls and the same key only one refund is made:
    one returns `201` and the others `200`.
- **Payments:** `GET /orders/{id}/payments/` lists the order's charge attempts (read only): `id`, `provider`
  (`manual` if recorded by hand, or the gateway), `method`, `status` (`pending`, `authorized`, `paid`,
  `partially_refunded`, `refunded`, `failed`, `expired`), `amount`, `currency`, `refunded_amount`,
  `provider_reference`, `needs_review` and `review_reason` (`late`, `amount_mismatch`, `duplicate`). Every paid
  order has one.
  - Each payment carries `refunds`: its refunds, oldest to newest, with `id`, `amount`, `currency`, `status`
    (`pending`, `done`, `failed`), `reason`, `provider_reference`, `created_at` and `completed_at`. `refunded_amount` is
    the sum of the `done` ones; what is left to refund is `amount` minus `refunded_amount` and minus the `pending` ones. A
    payment accepts several partial refunds, never for more than was charged.
  - Refunds are synchronous: with a gateway the gateway is asked in the same call and, if it fails, the response is
    `409` and no trace is left. A `manual` payment's refund is only recorded (you return the money yourself). A `needs_review` payment
    can also be refunded; in that case the order does not change.
- To map with another system (e.g. BC's sales order): external references with
  `object_type` `order` (key: the number).

### Products

```json
PUT /products/PLY-001/
{
  "name": "Basic T-shirt",
  "status": "active",
  "category": "TSHIRTS",
  "short_description": "...",
  "description": "...",
  "base_unit": "PC",
  "tax_exempt": false,
  "tax_code": "VAT13",
  "weight": "0.2",
  "track_inventory": true,
  "options": [
    {"name": "Color", "values": ["Black", "White"]},
    {"name": "Size", "values": ["S", "M", "L"]}
  ],
  "variants": [
    {"code": "BLACK-S", "sku": "PLY-001-N-CH", "barcode": "7501234567890"}
  ],
  "attributes": {"MATERIAL": "Cotton", "WARRANTY": 12}
}
```

- `status`: `draft`, `active`, `archived`.
- **`options`** (optional) is declarative: the product ends up with exactly those options and
  values. Variants are generated automatically (one per combination). Those that remain valid
  keep their prices and inventory. `"options": []` leaves only the default variant.
  If you do not send `options`, they are left alone.
- **Value color** (#52): each element of `values` can be a string (`"Red"`, as always) or an object
  `{"value": "Red", "color": "#C92A2A"}`. `color` is hexadecimal `#RRGGBB` (stored in uppercase); if it is missing, the
  color the value already had is not changed, and `""` or `null` removes it. An invalid color is `400`. Reads still return
  `values` as strings and add `colors` (`{"Red": "#C92A2A"}`, only the values with a color); each variant carries
  `option_colors` (`{"Color": "#C92A2A"}`). The store shows it as a color swatch. Variant templates
  (`/variant-templates/`) still use only strings.
- **`variants`** (optional) updates data of existing variants, looking them up by `code` (required, except with
  `variants_mode: "exact"`).
  The code is built from the values (`BLACK-S`); the default variant has code `""`.
  `sku`, `barcode`, `weight` and `is_active` can be changed.
- **`variants_mode: "exact"`** (optional, #68; without it everything works as above). For systems that already have their
  own variants (e.g. LS): the product ends up with **only** the variants in `variants`, without generating the other combinations,
  in a single call. It requires `options` (with all the values the variants use) and `variants`; each variant carries
  `options` (`{"Color": "Black", "Size": "S"}`, one value per option, case-insensitive) and, optionally, `code`
  (the code you set; if missing it is computed from the values) in addition to `sku`, `barcode`, `weight`, `is_active` and
  `attributes`. A combination that already exists is kept (with prices and inventory) and takes the code and SKU sent;
  existing ones that are not listed are deleted, or deactivated (`is_active: false`) if they have orders or stock.
  It is `400` if an option is missing or a value does not exist, if there are repeated combinations, codes or SKUs, if the SKU is
  already used by another product or if more than 250 variants are listed (the cap counts the listed ones, not the possible combinations).
  `POST /products/bulk/` accepts the same in each product. A later `PUT` without this mode generates all the
  combinations of `options` again.
- **`attributes` in each variant of `variants`** (optional, #78): `{"CODE": value}` with attributes of `scope`
  `variant`, same rules as at product level (`null` deletes; an unknown or product-level code is an error, and the `PUT`
  is not applied). Reads return `attributes` on each variant.
- **`attributes`** (optional): `{"CODE": value}`. `null` deletes the value; attributes you do not
  send are left alone. For type `option` the text of the value is sent (case-insensitive).
- The whole `PUT` is applied in full or not at all.

**For BC without LS:** BC has no variant dimensions. The simplest is to send a single
option (e.g. `"Variant"`) with the descriptions of the Item Variants as values, and then
set the matching `code` and `sku` in `variants`.

The response (and `GET /products/{code}/`) includes the variants with their options, stock by
location and prices, the images with URLs and the attributes.

List filters: `status`, `category` (code), `q` (name, code or SKU), `updated_since`.

### Bulk product creation

For first loads (tens of thousands of products). `PUT /products/{code}/` does one transaction per product; this
sends up to **500 products** per call, the worker processes them in the background and leaves the result row by row.
It requires the `write_products` permission (checking progress, `read_products`).

```json
POST /products/bulk/
{"items": [{"code": "PLY-001", "name": "Basic T-shirt", "options": [...], "variants": [...]}, {"code": "PLY-002", ...}]}

202 {"id": "01a1…", "status": "pending", "total": 2, "processed": 0, "created": 0, "updated": 0, "failed": 0,
     "created_at": "…", "started_at": null, "finished_at": null}
```

- Each element of `items` has **the same format as the body of `PUT /products/{code}/`** plus its `code` (upsert by
  code, uppercase): it creates the product or updates it, with options, variants, attributes, etc. Options are
  processed as in the `PUT`.
- The call only checks the **shape** and returns `400` (creating nothing) if `items` is not a list of 1 to 500
  elements, if an element is not an object with `code` (text, maximum 20) or if a code is repeated. The rest of
  the validation (nonexistent category, repeated SKU, attributes, etc.) is done by the worker, product by product.
- **One bad product does not fail the others:** each product is saved in its own savepoint inside the
  transaction of its batch; if it fails, nothing of that product remains and the error is noted on its row. There is no “all or
  nothing” mode. The batch's progress (`processed`, `rows`) is committed together with its products.
- Products are processed in order and in batches of up to 100 (or 20 s), one transaction per batch; between batches the
  worker handles events and webhooks. A `PUT` or a bulk creation on the same code in parallel are not coordinated: the last write wins.
- **Events:** bulk creation, like `PUT /products/{code}/`, emits no events or webhooks (there is no
  product event); creating stock or prices is separate (`/inventory/bulk/`, `/prices/bulk/`).
- `GET /products/bulk/{id}/` returns the status (`pending`, `running`, `done`), the progress (`processed` of `total`, with
  `created`, `updated` and `failed`) and `rows`, one row per processed product, in the order sent:

```json
{"id": "01a1…", "status": "done", "total": 2, "processed": 2, "created": 1, "updated": 0, "failed": 1,
 "offset": 0, "limit": 100,
 "rows": [{"index": 0, "code": "PLY-001", "status": "created",
           "variants": [{"code": "BLACK-S", "sku": "PLY-001-BLACK-S"}, {"code": "BLACK-M", "sku": "PLY-001-BLACK-M"}]},
          {"index": 1, "code": "PLY-002", "status": "error", "errors": ["category: <message>"]}]}
```

  The `created` and `updated` rows carry `variants`: the variants the product has when it finishes (`code` and `sku`),
  ordered by position (with `variants_mode: "exact"`, in the order sent), so the connector can update its map without
  another call; it includes deactivated variants that are no longer listed. Rows with an error carry no `variants`.

  `rows` is paginated with `?offset=` and `?limit=` (default 100, maximum 500); `?status=error` keeps only the rows with an
  error. `index` is the position in `items`. The job can be queried while it runs; its data is kept with no time
  limit, and the products sent are discarded when it finishes. If the worker is not running, the job stays
  `pending` until it comes back. A job belongs to the key's store: with another key, `404`.
- `POST /products/bulk/` and `GET /products/bulk/{id}/` occupy those paths: there is no product with code `bulk` through the API
  (`GET /products/bulk/` returns `405`).

**How long it takes** (development laptop, local PostgreSQL, a single worker; products with 3 to 5 variants and 2 options,
sent in calls of 500; the `POST` call responds in milliseconds):

| Load | Before (#77) | Now (#89) |
|---|---|---|
| 10,000 products, `variants_mode: "exact"` (39,999 variants) | 14 products/s (measured with 2,000) | **111 s, 90 products/s** |
| 100,000 products, `variants_mode: "exact"` (399,999 variants) | about 2 h (extrapolated) | **23.3 min (1,399 s), 71.5 products/s** |
| 10,000 products without exact mode (combinations) | 11.3 min (679 s), 15 products/s | 7.2 min (429 s), 23 products/s |

The goal (100,000 products in under 1 h) is met with exact mode; without it, 100,000 would take about
1 h 12 min (extrapolated from 10,000). The figures vary with the machine's load (a 10,000 run with another heavy
process in parallel gave 44 products/s). What was optimized: in exact mode, a new product inserts its options, values
and variants in batches (about 16 queries per product instead of about 70) with the same result as the `PUT`; the
unique code and SKU are computed in memory; categories, brands, groups and attributes are queried once per batch; and
the batch is a single transaction (one `commit` per 100 products). A product that already exists, or without exact mode,
keeps the usual path (the order of the generated variants does not change). It is processed with **a single worker**: two
jobs of the same store at once could collide when assigning codes or SKUs (the error would show on the row, with no
corruption), and with a single database CPU it would not gain speed. Measure with
`RM_BENCHMARK=10000 [RM_BENCHMARK_MODE=exact] python manage.py test apps.api.tests.test_products_bulk.Benchmark`.

### Catalog import (first loads)

To load a new store's full catalog (tens or hundreds of thousands of products) from a file. Unlike
`/products/bulk/` (up to 500 products per call and upsert), this **only creates**: you upload a file and the
worker reads it in batches. It requires `write_products` (checking, `read_products`).

```
POST /products/import/   (multipart/form-data)
  file=@catalog.ndjson

202 {"id": "01a1…", "status": "pending", "total": 100000, "processed": 0, "created": 0, "updated": 0, "failed": 0,
     "created_at": "…", "started_at": null, "finished_at": null}
```

- **Format: NDJSON** (one JSON object per line, UTF-8; the BOM and Windows line breaks are tolerated). Each line has the same
  format as the body of `PUT /products/{code}/` plus its `code`. The first line is checked on upload (`400` if it is not an
  object with `code`, if `file` is missing, if it is empty or if it is larger than 256 MB); the rest, line by line in the worker.
  Each line can be up to 1 MB; a longer one becomes an error for that line.
- **What it accepts:** products with `variants_mode: "exact"` (with `options` and `variants`) and products without `options` or
  `variants` (they end up with the default variant, whose SKU equals the product code). A line with `options` or
  `variants` without exact mode is an error for that line: those products go through `POST /products/bulk/`. `category`, `brand`, `attributes` (product and variant),
  `units` and `modifier_groups` also apply.
- **Create only.** If the code already exists in the store (or is repeated in the file, where the first line counts), the line
  is an error and the existing product is not touched. Updating or replacing products is left for another feature.
- **Same validations and messages as the `PUT`:** an invalid line gives the same `errors` the `PUT`'s `400` would give,
  and nothing of it remains. A bad line (broken JSON, empty line, missing code) does not stop the others.
- **Variants and uniqueness:** each variant's code and SKU are computed the same way as in the `PUT` (collisions append
  `-2`, `-3`…), and the product's `slug`, if missing, comes from the name and is made unique the same way as in the `PUT`.
- **Outside this contract:** stock, prices, external references, translations and images (loaded later with their
  own endpoints). The category's variant template is not applied either (same as the `PUT`).
  Like the `PUT` and `/products/bulk/`, it emits no events or webhooks.
- **Batches:** the worker processes 500 lines per batch, one transaction per batch with the progress (`processed` and the bytes
  read), so a restart resumes where it left off without repeating products. If the file is lost before finishing, the
  job ends `failed` with an `error`. A single worker handles the import; two imports of the same store at
  once, or an import and a `PUT` of the same code, are not coordinated (the one that arrives second fails on that row).

`GET /products/import/{id}/` returns the same body as `/products/bulk/{id}/` (`status` `pending`, `running`,
`done` or `failed`; `updated` is always 0; if it failed, `error`) and `rows` paginated with `?offset=`, `?limit=` (default
100, maximum 500) and `?status=error`:

```json
{"id": "01a1…", "status": "done", "total": 3, "processed": 3, "created": 2, "updated": 0, "failed": 1,
 "offset": 0, "limit": 100,
 "rows": [{"index": 0, "code": "PLY-001", "status": "created", "variants": [{"code": "BLACK-S", "sku": "PLY-001-BLACK-S"}]},
          {"index": 1, "code": "PLY-002", "status": "error", "errors": ["code: <message>"]}]}
```

`index` is the line number (from 0) and `variants` carries the stored `code` and `sku` of each variant, by position.
For hundreds of thousands of rows, `GET /products/import/{id}/rows/` returns **the whole result as NDJSON** (one row per
line, in order, unpaginated; it also accepts `?status=error`). Per-line results are kept with no time
limit; the uploaded file is deleted when it finishes. A job belongs to the key's store: with another key, `404`.
`import` is a reserved code: `/products/import/` no longer reaches a product with that code (`GET` returns `405`).

**How long it takes** (same conditions as the table above, products with 3 to 5 variants and 2 options, exact mode):

| Load | With `/products/bulk/` (#89) | With the import (#90) |
|---|---|---|
| 10,000 products (39,999 variants) | 111 s, 90 products/s | **44 s, 227 products/s** |
| 100,000 products (399,999 variants) | 23.3 min (1,399 s) | **8.1 min (484 s), 206.6 products/s** |

The 100,000 figures were measured on this development laptop (i5-1135G7). What makes it faster: it validates with the same serializer, builds the products in memory and saves each batch with one
insert per table, instead of a few dozen queries per product. Measure with
`RM_BENCHMARK=10000 python manage.py test apps.api.tests.test_import_equivalence.Benchmark`.

### Images

```
POST /products/PLY-001/images/   (multipart/form-data)
  image=@photo.jpg
  alt_text=Black T-shirt, front view
  variant_skus=PLY-001-N-CH,PLY-001-N-M     (optional)
```

JPG, PNG, WEBP or GIF, up to 10 MB. A 400 px WEBP thumbnail is generated. The response carries the image's `id` (UUID),
which is the one used to delete it.

- **Unknown SKUs:** if any SKU in `variant_skus` (JSON list or comma-separated) is not a variant of that
  product, it returns `400` with `variant_skus` and `unknown_skus` (the list) and does not create the image.
- **External key:** `external_key` (optional, up to 100 characters) is unique per product. A `POST` with the same
  key **replaces** the image (keeps `id` and order; without `alt_text` or `variant_skus` it keeps the previous ones) and
  returns `200`; with a new key it returns `201`. Without a key it always creates. Delete by key:
  `DELETE /products/{code}/images/?external_key=LS-1` (`204`; `404` if it does not exist).
- **Image by URL:** instead of `image`, send JSON `{"url": "https://cdn.example.com/a.jpg", "external_key": "LS-1",
  "alt_text": "...", "variant_skus": ["A"]}`. `https` only, no username or password, up to 500 characters; the URL is stored
  **without downloading it** (no thumbnail and no width/height) and the store and back office display it directly.
  The response's `url` and `thumbnail_url` are that URL; `external_url` and `external_key` are also returned. Sending
  `image` and `url` together is `400`. To change an image from a file to a URL (or the other way round), resend with the same key.
- Security: the CSP of the store and the back office allows `img-src https:` (any https, for images only;
  scripts, styles, frames and forms remain restricted to the site itself).

### Variants

`GET /variants/?product=PLY-001` or `?barcode=750...` · `PATCH /variants/{sku}/` with
`sku`, `barcode`, `weight`, `is_active` and `attributes` (`{"CODE": value}` of attributes with `scope` `variant`;
`null` deletes the value; an unknown or product-level code is `400`).

`DELETE /variants/{sku}/` (`204`) deletes the variant with its linked prices, stock and images (the image
is kept on the product). `409` if the variant has orders (in that case deactivate it with `is_active: false`: orders
keep the SKU and name, but losing the link is not desirable) or if it is the product's only variant (to remove it, delete
the product). If the product has options, the combination reappears when its options are saved again; to
really remove it, change the options. It requires `write_products`.

### Bulk prices

```json
POST /prices/bulk/
{"items": [
  {"price_list": "GENERAL", "sku": "PLY-001-N-CH", "price": "249.00", "compare_at_price": "299.00"},
  {"price_list": "GENERAL", "sku": "PLY-001-N-CH", "price": "220.00", "min_quantity": "6"},
  {"price_list": "WHOLESALE", "sku": "PLY-001-N-M", "price": null}
]}
```

- Up to 1000 rows per call. By default everything is applied or nothing.
- **Partial success** (optional): `POST /prices/bulk/?partial=true` (or `"partial": true` in the body) applies the valid
  rows and reports the rest; see “Partial success in bulk calls”.
- `price: null` deletes that line.
- `min_quantity` (default `0`) is equivalent to BC's price line “Minimum Quantity”.
- Response: `{"updated": 2, "deleted": 1}`. If there are errors, `400` with
  `{"errors": {"<index>": ["..."]}}`.

`GET /prices/?price_list=GENERAL&sku=...&product=...`

### Bulk inventory

```json
POST /inventory/bulk/
{"items": [
  {"sku": "PLY-001-N-CH", "location": "DC", "on_hand": "12"},
  {"sku": "PLY-001-N-CH", "location": "DOWNTOWN", "on_hand": "3", "reference": "BC 2026-10-14"}
]}
```

- `on_hand` is the **absolute** stock (as BC computes it), not an adjustment.
- Each change is recorded in the movement log with the reason “Sincronización” (sync).
- By default everything is applied or nothing, like prices; `?partial=true` enables partial success.
- Response: `{"received": 2, "changed": 1}`.

`GET /inventory/?sku=...&location=...&product=...` returns `on_hand`, `reserved` and `available`
(`available = on_hand − reserved`; the back office also shows the available quantity next to the stock).

### Inventory with an ERP

A single flow, with or without an ERP: each order reserves its quantity when created and on shipping (`ship`, `ready`) the reservation is released and `on_hand` is deducted (“Venta”, i.e. sale, movement); cancelling or expiring without payment releases the reservation.
`POST /inventory/bulk/` overwrites `on_hand` with the ERP's absolute value and does not touch `reserved`: your number rules. While an order is still open in both systems, RM shows less than the ERP (never more).
`POST /orders/{id}/prepare/` and the order's external reference through the connector's connection only set `erp_accepted_at` (informational, `null` until it happens) and an event “El ERP recibió el pedido.” (“The ERP received the order.”); they release and deduct nothing.

### Partial success in bulk calls

`POST /prices/bulk/?partial=true` and `POST /inventory/bulk/?partial=true` (also `"partial": true` in the body)
validate row by row: they apply the valid ones, in a single transaction, and do not reject the batch because of the bad ones (nonexistent SKU, list or
location, invalid values). They always return `200` (even if all fail) with the usual totals plus
`failed` and `rows`, one row for each row sent, in the same order:

```json
{"updated": 1, "deleted": 0, "failed": 1,
 "rows": [{"index": 0, "status": "updated"},
          {"index": 1, "status": "error", "errors": ["No existe el SKU «NOPE»."]}]}
```

(The error text reads “The SKU «NOPE» does not exist.”)

`status`: `updated` or `deleted` (prices); `changed` or `unchanged` (inventory); `error`. In inventory `received` counts
the rows applied. Without `partial` (or with `false`) the behavior does not change: `400` with `{"errors": {...}}` and nothing is
applied. The 1000-row limit still applies.

### Sync runs

The connector reports each run so RM shows when it last synced and how it went. It requires `write_integrations`
(`read_integrations` to list). `{id}` is the connection's `id` (UUID).

```json
POST /connections/{id}/sync-runs/
{"kind": "inventory", "started_at": "2026-10-17T10:00:00Z", "finished_at": "2026-10-17T10:05:00Z",
 "records": 1200, "errors": 3, "message": "3 SKUs without a variant"}
```

- `kind` (free text, up to 30: `products`, `prices`, `inventory`, `images`...), `started_at` and `finished_at`
  (`finished_at` cannot be earlier) are required; `records` and `errors` (integers, default `0`) and `message`
  (up to 2000) are optional.
- `status` (optional): `ok`, `partial` or `failed`. Without it: `ok` if `errors` is `0`, `partial` otherwise.
- It returns `201` with the run (`id`, `kind`, `started_at`, `finished_at`, `records`, `errors`, `status`, `message`).
  It sets the connection's `last_sync_at` to `finished_at` (never moves it back; a `failed` run does not move it).
- `GET /connections/{id}/sync-runs/` lists the runs (oldest first, by cursor; `?kind=` filter).
- The “LS Central – connector” app page in the back office shows the latest 20.

### External references

They store the relationship between a local record and its ID in BC (or another system), per connection.
Connections are created in the back office (**Settings → Integrations**) and their `id` is
retrieved with `GET /connections/`.

```json
POST /external-references/
{"connection": "0192f3a4-6b1c-7d2e-9f00-1a2b3c4d5e6f", "object_type": "product", "object_key": "PLY-001",
 "external_id": "5c2e...-bc-guid", "external_code": "1000", "data_hash": "..."}
```

- `object_type`: `product`, `variant` (the key is the SKU), `category`, `attribute`,
  `location`, `price_list`, `customer_group`, `customer` (the key is the customer code), `order` (the key is the number).
- With `object_type: "order"`, if the connection is the connector's, registering the
  reference sets the order's informational stamp `erp_accepted_at` (see “Inventory with an ERP”).
- It is an upsert by (connection, local record). The same `external_id` cannot be linked to
  two records.
- `GET /external-references/?connection={id}&object_type=product&external_id=...` to search.
- Each reference carries its `id` (UUID), which is used to delete it.
- When the local record is deleted, its references are deleted.

## Webhooks (events to your system)

The store's **Webhooks** app sends a `POST` with JSON to the configured address for each chosen event
(`order.created`, `order.updated`, `order.paid`, `order.payment_failed`, `order.shipped`, `order.ready_for_pickup`, `order.delivered`, `order.cancelled`,
`order.returned`, `order.refunded`, `inventory.changed`). Redirects are not followed; 5 s timeout. Only `https` and public addresses
are accepted: internal networks (localhost, private ranges and local addresses) are never notified.

Body:

```json
{"id": "6f1c…", "event": "order.created", "store": "mia", "occurred_at": "2026-10-15T20:31:02+00:00", "data": {…}}
```

Headers: `X-RM-Event` (name), `X-RM-Event-Id` (equal to `id`), `X-RM-Signature` (hex HMAC-SHA256 of the
exact body, with the app secret) and `User-Agent: RM-Webhooks/1`.

- **Delivery and retries:** reply `2xx` to acknowledge. On `5xx`, `429`, `408`, timeout or network error it is
  retried with growing backoff (30 s · 2^n, maximum 1 h, with random jitter), up to 8 attempts in about 2 hours.
  Any other `4xx` (and redirects) is considered failed without retrying. Each retry sends **the same
  body, the same `id` and the same signature**.
- **At least once:** a delivery may arrive more than once. Deduplicate by `id`.
- **Data:** order events carry the `id` (UUID), the `number`, the three statuses, `delivery_type`,
  `payment_method` and the lines (with `id` and `parent_line`); `inventory.changed` carries `sku`,
  `product_code`, `product_name` and `available`.
- **`order.refunded`:** one event per refund made (also when cancelling or returning a paid order). It carries the
  order data (with `refunded_total`) and `refund` with `id`, `payment_id`, `amount`, `currency`, `status` and
  `reason`. A refund of a `needs_review` payment does not emit it.
- **Order:** not guaranteed. Compare `occurred_at`; in `inventory.changed` ignore the one older than the
  last applied for that SKU. Consecutive stock changes of a variant are grouped into a single event with
  the final stock (`data.available`).
- **Deactivation:** after 5 consecutive failed deliveries the app is paused and the store sees a notice on Home. A successful
  delivery resets the count; so does reactivating the app or saving its configuration.

## curl example

```bash
export RM_BASE_URL="https://retail-s.online/api/v1"   # local: http://127.0.0.1:8000/api/v1
export RM_API_KEY="rk_..."                              # your key; never in code or in a repository
curl -s "$RM_BASE_URL/" -H "Authorization: Bearer $RM_API_KEY"
curl -s -X PUT "$RM_BASE_URL/products/MUG-01/" \
  -H "Authorization: Bearer $RM_API_KEY" -H "Content-Type: application/json" \
  -d '{"name": "Mug", "status": "active"}'
# On the LAN test server with its own CA, add: --cacert casa-ca.crt
```

## Limits

Each API key has its own budget: **600 requests per minute** (sliding window; the operator changes it
with `API_RATE_LIMIT_PER_MINUTE`). One store's key never spends another store's budget, nor that of another key of the
same store. All authenticated requests with permission count, including those that end in a validation error.

Each authenticated response (the `429` too) carries:

- `X-RateLimit-Limit`: requests allowed per minute.
- `X-RateLimit-Remaining`: the ones left in the current window.
- `X-RateLimit-Reset`: second (Unix epoch) at which the window's oldest slot is freed.

When it runs out, the API returns `429` with `Retry-After` (seconds left) and `X-RateLimit-Remaining: 0`. Rejected requests
do not count, so waiting for `Retry-After` is enough. `401` and `403` responses carry no such headers.

## Contract changes

- **28 Oct 2026 (#166): documentation.** No API changes. New: `docs/openapi.json` (OpenAPI 3.1), Postman collection and environment, public `/docs/` page, `/llms.txt` and `docs/api-ia.md`. The base becomes `{base_url}` (see above).
- **28 Oct 2026 (#171): English documentation.** No API changes. `/docs/?lang=en`, `/llms.txt?lang=en`, `docs/openapi.en.json`, the English Postman collection, `docs/api.en.md` and `docs/api-ia.en.md`.
- **20 Oct 2026 (#105): a single inventory flow.** `erp_accepted_at` becomes informational only: `POST /orders/{id}/prepare/` and the order's external reference no longer release the reservation, the order keeps reserving and is deducted on shipping or when marked ready, and an order with `erp_accepted_at` can still be edited and expires like the others. The “LS Central controls inventory” option is removed. `POST /orders/{id}/prepare/` through the API sets the `erp_accepted_at` stamp and the event in every store, with or without a connector (with #88 it did not stamp without the option). See “Inventory with an ERP”.
- **19 Oct 2026 (#14): rate limit.** Each API key gets 600 requests per minute; authenticated responses
  carry `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`, and when it runs out the API returns
  `429` with `Retry-After`. See “Limits”.
- **19 Oct 2026 (#12): safe retries.** Optional `Idempotency-Key` header on `POST /orders/`,
  `POST /orders/{id}/pay/` and `POST /orders/{id}/returns/` (see “Orders”): `201` the first time and `200` when repeated
  (`pay` always returns `200`), `409` with the same key and different data. It follows the same criterion as
  refunds (#60): the key does not expire and the repetition does not store the original response; that is why the contract differs
  from the issue text (`422` and 24 hours). No new fields in responses or events. Without the header everything
  stays the same.
- **19 Oct 2026 (#60): refunds.** `POST /orders/{id}/refunds/` (with optional `Idempotency-Key`; permission
  `write_orders`), new `refunds` field on each payment of `GET /orders/{id}/payments/`, new `order.refunded` event and
  `refunded_total` in the data of order events. Cancelling a paid order (also a partially
  refunded one) or recording a return now refunds through the order's payment. No changes to the other paths.
- **19 Oct 2026 (#90): catalog import.** `POST /products/import/` (NDJSON file, `202` with the id of an asynchronous
  job that only creates products), `GET /products/import/{id}/` (status, progress and per-line result) and
  `GET /products/import/{id}/rows/` (the full result as NDJSON). `import` becomes a reserved code in
  `/products/import/`. No changes to existing paths.
- **17 Oct 2026 (#89): variants in the bulk creation result.** New `variants` field (`[{"code", "sku"}]`, by
  position) on the `created` and `updated` rows of `GET /products/bulk/{id}/`; the rest of the row does not change. Processing
  is faster (see “How long it takes”) and each batch of 100 products is one transaction.
- **17 Oct 2026 (#68): exact variants.** `PUT /products/{code}/` (and `POST /products/bulk/`) accepts `variants_mode: "exact"`:
  the product ends up with only the variants in `variants`, identified by `options` and with the given `code`, without
  generating the other combinations. `code` becomes optional in that mode; `options` inside `variants` is only accepted
  with it. Without `variants_mode` nothing changes.
- **17 Oct 2026 (#88): inventory with an ERP.** New read field `erp_accepted_at` on the order. In a store with the
  “LS Central controls inventory” option, `POST /orders/{id}/prepare/` and the order's external reference through the connector's connection
  release that order's reservation (see “Inventory with an ERP”). Without that option nothing changes.
- **17 Oct 2026 (#52): option value color.** `options[].values` of `PUT /products/{code}/` accepts, besides
  strings, `{"value", "color"}` (hexadecimal `#RRGGBB`). New read fields: `options[].colors` on the product and
  `option_colors` on each variant (also in `/variants/`). The format of `values` as strings does not change.
- **17 Oct 2026 (#77): bulk product creation.** `POST /products/bulk/` (up to 500 products, `202` response with the id of an asynchronous
  job) and `GET /products/bulk/{id}/` (status, progress and per-row result). A product with code `bulk`
  can no longer be reached through `/products/bulk/`.
- **15 Oct 2026 (#20, ADR 0002): public IDs.** Customers, orders (and their lines and returns), images, connections
  and external references use a UUID as `id` instead of the internal number, in responses, in paths
  (`/customers/{id}/`, `/orders/{id}/`, `/connections/{id}/`, `/external-references/{id}/`,
  `/products/{code}/images/{id}/`) and in the fields that reference them (`customer`, `connection`, `line_id`).
  Orders are no longer looked up by number in the path: use `?number=`. Resources with a code (products, categories,
  locations, etc.) no longer carry `id`. `GET /` no longer carries the store id. In `inventory.changed`, `product_id`
  became `product_code`.
- **15 Oct 2026 (#21, ADR 0007): scopes.** Each endpoint requires a key permission (see “Permissions”); without it
  it returns `403`. Keys that already existed keep all permissions. `GET /` carries `scopes`.
- **15 Oct 2026 (#53, ADR 0008): cursor pagination** on all lists, instead of `limit`/`offset`.
  The response no longer carries `count` or `previous`; orders are listed in creation order (before, by number).
- **15 Oct 2026 (#10): order statuses.** New values: `status` `archived`; `payment_status` `authorized` and
  `failed`; `fulfillment_status` `ready_for_pickup`. New fields: `delivery_type`, `archived_at`,
  `reservation_expires_at` and `parent_line` on lines. `payment_method` becomes a key (`card`, `transfer`,
  `cash_on_delivery`, `in_store`, `other`), also in `pay`. New actions: `ready`, `archive`, `unarchive`. New event:
  `order.ready_for_pickup`. A `pickup` order is no longer marked `shipped`.
- **15 Oct 2026 (#9): payments.** Read-only resource `GET /orders/{id}/payments/`. New event
  `order.payment_failed`. A store order paid by card is created `pending` and moves to `paid` when the
  gateway confirms it.
- **17 Oct 2026 (#75): payment methods per store.** Read-only resource `/payment-methods/`. `payment_method` is no
  longer a fixed list: it is the `code` of one of the store's methods (the usual codes remain valid). New field
  `payment_method_name` on the order. `reservation_expires_at` is now set according to the method and the unpaid order
  is cancelled when it expires. `cancel_reason` is also carried in the `order.cancelled` event.
- **17 Oct 2026 (#78): attributes per variant.** `scope` field (`product` or `variant`, default `product`) on
  `/attributes/`; `attributes` on each variant (read, `variants[]` of `PUT /products/{code}/` and
  `PATCH /variants/{sku}/`). Product attributes stay the same; sending a variant code at product level
  (or the other way round) is `400`.
- **17 Oct 2026 (#76): carriers and services.** Read-only resource `/carriers/`. New fields on the order:
  `carrier_code`, `carrier_service_code`, `carrier_service_name`, `carrier_fee` and `tracking_url`. `POST /orders/` accepts
  `carrier_code` and `carrier_service_code`; `ship` accepts `carrier_code`. Order events carry the shipping data.
- **15 Oct 2026 (#54): modifiers.** New resource `/modifier-groups/`, `modifier_groups` field on products,
  `modifiers` on lines when creating orders and child lines (`parent_line`) in responses.
- **15 Oct 2026 (#55): hours.** `hours` field on locations; `POST /orders/` may return `400` outside opening hours.
- **15 Oct 2026 (#56): restaurant order types.** `takeout` and `dine_in` in `delivery_type` (depending on the store) and
  `table_number` field.
