Files
jtlPosSync/API.md
2026-07-20 15:35:32 +02:00

439 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 (14 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 14). 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/).