15 KiB
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/ |
| C++ | jtlsrv-cpp/ |
jtlsrv-cpp/src/endpoints/ |
Clients talk to either over the same paths and shapes. See 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 for the cursor / row-version model in detail.
Demo mode
Node can serve a generated in-memory catalog without MSSQL.
- Generate artifacts once:
npm run demo:generate
Writesdemo/catalog.jsonanddemo/images/<hash>.jpg(≥1000 products, 3–4 category levels, variants, real photos). - Set
DEMO_MODE=truein.env(see.env.example). - Start the server as usual (
npm start). Pairing and all sync endpoints work; orders are logged and return syntheticOKresults.
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
- Discovery — call with a short
authCode(length 1–4). Response includesauthToken, certificate fingerprints, andmandantId.mandantName/mandantDatabasearenull. - Pair — call with the 6-digit pairing code. On success the code is revoked and the device is registered. Response includes
mandantNameandmandantDatabase.
Success (200)
{
"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)
{
"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:
- Call with the same cursor used on
/v1/init. - Results are ordered by
lastChangedascending. - After each page, set the cursor to the highest
lastChangedin the batch. - Repeat until
/v1/initreports a count of"0".
GET /v1/category
| Param | Default |
|---|---|
lastChangedCategory |
0 |
limit |
20 |
[
{
"_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"
}
]
pidis"0"for top-level categories (under the shop root).imghash/imgsrcare image content hashes for/v1/cimage.
GET /v1/product
| Param | Default |
|---|---|
lastChangedProduct |
0 |
limit |
20 |
[
{
"_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 |
[
{
"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.
[
{
"customerGroupId": "1",
"name": "Endkunde",
"standard": "1",
"discountPercent": "0.00",
"lastChanged": "100"
}
]
GET /v1/deletedentity
| Param | Default |
|---|---|
lastChangedDeletedEntity |
0 |
limit |
600 |
[
{ "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
{
"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.
[
{ "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
{
"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/, C++ 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
pricefor the default gross; do not assumeprices[]differ by group unless you know you are on Node. - Call images with
?path=<hash>only; omitsizeunless you need C++-specific sizing. - Parse
discountPercentas a number, not a fixed-format string. - On init, treat HTTP 500 and all-zero counts similarly as “nothing to sync / unavailable”.