2026-06-21 09:09:06 +02:00
2026-06-21 09:09:06 +02:00
2026-06-21 09:09:06 +02:00
2026-06-21 09:09:06 +02:00
2026-06-21 09:09:06 +02:00
2026-06-21 09:09:06 +02:00
2026-06-21 09:09:06 +02:00
2026-06-21 09:09:06 +02:00
2026-06-21 09:09:06 +02:00
2026-06-21 09:09:06 +02:00

jtlsrv

Self-signed HTTPS debug server for JTL-POS pairing and sync, now split into a reusable library and a web admin UI.

Project layout

.
├── lib/                    # jtl-pos-server library
│   ├── src/
│   │   ├── index.js        # public exports (createStore, createJtlPosServer, createAdminServer, actions, seed)
│   │   ├── store.js        # in-memory state + reducer actions
│   │   ├── actions.js      # action creators for CRUD + pairing
│   │   ├── seed.js         # sample data
│   │   ├── jtl-server.js   # JTL-POS HTTPS protocol handler
│   │   └── admin-server.js # Express admin API (/admin/api)
│   └── package.json
├── web/                    # Vite + React + MUI admin UI
│   ├── src/
│   │   ├── main.jsx
│   │   ├── App.jsx
│   │   ├── api.js
│   │   ├── components/CrudTable.jsx
│   │   └── pages/*.jsx
│   ├── package.json
│   └── vite.config.js
├── certs/                  # self-signed TLS certificate
├── server.js               # thin wrapper: HTTPS POS server + admin API
├── test-client.js          # probe real upstream or local server
└── generate-cert.js        # create self-signed TLS cert

Quick start

npm install          # install root deps (express, concurrently, uuid, local lib)
cd web && npm install && cd ..
npm run cert         # once, before first start
npm run dev          # starts POS server + admin API + Vite dev server
  • POS HTTPS endpoint: https://localhost:8086
  • Admin JSON API: http://localhost:8087/admin/api
  • Admin web UI: http://localhost:5173

Environment variables:

Variable Default Purpose
PORT 8086 HTTPS POS server port
ADMIN_PORT 8087 Admin API / static UI port
AUTH_TOKEN 9a2e3036ed9c47e389741d9dbb7590e9 bearer token issued to paired devices
PAIRING_CODE 307018 pre-registered 6-digit pairing code

Production build of the web UI

npm run build        # builds web/ into web/dist
npm start            # starts POS + admin API and serves web/dist

After npm run build, server.js serves the built admin SPA from web/dist on ADMIN_PORT.

Using the web admin

Open http://localhost:5173 (dev) or http://localhost:8087 (built).

Page What you can do
Dashboard View entity counts and paired devices
Pairing See/generate 6-digit pairing codes, list and revoke paired devices
Products CRUD products; mark products as composite and assign component products
Categories CRUD category tree via pid
Customers CRUD customers
Customer Groups CRUD customer groups
Deleted View deletion events and manually record new deletions

The admin API lives under /admin/api and mutates the same in-memory store as the POS sync endpoint.

Programmatic use

const { createStore, createJtlPosServer, createAdminServer, actions } = require('./lib/src');

const store = createStore();
store.dispatch(actions.setPairingCode('307018'));

const posHandler = createJtlPosServer(store, { authToken: '...' });
https.createServer({ key, cert }, posHandler).listen(8086);

const { server: adminServer } = createAdminServer(store, { adminPort: 8087 });

Protocol overview

  • Transport: HTTPS (TLS required)
  • Base path: /api/v1/
  • Methods observed: GET only (empty body)
  • Content type: application/json; charset=utf-8
  • Numeric fields: counts and IDs are returned as strings, not JSON numbers

Request headers (JTL-POS client)

Header Example Notes
accept application/json
content-type application/json sent even on GET
cache-control no-cache
authorization Bearer <authToken> required after pairing
version 1.0.11.14 POS app version
system JTL-POS
charset utf-8
accept-encoding gzip upstream may gzip responses
user-agent Dalvik/2.1.0 … Android

Error responses

Unknown paths return 404 with:

{
  "Message": "No HTTP resource was found that matches the request URI 'https://…'."
}

Pairing errors (upstream, German) observed on /api/v1/client:

Situation Message
Step 2 before step 1 Die erste Anfrage wurde noch nicht erfolgreich durchgeführt.
Step 1 repeated Die erste Anfrage wurde bereits erfolgreich durchgeführt.
Wrong code Der Authentifizierungscode ist falsch. / Keinen passenden Authentifizierungscode gefunden.

Auth codes are single-use and expire quickly.


Flow 1: Pairing (/api/v1/client)

No Authorization header. Two-step GET handshake using a 6-digit code shown in JTL Wawi (or generated in the admin UI).

Step 1 — first 4 digits

GET /api/v1/client?authCode=3070&name=001

Response (fields vary; authCode is null on success):

{
  "authCode": null,
  "authToken": "9a2e3036ed9c47e389741d9dbb7590e9",
  "certificateFingerprint": "BC2114CF407A42724BEEF417960F76DCBF9DE879",
  "certificateSerialNumber": "00BFC8BEACDB981B165210EF111CB9D3",
  "mandantId": "1",
  "mandantName": null,
  "mandantDatabase": null,
  "serverFingerprint": "39-6D-BD-DE-F3-5C-5A-EA-C2-19-CF-EB-A7-A9-58-2F-20-3F-20-F7-3D-E6-CA-8E-AE-FD-28-30-37-A6-45-AE",
  "name": null,
  "serverTimestamp": "2026-06-16 16:37:53"
}

serverFingerprint is present in step 1; mandantName / mandantDatabase are null.

Step 2 — full 6 digits

GET /api/v1/client?authCode=307018&name=001
{
  "authCode": "307018",
  "authToken": "9a2e3036ed9c47e389741d9dbb7590e9",
  "certificateFingerprint": "BC2114CF407A42724BEEF417960F76DCBF9DE879",
  "certificateSerialNumber": "00BFC8BEACDB981B165210EF111CB9D3",
  "mandantId": "1",
  "mandantName": "eB-Standard",
  "mandantDatabase": "eazybusiness",
  "serverFingerprint": null,
  "name": null,
  "serverTimestamp": "2026-06-16 16:37:53"
}

authToken is the bearer token used for all subsequent sync calls. serverTimestamp format: YYYY-MM-DD HH:MM:SS.

Probe pairing:

node test-client.js 307018          # runs step 1 + step 2
node test-client.js 3070            # step 1 only

Flow 2: Sync

After pairing, the POS polls with Authorization: Bearer <authToken>.

/api/v1/init — sync manifest

Returns how many entities still need syncing, based on the client's stored cursors.

GET /api/v1/init?mandantId=1
  &lastChangedCategory=0
  &lastChangedCustomer=0
  &lastChangedCustomerGroup=0
  &lastChangedProduct=0
  &lastChangedConfigurationGroup=0
  &lastChangedConfigurationItem=0
  &lastChangedCompositeProduct=0
  &lastChangedDeletedEntity=0

Response (fresh mandant, all cursors at 0):

{
  "version": "1.10.12.0",
  "product_count": "3",
  "category_count": "2",
  "customer_count": "3",
  "customerGroup_count": "1",
  "compositeProduct_count": "1",
  "configurationGroup_count": "0",
  "configurationItem_count": "0",
  "deletedEntity_count": "1",
  "max_orderId_count": "0"
}

Each *_count is the number of records with lastChanged greater than the matching lastChanged* query parameter. Example: if the client has synced customer groups up to cursor 7323, pass lastChangedCustomerGroup=7323 and customerGroup_count becomes "0".

Typical poll sequence after initial sync:

  1. init with all cursors at latest values → counts are "0" when fully synced
  2. If a count is > 0, POS fetches the corresponding entity endpoint
  3. POS updates its local cursor to the highest lastChanged received
  4. Repeat

Probe:

node test-client.js init <authToken>

Incremental sync pattern

Entity list endpoints use the same cursor model:

  • Query param: lastChanged<EntityType>=<cursor> (client's last known value)
  • Response: JSON array of records where record.lastChanged > cursor
  • Empty array [] means the client is up to date
  • After sync, the client stores max(lastChanged) and sends that on the next poll

/api/v1/customergroup

GET /api/v1/customergroup?mandantId=1&lastChangedCustomerGroup=0
[
  {
    "customerGroupId": "1",
    "name": "Endkunden",
    "standard": "1",
    "discountPercent": "0.00",
    "lastChanged": "7323"
  }
]
lastChangedCustomerGroup Result
0 all groups changed since beginning
7323 [] (client has this revision)
7324 []

Probe:

node test-client.js customergroup <authToken>
node test-client.js customergroup <authToken> 7323

/api/v1/deletedentity

GET /api/v1/deletedentity?mandantId=1&limit=200&lastChangedDeletedEntity=0
[
  {
    "entityId": "2",
    "entityType": "6",
    "lastChanged": "8281"
  }
]
Field Meaning
entityId ID of the deleted record
entityType numeric type code (6 observed for a customer group)
lastChanged revision cursor for this deletion event
lastChangedDeletedEntity Result
0 pending deletions
8281 [] (client has processed this deletion)

limit caps how many records are returned per request (POS uses 200).

Probe:

node test-client.js deletedentity <authToken>
node test-client.js deletedentity <authToken> 8281

/api/v1/category

GET /api/v1/category?mandantId=1&limit=20&lastChangedCategory=0
[
  {
    "_id": "1",
    "imghash": null,
    "imgsrc": null,
    "name": "Haupt",
    "pid": "0",
    "discounts": [],
    "sort": "0",
    "updated_at": "2026-06-16 22:42:49",
    "created_at": "2026-06-16 22:42:49",
    "lastChanged": "10252"
  }
]

pid is the parent category ID ("0" = root). The server also serves a sample child category Getränke (_id: "2", pid: "1").

Probe: node test-client.js category <authToken>


/api/v1/product

GET /api/v1/product?mandantId=1&limit=20&lastChangedProduct=0

Large product objects; key fields:

Field Notes
_id product ID
name, sku, price, tax_rate basics
categories_id / categories category linkage
isCompositeProduct "1" if this product is a bundle/set
prices per customer group / customer overrides
lastChanged sync cursor

Upstream example products: a1 (normal), a2 (isCompositeProduct: "1"). The debug server also adds Cola 0,5l.

Composite definitions (which components make up a bundle) are not embedded in the product object — see /api/v1/productcomposite.

Probe: node test-client.js product <authToken>


/api/v1/customer

GET /api/v1/customer?mandantId=1&limit=20&lastChangedCustomer=0
[
  {
    "id": "1",
    "customerNumber": "0",
    "firstname": "",
    "lastname": "kjhkjh",
    "company": "kjhkjh",
    "city": "kjhkjhkjh",
    "country": "Deutschland",
    "customerGroupId": "1",
    "discount": "0.00",
    "lastChanged": "13246",
    "debtorNumber": "0"
  }
]

The debug server also includes sample customers Muster GmbH and Max Mustermann.

Probe: node test-client.js customer <authToken>


/api/v1/productcomposite — composite product components

There is no /api/v1/compositeproduct endpoint (returns 404). Composite bundles use this path instead:

GET /api/v1/productcomposite?mandantId=1&limit=20&lastChangedCompositeProduct=0
[
  {
    "productId": "2",
    "productIdComponent": "1",
    "quantity": "1.00",
    "lastChanged": "13227"
  }
]
Field Meaning
productId the composite/bundle product (a2, isCompositeProduct: "1")
productIdComponent component product included in the bundle
quantity how many units of the component

init.compositeProduct_count tracks pending rows here, separate from product_count.

Probe: node test-client.js productcomposite <authToken>


Observed sync order

After pairing, the POS roughly follows:

init → customergroup → init → category → init → product → init →
productcomposite → init → customer → init → deletedentity → init → …

init is called before and after each entity fetch. Counts in init drive which endpoint the POS calls next.


Implemented in server

Endpoint Sample data
/api/v1/client pairing flow
/api/v1/init dynamic counts from cursors
/api/v1/customergroup 1 group (Endkunden)
/api/v1/category 2 categories (Haupt + Getränke)
/api/v1/product 3 products (a1, a2 composite, Cola)
/api/v1/productcomposite 1 bundle link (a2 → a1)
/api/v1/customer 3 customers
/api/v1/deletedentity 1 deletion event

Admin API endpoints

All prefixed with /admin/api.

Method Path Description
GET /init counts + full state snapshot
GET /state current in-memory state
GET /categories list categories
POST /categories create category
PATCH /categories/:id update category
DELETE /categories/:id delete category
GET /products list products
POST /products create product
PATCH /products/:id update product
DELETE /products/:id delete product
GET /customers list customers
POST /customers create customer
PATCH /customers/:id update customer
DELETE /customers/:id delete customer
GET /customer-groups list customer groups
POST /customer-groups create group
PATCH /customer-groups/:id update group
DELETE /customer-groups/:id delete group
GET /product-composites list composite links
POST /product-composites add or update composite link
DELETE /product-composites/:parent/:component remove link
GET /pairing pending codes + paired devices
POST /pairing generate a new 6-digit code ({ name })
POST /pairing/revoke revoke a pending code
DELETE /devices/:token revoke a paired device
GET /deleted list deletion events
POST /deleted record a deletion ({ entityId, entityType })

Endpoints not yet implemented

init count field Endpoint Notes
configurationGroup_count /api/v1/configurationgroup returns [] upstream (no data yet)
configurationItem_count /api/v1/configurationitem returns [] upstream (no data yet)

Development workflow

  1. Run npm run dev to start both servers and the web UI.
  2. Point POS at https://<host>:8086 and pair with code 307018 (or generate a new one in the web UI).
  3. Manage sample data through the web admin; counts in /api/v1/init update automatically.
  4. If the POS requests an unknown path, replay it against the upstream server with test-client.js, then extend lib/src/jtl-server.js or the relevant action in lib/src/store.js.
  5. For list endpoints, ensure records are filtered by lastChanged > cursor and honors limit.
  6. Keep IDs and counts as strings to match upstream.
Description
No description provided
Readme 93 KiB
Languages
JavaScript 99.6%
HTML 0.4%