15 KiB
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:
GETonly (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:
initwith all cursors at latest values → counts are"0"when fully synced- If a count is
> 0, POS fetches the corresponding entity endpoint - POS updates its local cursor to the highest
lastChangedreceived - 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
- Run
npm run devto start both servers and the web UI. - Point POS at
https://<host>:8086and pair with code307018(or generate a new one in the web UI). - Manage sample data through the web admin; counts in
/api/v1/initupdate automatically. - If the POS requests an unknown path, replay it against the upstream server with
test-client.js, then extendlib/src/jtl-server.jsor the relevant action inlib/src/store.js. - For list endpoints, ensure records are filtered by
lastChanged > cursorand honorslimit. - Keep IDs and counts as strings to match upstream.