# JTL POS Sync API HTTPS API that mirrors the JTL-POS ↔ Wawi sync protocol. Use this to build a custom POS client that syncs catalog data and submits orders. Two servers implement this protocol: | Implementation | Entry | Handlers | |---|---|---| | **Node.js** | `server.js` | [`src/endpoints/`](src/endpoints/) | | **C++** | `jtlsrv-cpp/` | [`jtlsrv-cpp/src/endpoints/`](jtlsrv-cpp/src/endpoints/) | Clients talk to either over the same paths and shapes. See [Node vs C++](#node-vs-c) for the few behavioral differences. Base URL: `https://:` (default port `4443`) Paths may be called as `/v1/...` or `/api/v1/...` — both resolve to the same handlers. All JSON responses use `Content-Type: application/json; charset=utf-8`. Numeric fields are usually returned as **strings**. The server currently does **not** validate the `authToken` on subsequent requests after pairing. Pairing still issues a token so clients can store it for a real JTL-compatible flow. --- ## Client lifecycle ``` 1. Pair GET /v1/client 2. Poll sync GET /v1/init (with lastChanged* cursors) 3. Fetch deltas GET /v1/category|product|productcomposite|customergroup|deletedentity 4. Fetch images GET /v1/pimage|cimage (optional, by hash) 5. Submit sale POST /v1/order ``` Persist every `lastChanged*` cursor locally. On the next poll, send the highest value you have seen for that entity type so you only download deltas. See [productSync.md](productSync.md) for the cursor / row-version model in detail. --- ## Demo mode Node can serve a generated in-memory catalog without MSSQL. 1. Generate artifacts once: `npm run demo:generate` Writes `demo/catalog.json` and `demo/images/.jpg` (≥1000 products, 3–4 category levels, variants, real photos). 2. Set `DEMO_MODE=true` in `.env` (see `.env.example`). 3. Start the server as usual (`npm start`). Pairing and all sync endpoints work; orders are logged and return synthetic `OK` results. Demo mode is **opt-in only** — a failed MSSQL connection does not enable it. --- ## `GET /v1/client` — Pairing Discovers the server and completes pairing with a 6-digit code. ### Query parameters | Param | Required | Description | |---|---|---| | `authCode` | yes | Pairing code. Short codes (1–4 chars) trigger discovery; a 6-digit code completes pairing. | | `name` | no | Device name stored on successful pair. Default: `JTL-POS`. | ### Flow 1. **Discovery** — call with a short `authCode` (length 1–4). Response includes `authToken`, certificate fingerprints, and `mandantId`. `mandantName` / `mandantDatabase` are `null`. 2. **Pair** — call with the 6-digit pairing code. On success the code is revoked and the device is registered. Response includes `mandantName` and `mandantDatabase`. ### Success (200) ```json { "authCode": "307018", "authToken": "df40ad2067954646abb0499548a52241", "certificateFingerprint": "...", "certificateSerialNumber": "...", "mandantId": "1", "mandantName": "eB-Standard", "mandantDatabase": "eazybusiness", "serverFingerprint": null, "name": null, "serverTimestamp": "2026-07-20 15:00:00" } ``` ### Errors | Status | When | |---|---| | 400 | Wrong 6-digit code (`Der Authentifizierungscode ist falsch.`) | | 400 | Missing / invalid code (`Keinen passenden Authentifizierungscode gefunden.`) | --- ## `GET /v1/init` — Sync status Returns how many entities changed since each cursor. Poll this; only fetch list endpoints when the matching `*_count` is greater than `"0"`. ### Query parameters | Param | Default | Cursor for | |---|---|---| | `lastChangedProduct` | `0` | Products | | `lastChangedCategory` | `0` | Categories | | `lastChangedCustomerGroup` | `0` | Customer groups | | `lastChangedCompositeProduct` | `0` | Composite (Stückliste) products | | `lastChangedDeletedEntity` | `0` | Deleted entities | ### Response (200) ```json { "version": "1.10.12.0", "product_count": "3", "category_count": "0", "customer_count": "0", "customerGroup_count": "1", "compositeProduct_count": "0", "configurationGroup_count": "0", "configurationItem_count": "0", "deletedEntity_count": "0", "max_orderId_count": "100" } ``` | Field | Meaning | |---|---| | `*_count` | Number of rows with `lastChanged` **strictly greater than** the matching cursor | | `max_orderId_count` | Highest POS order id already mapped for this shop (`Pos.tAuftragMapping`) | | `customer_count`, `configurationGroup_count`, `configurationItem_count` | Always `"0"` (not implemented) | --- ## Catalog sync endpoints Shared pattern for list endpoints: 1. Call with the same cursor used on `/v1/init`. 2. Results are ordered by `lastChanged` ascending. 3. After each page, set the cursor to the highest `lastChanged` in the batch. 4. Repeat until `/v1/init` reports a count of `"0"`. ### `GET /v1/category` | Param | Default | |---|---| | `lastChangedCategory` | `0` | | `limit` | `20` | ```json [ { "_id": "12", "imghash": "abc...", "imgsrc": "abc...", "name": "Beverages", "pid": "0", "discounts": [], "sort": "10", "lastChanged": "24001", "updated_at": "2026-07-20 15:00:00", "created_at": "2026-07-20 15:00:00" } ] ``` - `pid` is `"0"` for top-level categories (under the shop root). - `imghash` / `imgsrc` are image content hashes for `/v1/cimage`. ### `GET /v1/product` | Param | Default | |---|---| | `lastChangedProduct` | `0` | | `limit` | `20` | ```json [ { "_id": "1234", "sku": "ART-001", "barcode": "4006381333931", "name": "Example product", "tax_rate": "19", "price": "11.90", "created_at": "2025-01-15 10:00:00", "lastChanged": "24609", "categories_id": "12", "categories": [{ "categoryId": "12" }], "prices": [ { "customerGroupId": "1", "customerId": "0", "price": "11.90", "quantity": "0" } ], "imghash": "def...", "imgsrc": "def...", "is_parent": "0", "parent": "0", "variants": "", "isCompositeProduct": "0", "attributes": [ { "aname": "Color", "aprice": "0.0", "asort": "1", "atype": "1", "agroup": "JTL-POS" } ], "deposit": "0", "deposit_name": "", "d_price": "0.0" } ] ``` Notable fields: | Field | Notes | |---|---| | `price` / `prices[].price` | Gross prices (net × tax) | | `prices` | One entry per customer group | | `isCompositeProduct` | `"1"` if the article is a Stückliste parent | | `imghash` | Pass to `/v1/pimage?path=...` | | Deposit fields | Present when JTL-POS Pfand attributes are set | | `attributes` | Article attributes from `tArtikelAttribut` (incl. Pfand) | Many other product fields are filled with static defaults (`sort`, `use_stock`, `unit`, etc.) for JTL-POS compatibility. **Node only:** `prices[]` applies per–customer-group net overrides from `tPreis` / `tPreisDetail`. C++ currently fills every group with the base gross price. ### `GET /v1/productcomposite` BOM rows for composite (Stückliste) parents. | Param | Default | |---|---| | `lastChangedCompositeProduct` | `0` | | `limit` | `100` | ```json [ { "productId": "100", "productIdComponent": "200", "quantity": "2.00", "lastChanged": "25000" } ] ``` ### `GET /v1/customergroup` | Param | Default | |---|---| | `lastChangedCustomerGroup` | `0` | No `limit` — returns all groups newer than the cursor. ```json [ { "customerGroupId": "1", "name": "Endkunde", "standard": "1", "discountPercent": "0.00", "lastChanged": "100" } ] ``` ### `GET /v1/deletedentity` | Param | Default | |---|---| | `lastChangedDeletedEntity` | `0` | | `limit` | `600` | ```json [ { "entityId": "123", "entityType": "1", "lastChanged": "208980" } ] ``` | `entityType` | Entity to remove locally | |---|---| | `1` | Product | | `2` | Category | | `7` | Composite product | --- ## Images ### `GET /v1/pimage` — Product image ### `GET /v1/cimage` — Category image | Param | Required | Notes | |---|---|---| | `path` | yes | Image hash from `imghash` / `imgsrc` | | `size` | no | **C++ only.** Target max dimension in px (default `200`). Use `0` for the full unresized image. Node always serves a ≤200px resize and ignores `size`. | **Success (200):** binary image body (`image/jpeg`, `image/png`, …), resized by the server. **Errors:** | Status | Body | |---|---| | 400 | `{ "Message": "Missing required query parameter 'path'." }` | | 404 | `{ "Message": "No image was found for path '...'" }` | --- ## `POST /v1/order` — Submit orders Creates Wawi sales orders (`Verkauf.tAuftrag`), maps POS ids, optionally delivers stock, and records payments. ### Request ```json { "orders": [ { /* Order */ } ] } ``` ### Order object | Field | Type | Notes | |---|---|---| | `externalId` | string | POS order id. Used for idempotency via `Pos.tAuftragMapping`. | | `externalOrderNumber` | string | Receipt / external number stored on the Auftrag | | `creationDate` | string | `YYYY-MM-DD HH:mm:ss` (local server time). Fallback: now | | `currencyIso` | string | Default `EUR` | | `paymentMethodName` | string | Zahlungsart name (e.g. `BAR`); created if missing | | `shippingName` | string | Versandart name; default lookup `Selbstabholer` | | `customerNumber` | string | Existing customer number, or empty/`0` for walk-in | | `billingAddress` | object | See address fields below | | `shippingAddress` | object | See address fields below | | `orderItems` | array | Line items | | `payments` | array | Payments to apply | | `settings.deliver` | string/bool | Default deliver on create (`1`/`true`). Set `0`/`false` to skip | | `settings.importSetting` | string | `0` → read-only order, no Wawi invoice | | `settings.invoiceSetting` | string | Bit flags; bit 0 enables Wawi invoice path | ### Address fields Used on `billingAddress` / `shippingAddress`: `firstName`, `lastName`, `company`, `street`, `zipCode`, `city`, `countryIso`, `phone`, `fax`, `email`, `mobile`, `salutation`, `title`, `state`, `extraAddressLine`, `addressAddition`, `discount`, `birthday`, `customerGroupId`, `debtorNumber` Walk-in: omit `customerNumber` (or `"0"`) and use a minimal billing address (`lastName` often `Laufkunde`). ### Order item fields | Field | Notes | |---|---| | `externalId` | POS line id (mapped in `Pos.tAuftragPositionMapping`) | | `sku` | Article number; looked up in `tArtikel` | | `name` | Display name | | `quantity` | Quantity | | `priceNet` / `priceGross` | Unit prices; net derived from gross + vat if omitted | | `vat` | VAT percent (e.g. `"19.00"`) | | `discountPercent` | Line discount | | `unit` | Unit label | | `note` | Position note | | `type` | `2` = shipping line; otherwise article/free position | | `isReturn` | `"1"` for returns | If no shipping line (`type: "2"`) is present and the order has sale items, the server may inject a `Selbstabholer` shipping position automatically. ### Payment fields | Field | Notes | |---|---| | `paymentId` | Only payments with id `≤ 0` / empty are inserted (new payments) | | `paymentMethodName` | Overrides order-level method if set | | `amount` | Payment amount | ### Response Array of per-order results. HTTP **200** if all succeed; **500** if any failed or was skipped as already mapped. ```json [ { "status": "OK", "externalOrderId": "99", "message": "" }, { "status": "ERROR", "externalOrderId": "100", "message": "..." } ] ``` | `status` | Meaning | |---|---| | `OK` | Order created | | `ERROR` | Create failed, **or** order already mapped (idempotent skip) | Invalid JSON body → `500` with `[]`. ### Minimal example ```json { "orders": [ { "externalId": "99", "externalOrderNumber": "T00100", "creationDate": "2026-07-20 15:01:41", "currencyIso": "EUR", "paymentMethodName": "BAR", "customerNumber": "0", "shippingName": "", "billingAddress": { "lastName": "Laufkunde", "countryIso": "DE" }, "shippingAddress": { "lastName": "Laufkunde", "countryIso": "DE" }, "orderItems": [ { "externalId": "146", "sku": "ART-001", "name": "Example product", "quantity": "1", "priceNet": "10.00", "priceGross": "11.90", "vat": "19.00", "type": "1", "isReturn": "0", "discountPercent": "0" } ], "payments": [ { "paymentId": "", "paymentMethodName": "BAR", "amount": "11.90" } ], "settings": { "importSetting": "0", "invoiceSetting": "0", "deliver": "1" } } ] } ``` --- ## Errors (general) | Status | Body | |---|---| | 404 | `{ "Message": "No HTTP resource was found that matches the request URI '...'." }` | | 500 | `{ "Message": "" }` (unhandled handler errors) | --- ## Endpoint index | Method | Path | Role | |---|---|---| | `GET` | `/v1/client` | Pairing / discovery | | `GET` | `/v1/init` | Change counts for all entity cursors | | `GET` | `/v1/category` | Category deltas | | `GET` | `/v1/product` | Product deltas | | `GET` | `/v1/productcomposite` | Composite BOM deltas | | `GET` | `/v1/customergroup` | Customer group deltas | | `GET` | `/v1/deletedentity` | Deletion deltas | | `GET` | `/v1/pimage` | Product image by hash | | `GET` | `/v1/cimage` | Category image by hash | | `POST` | `/v1/order` | Create order(s) | Handlers: Node [`src/endpoints/`](src/endpoints/), C++ [`jtlsrv-cpp/src/endpoints/`](jtlsrv-cpp/src/endpoints/). --- ## Node vs C++ Both expose the same route table, pairing flow, cursor sync protocol, and order request/response contract. A client written against this document works with either binary. ### Same (client-visible) | Area | Behavior | |---|---| | Routes | Identical methods and paths (`/v1/...` and `/api/v1/...`) | | Pairing | Short `authCode` → discovery; 6-digit → pair / revoke / register | | Init / list params | Same cursor names and default `limit` values | | Category / composite / deleted | Same JSON field names and cursor semantics | | Order | Same `{ "orders": [...] }` body, result array, `OK` / `ERROR`, HTTP 200 vs 500 | | Auth | Neither validates `authToken` on sync/order after pairing | ### Differences that affect clients | Area | Node | C++ | |---|---|---| | **Group prices** | Per–customer-group overrides from `tPreis` | Every group gets the base gross price | | **Image `size`** | Ignored; always resize ≤200px | Honored (`size`, default `200`; `0` = full) | | **`discountPercent`** | Fixed 2 decimals (`"0.00"`) | `std::to_string` of the double (e.g. `"0.000000"`) | | **Init when DB is down** | Handler throws → HTTP 500 | Returns all counts as `"0"` (HTTP 200) | | **Product `created_at`** | Formatted `YYYY-MM-DD HH:mm:ss` | Whatever ODBC returns for `dErstelldatum` | ### Safe client strategy - Treat optional product fields (per-group price overrides) as best-effort when talking to C++. - Prefer `price` for the default gross; do not assume `prices[]` differ by group unless you know you are on Node. - Call images with `?path=` only; omit `size` unless you need C++-specific sizing. - Parse `discountPercent` as a number, not a fixed-format string. - On init, treat HTTP 500 and all-zero counts similarly as “nothing to sync / unavailable”.