# DZBuild POS

DZBuild POS is the free Windows till for shops that also sell online. It keeps working offline and, once linked, stays in sync with one or more DZBuild stores. This page lists the calls a linked till makes, so support staff and partners can see what a till can and cannot do.

These endpoints answer only tokens issued to the till. A personal API key or an app token gets `403` on the POS-only paths, and a till token gets `403` on every path outside its list.

## Who it is for

* Shop owners who sell in a physical shop and on their DZBuild store. Only the store owner can link a till; team members cannot.
* Every plan. A till is not an API key, so it needs no Enterprise plan and takes no key slot.
* Once the first till registers, the **DZBuild POS** extension appears in the dashboard with the linked tills, a disconnect button and the latest sales, refunds and Z closures. `https://dzbuild.com/dashboard/connected-devices` opens that page.

## How a till links

* Discovery: `GET https://dzbuild.com/.well-known/oauth-authorization-server` (RFC 8414).
* The till is a public OAuth 2.0 client, `dzbuild-pos-windows`, with no secret. Only PKCE `S256` is accepted, and the redirect is `http://127.0.0.1:PORT/oauth/callback` on any port.
* The owner signs in in the system browser and picks the stores the till may use, up to 10. A till can also link with a code: it shows one, and the owner types it at `https://dzbuild.com/device` from a phone.
* The access token starts with `dzpos_` and lasts 15 minutes. The refresh token is replaced on every use and ends after 30 days without use or 180 days in all. Sending an old refresh token again ends the link.
* Every call carries `X-DZ-Store` with the store id, except `GET /v1/me`. A store the owner did not pick answers `403`.
* Every `POST`, `PATCH` and `DELETE` carries an `Idempotency-Key`, with the rules in [Idempotency](https://dzbuild.com/api-docs/idempotency.md).
* Limits: 120 requests a minute per till and 600 per store. The monthly API quota does not apply to tills.
* Disconnecting the till in the dashboard ends the link: the next refresh answers `400 invalid_grant` and the next heartbeat `410 device_revoked`.

## The 19 scopes

The till always asks for all 19, and DZBuild always grants all 19.

| Scopes                                | What the till can do                                              |
| ------------------------------------- | ----------------------------------------------------------------- |
| `openid`, `profile`, `offline_access` | Know who linked it and stay linked                                |
| `store:read`                          | Read the stores of the link, their language, plan and product cap |
| `products:read`, `products:write`     | Read products, create and update them in batches, add photos      |
| `inventory:read`, `inventory:write`   | Read and adjust product stock                                     |
| `orders:read`, `orders:write`         | Receive the store's orders, claim one, move it, cancel it         |
| `customers:read`                      | Check one phone number before a sale                              |
| `pos:sales:read`, `pos:sales:write`   | Record sales, refunds and Z closures                              |
| `locations:read`, `locations:write`   | Register the shop the till sits in                                |
| `backups:read`, `backups:write`       | Reserved: backups are not offered                                 |
| `devices:self`                        | Register the till, send heartbeats, unlink                        |
| `events:read`                         | Read the change feed                                              |

## Endpoints

| Method and path                                                                       | What it does                                                                         |
| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `GET /v1/me`                                                                          | The owner and the stores of the link                                                 |
| `GET /v1/store`                                                                       | The chosen store: name, language, currency `DZD`, stock timing, plan and product cap |
| `POST /v1/devices`, `GET /v1/devices`                                                 | Register the till (one row per till and store), list the tills                       |
| `POST /v1/devices/{id}/heartbeat`                                                     | Every 15 minutes: version, pending and failed uploads                                |
| `DELETE /v1/devices/{id}`                                                             | Unlink this till                                                                     |
| `GET /v1/locations`, `POST /v1/locations`                                             | The shop the till sits in                                                            |
| `POST /v1/products/batch`                                                             | Create or update up to 100 products                                                  |
| `GET /v1/products`, `GET /v1/products/{id}`                                           | Products changed since a date, one product                                           |
| `POST /v1/media/uploads`, `POST /v1/products/{id}/images`                             | Photo ticket, then attach the uploaded photo                                         |
| `POST /v1/inventory/adjustments/batch`                                                | Up to 500 stock sets or changes                                                      |
| `POST /v1/pos/sales`, `POST /v1/pos/sales/{sale_id}/refunds`, `POST /v1/pos/closures` | Sales, refunds, Z closures                                                           |
| `GET /v1/orders`, `GET /v1/orders/{id}`                                               | The store's orders in the till's shape                                               |
| `POST /v1/orders/{id}/claim`, `PATCH /v1/orders/{id}`, `POST /v1/orders/{id}/cancel`  | Take an order, move it, cancel it                                                    |
| `GET /v1/customers?phone=`                                                            | Flags for one phone number                                                           |
| `GET /v1/events`                                                                      | The change feed                                                                      |

Some paths are shared with API keys (products, orders, customers). A till gets the shapes on this page; an API key keeps the shapes in Resources.

## Products batch

`POST /v1/products/batch` takes `items`, up to 100. Each item has the till's own `external_id`, `name`, an optional `sku` and `barcode`, `pricing.price` as a string with two decimals (`"4500.00"`), `inventory.track_stock`, `status` (`active`, `draft` or `archived`) and an optional `category` with its own `external_id` and `name`.

* An item updates the product already linked to its `external_id`. Failing that, it adopts a product without variants that has the same `sku` and no link yet. Failing that, it creates a product with 0 in stock.
* A category is found by its `external_id`, or created from its name.
* The answer lists every item: `external_id`, `id`, `status` (`created` or `updated`) and `error: null`. A refused item has an `error` object and no `id` or `status`.
* Plan cap: when the store already has as many active products as its plan allows, a batch that would create a product, draft or not, answers `402 product_limit_reached`. Items before that one stay written. Switching an existing product to `active` past the cap is an error on that item only.
* The batch never sets stock. Stock goes through the adjustments call.

## Photos

1. `POST /v1/media/uploads` with `filename`, `content_type` (`image/jpeg`, `image/png` or `image/webp`), `size` (up to 8 MiB) and `sha256`. The answer carries `media_id` and a signed `upload_url` that works for 10 minutes.
2. The till sends the raw bytes with `PUT` to `upload_url`, without the DZBuild headers.
3. `POST /v1/products/{id}/images` with `media_id` and `position` attaches the photo. The size and the sha256 must match the ticket, or the call answers `422 media_mismatch`. An unknown or expired ticket answers `404 media_not_found`. A product holds up to 20 photos.

## Stock

`POST /v1/inventory/adjustments/batch` takes up to 500 items. Each item names `product_external_id`, `target: "product"`, either `set` (whole pieces) or `delta`, a `reason` (`pos_sale`, `pos_return`, `restock`, `count` or `loss`) and a unique `ref`.

* Only products that track stock at the product level can be adjusted. A product with stock per variant answers the item error `variant_product`, one that does not track stock `not_tracked`, a product that no longer exists `unknown_product`.
* The answer lists every item by `ref` with `status` `ok` or `error`.
* Till stock changes never appear in the dashboard's change history and never offer an undo.

## POS documents

`POST /v1/pos/sales` records a ticket, an invoice or a delivery note; `POST /v1/pos/sales/{sale_id}/refunds` a return note or a credit note against that sale; `POST /v1/pos/closures` a Z closure with its totals and hash anchors.

* Documents are kept as sent and never change: there is no update or delete path.
* A sale answers `201` with `{"id": 99120, "stock_applied": false}`. Stock moves through the adjustments call, never through a document.
* POS documents live apart from orders. They do not count toward the store's monthly order limit, do not notify anyone, and never reach Google Sheets or webhooks.
* Money is a string with two decimals, quantities a number with up to 3 decimals, up to 500 lines and 20 payments per document.
* Sending a document whose `external_id` is already recorded answers `409 already_exists` with the recorded id in `error.details.id`. The till reads that as success.
* A refund for a sale of another store answers `404 not_found`.

## Orders

* `GET /v1/orders?updated_since=...` and `GET /v1/orders/{id}` give the store's orders in the till's shape, with their items. `order_number` is the store's short number when it has one.
* `POST /v1/orders/{id}/claim` with `device_id` and `terminal`: the first till wins. The same till claiming again gets `200`; another till gets `409 order_claimed`.
* `PATCH /v1/orders/{id}` with `status` needs the claim (`409 claim_required` otherwise). Allowed moves: from `pending` or `confirmed` to `processing`, `shipped` or `delivered`, from `processing` to `shipped` or `delivered`, and from `shipped` to `delivered`. Any other move answers `409 transition_not_allowed`.
* `POST /v1/orders/{id}/cancel` takes `reason` (`out_of_stock`, `customer_unreachable`, `duplicate` or `other`) and an optional `note` up to 500 characters. Stock comes back under the store's own stock rules. If another till holds the claim, cancel answers `409 order_claimed`.
* A status change from the till runs the same follow-up as one made in the dashboard, notifications and the Google Sheets update included.

## Customers

`GET /v1/customers?phone=0550123456` answers a page with at most one item: `id`, `is_banned` and `fraud_score`. It looks only at the store's own customers, accepts the phone with or without `+213`, and gives no name, address or history. An unknown number gives an empty page.

## Events

`GET /v1/events?wait=25&limit=200&cursor=...` returns `items`, `next_cursor` and `has_more`. `next_cursor` is always present, also on an empty page; the till sends it back on the next call.

* The edge holds the call up to 25 seconds and answers as soon as something changed.
* The first call, without a cursor, starts with `order.updated` for every `pending`, `confirmed` and `processing` order.
* Types: `order.created`, `order.updated`, `product.updated`, `product.deleted`, `inventory.level_changed`, `customer.updated` and `device.revoked`. Every event has a stable `id`, so the till can ignore repeats.
* `inventory.level_changed` carries `old`, `new`, `delta` and a `source`: `order` when an order moved the stock (with the order number), `dashboard` for any other change made outside the till, and `pos` when another till of the same store changed it (the till ignores that source).
* The till's own product and stock writes are not sent back to it.
* `device.revoked` carries `device_id` as a string, once, after the till is disconnected.
* A cursor this API did not issue answers `400 bad_request`.

## Backups

DZBuild does not store till backups. `GET /v1/backups`, `POST /v1/backups`, `POST /v1/backups/{id}/complete` and `GET /v1/backups/{id}/download` always answer `501 not_implemented`, and the till keeps its backups on the PC.

## Error codes

| Status | `code`                   | When                                                                      |
| ------ | ------------------------ | ------------------------------------------------------------------------- |
| 400    | `bad_request`            | A wrong `updated_since` or a cursor this API did not issue                |
| 401    | `unauthorized`           | Token missing, wrong or expired, or the link was ended                    |
| 402    | `product_limit_reached`  | A batch would create a product past the plan's cap                        |
| 403    | `forbidden`              | A path outside the till's list, or a store the owner did not pick         |
| 404    | `not_found`              | Product, sale or order not on this store                                  |
| 404    | `device_not_found`       | A till id that is not this till on this store                             |
| 404    | `media_not_found`        | Photo ticket unknown or expired                                           |
| 409    | `already_exists`         | A document with this `external_id` is recorded; its id is in `details.id` |
| 409    | `order_claimed`          | Another till claimed the order                                            |
| 409    | `claim_required`         | Moving an order this till has not claimed                                 |
| 409    | `transition_not_allowed` | The move is not allowed from the order's status                           |
| 410    | `device_revoked`         | The till was disconnected                                                 |
| 413    | `payload_too_large`      | Body larger than 1 MiB                                                    |
| 422    | `validation_error`       | A field is wrong; `details.field` names it                                |
| 422    | `media_mismatch`         | The uploaded photo does not match its ticket                              |
| 422    | `idempotency_key_reuse`  | Same `Idempotency-Key` with a different body                              |
| 429    | `rate_limited`           | Over a limit; wait for `Retry-After`                                      |
| 501    | `not_implemented`        | The backup paths                                                          |
| 503    | `storage_unavailable`    | Photo storage is not reachable; retry later                               |
