Files
jtlPosSync/API.md
2026-07-27 01:14:36 +02:00

15 KiB
Raw Permalink 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.


Demo mode

Node can serve a generated in-memory catalog without MSSQL.

  1. Generate artifacts once: npm run demo:generate
    Writes demo/catalog.json and demo/images/<hash>.jpg (≥1000 products, 34 category levels, variants, real photos).
  2. Set DEMO_MODE=true in .env (see .env.example).
  3. Start the server as usual (npm start). Pairing and all sync endpoints work; orders are logged and return synthetic OK results.

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 (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/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”.