Files
jtlPosSync/API.md
seb 5951e10ea5 fix(delivery): deliver orders with father-articles and no-payment orders
- 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).
2026-08-13 13:19:47 +02:00

15 KiB
Raw Blame History

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.


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)

{
  "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)

{
  "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)

{
  "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
[
  {
    "_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
[
  {
    "_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 percustomer-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 Percustomer-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”.