- Treat an article that has become a father (nIstVater=1) as a free position (kArtikel=0) with the name kept: VersandIntern.vBestellPosOffen joins tArtikel with nIstVater=0, so such positions could never be reserved and failed with "insufficient stock after POS shortage booking" (e.g. Becks 42000030). - Recalculate tAuftragPositionEckdaten before deliverOrder so orders without payments get their positions reserved too (previously only insertPayment triggered the recalculation). Also adds debug logging across the delivery pipeline (reserve, commit, deliver, session, stock-shortage, create-order).
519 lines
15 KiB
Markdown
519 lines
15 KiB
Markdown
# 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://<host>:<port>` (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.
|
||
|
||
---
|
||
|
||
## `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/newpin` — Generate a pairing code
|
||
|
||
Creates a fresh, usable 6-digit pairing code on demand. Useful when the static `PAIRING_CODE` has already been consumed by a previous pair.
|
||
|
||
### Enabling
|
||
|
||
Disabled by default for security. Enable it by setting `NEWPIN_ENABLED=true` in the environment (`.env`). When disabled, the endpoint returns `403`.
|
||
|
||
### Response (200)
|
||
|
||
```json
|
||
{
|
||
"pin": "482913",
|
||
"name": "JTL-POS",
|
||
"serverTimestamp": "2026-08-13 12:00:00"
|
||
}
|
||
```
|
||
|
||
| Field | Meaning |
|
||
|---|---|
|
||
| `pin` | Fresh 6-digit code; valid until consumed by `GET /v1/client` pairing |
|
||
| `name` | Device name registered on pair (fixed `JTL-POS`) |
|
||
| `serverTimestamp` | Server time at generation |
|
||
|
||
### Errors
|
||
|
||
| Status | When |
|
||
|---|---|
|
||
| 403 | `NEWPIN_ENABLED` is not `true` |
|
||
|
||
---
|
||
|
||
## `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": "<error 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=<hash>` 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”.
|