439 lines
12 KiB
Markdown
439 lines
12 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.
|
||
|
||
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/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 |
|
||
|
||
Many other product fields are filled with static defaults (`sort`, `use_stock`, `unit`, etc.) for JTL-POS compatibility.
|
||
|
||
### `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 |
|
||
|---|---|
|
||
| `path` | yes — image hash from `imghash` / `imgsrc` |
|
||
|
||
**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 live in [`src/endpoints/`](src/endpoints/).
|