@vxnsin/jtl-client
v0.1.0
Published
Typed Node.js client and CLI for the on-premise JTL-Wawi REST API, generated from the official OpenAPI specification
Maintainers
Readme
@vxnsin/jtl-client
Typed Node.js client and command-line tool for the on-premise JTL-Wawi REST API (v1).
The client is generated from JTL's official OpenAPI specification and covers all 229 operations in 43 resources. On top of that it guards against the mistakes that cost the most time with this API: silently ignored filter parameters, empty 404 bodies, permission errors that look like authentication errors, and the manual app registration handshake.
- TypeScript source, ESM and CommonJS builds, bundled type definitions
- No runtime dependencies, Node.js 18 or later
jtlCLI for registration, diagnostics and quick lookups
The package targets the REST service that ships with JTL-Wawi (JTL.Wawi.Rest.exe).
It does not cover the JTL Cloud gateway or the GraphQL API introduced with
JTL-Wawi 2.0.
Documentation
- Installation: requirements, enabling the REST service, installing library and CLI
- Getting started: from an empty directory to the first request
- Configuration: every option, environment variable and default
- Command-line reference: all commands, flags and exit codes
- Troubleshooting: what each error means
The rest of this file covers the library in depth.
Contents
- Installation
- Command-line tool
- Library usage
- Configuration
- Filters
- Errors
- Pagination
- Scopes
- App registration
- API versions
- Behaviour worth knowing
- Development
Installation
npm install @vxnsin/jtl-clientCommand-line tool
The package installs a jtl command. To use it without installing:
npx @vxnsin/jtl-client helpSetting up a new instance takes three commands:
jtl init # asks for host, port and app ID, writes ./.env
jtl register # walks through the registration wizard, stores the API key
jtl doctor # checks configuration, connectivity, version and permissionsTypical jtl doctor output:
jtl doctor
✔ Base URL http://wawi01:5884/api/eazybusiness/v1
✔ App headers acme/shipping 1.0.0
✔ API key 3f2a********9c1d
✔ Instance JTL-Wawi 1.11.8, tenant eazybusiness
! API version This release ships REST API 1.1; the client is generated from 1.3. …
✔ Sales orders GET /salesOrders answered.
✔ Scopes 9 granted (details: jtl scopes)Commands
| Command | Purpose |
| ------------------------------------ | ------------------------------------------------------------ |
| init | Write host, port, tenant and app ID to .env |
| register | Register the app with JTL-Wawi and store the API key |
| register status [id] | Resume a registration that was interrupted |
| doctor | Check configuration, connectivity, version and permissions |
| info | Instance version and tenant (GET /info) |
| scopes [filter] [--missing] | Permission scopes and which ones the app holds |
| ops [filter] | Every operation the client knows, with its call name |
| discover [path…] | Probe read-only routes: available, missing scope, or unknown |
| order <number> [--lines] | Sales order by number, optionally with line items |
| delivery-note <order number> | Delivery note and packages of an order |
| customers [search] [--number] | Search customers |
| items [search] | Search items |
| stock <item id> | Stock per warehouse and storage location |
| get <path> [-q key=value]… [--all] | GET request to any route |
| call <resource.operation> [params] | Run any operation listed by jtl ops |
All commands accept --json for machine-readable output, -f/--field to choose
columns (dot paths such as Shipmentaddress.City work) and -n/--limit.
jtl help <command> shows the options of a single command.
register takes --preset shipping|read|all or explicit --scope/--mandatory
lists, which makes it scriptable. Without them it asks.
Settings
Settings are read from ./.env, then from JTL_* environment variables, then from
command-line flags; later sources win. --env <path> selects a different file, which
helps when working with several instances:
jtl doctor --env .env.stagingSafety
Every command except register and call only reads. When call runs an operation
other than GET, it prints the full request and asks for confirmation; in scripts it
refuses to run unless --yes is given.
jtl call salesOrders.querySalesOrderLineItems 4711
jtl call deliveryNotes.patchPackageData 1203 --body '{"TrackingID":"00340434161094042557"}'
jtl call returns.createReturn --body @return.json --yesregister stores the API key in the .env file and prints it masked. Use
--show-key to print it, or --no-save to print it once without writing anything.
The tool warns when the .env file is not listed in .gitignore.
Library usage
import { JtlClient } from '@vxnsin/jtl-client';
const jtl = new JtlClient({
host: 'wawi01',
apiKey: process.env.JTL_API_KEY,
appId: 'acme/shipping',
appVersion: '1.0.0',
});
const status = await jtl.diagnose();
if (!status.ok) throw new Error(status.message);
// Convenience methods
const order = await jtl.salesOrders.byNumber('AU-2026-10042');
const note = await jtl.deliveryNotes.byOrderNumber('AU-2026-10042');
const methods = await jtl.shippingMethods.byId();
// Generated methods, named after the operation IDs in the specification
const customers = await jtl.customers.queryCustomers({ searchKeyWord: 'Meier', pageSize: 20 });
const items = await jtl.items.queryItems({ manufacturerId: 4 });
const stock = await jtl.stocks.queryStocksPerItem({ itemId: 815 });
await jtl.salesOrders.triggerSalesOrderWorkflowEvent(17, { WorkflowEventId: 42 });CommonJS works the same way:
const { JtlClient, configFromEnv } = require('@vxnsin/jtl-client');
const jtl = new JtlClient(configFromEnv());Method names follow the operationId of the specification:
SalesOrderHeader_QuerySalesOrdersAsync becomes jtl.salesOrders.querySalesOrders().
Request and response types are generated as well, with enums as union types.
Hand-written additions:
| Resource | Methods |
| ----------------- | ------------------------------------------------------------------------------------- |
| salesOrders | byNumber, list, iterate |
| deliveryNotes | byOrderNumber, list, packages, postPackageData, patchPackageData, iterate |
| shippingMethods | byId (map of ID to shipping method) |
Helpers: looksAlreadyShipped, hasTracking, firstTrackingId, packageWeightKg,
isDateSet, paginate, collect, restApiVersionForWawi.
Configuration
| Option | Default | Notes |
| ---------------- | -------------- | -------------------------------------------------------- |
| baseUrl | | Complete base URL; takes precedence over the parts below |
| host | | Required unless baseUrl is set |
| port | 5884 | Chosen when the REST service is started (--port) |
| protocol | http | The local service does not speak HTTPS |
| tenant | eazybusiness | Fixed URL segment, not the company name |
| apiVersion | v1 | |
| basePath | /api | Change only behind a reverse proxy |
| apiKey | | From the app registration |
| appId | | Must match the registered app, otherwise 400 |
| appVersion | | Same |
| timeoutMs | 20000 | Per attempt |
| retries | 3 | GET only |
| fetch | global fetch | Inject for tests or proxies |
| grantedScopes | | Enables permission checks before a request is sent |
| normalizeDates | true | Turns 0001-01-01T00:00:00 into null |
| filterMode | strict | strict, warn or off |
| filters | {} | Override the allowed filters per endpoint |
configFromEnv() reads the same settings from environment variables; see
.env.example.
The REST service is started per instance, for example
JTL.Wawi.Rest.exe -w "Standard" -l 0.0.0.0 --port 5884 -d eazybusiness. The -d
value becomes the tenant segment of the URL, even if the database has another name.
Filters
The API ignores query parameters it does not know. A misspelled filter does not
return an empty result, it returns every record. On a large /salesOrders table that
ends in a timeout instead of an error.
The client therefore checks every query parameter against the list the specification
defines for that endpoint and throws a JtlFilterError before anything is sent:
await jtl.salesOrders.byNumber('AU-2026-10042'); // ?salesOrderNumber=…
await jtl.customers.queryCustomers({ number: '10042' }); // valid for /customers
await jtl.request('/salesOrders', { query: { number: 'AU-2026-10042' } });
// throws JtlFilterError: "number" is not a filter of /salesOrdersnumber is valid on /customers and wrong on /salesOrders, which is why the list
is kept per endpoint. Free-form requests through request() are checked as well; the
path is matched against the templates in the specification.
filterMode: 'warn' logs instead of throwing, 'off' disables the check. Endpoints
can be overridden individually:
new JtlClient({ ...config, filters: { '/salesOrders': ['salesOrderNumber', 'pageSize'] } });Errors
| Status | Meaning | Body |
| ------- | --------------------------------------------------------- | ------------- |
| 400 | X-AppID/X-AppVersion missing, or invalid request body | JSON |
| 401 | The app lacks the scope for this route | JSON or empty |
| 404 | The route does not exist on this Wawi version | empty |
| 503 | Right address, but the REST service is not running | empty |
| Timeout | Usually a full table scan caused by an unknown filter | |
import { JtlHttpError, JtlScopeError, JtlTimeoutError } from '@vxnsin/jtl-client';
try {
await jtl.salesOrders.byNumber(number);
} catch (err) {
if (err instanceof JtlScopeError) {
// scope not granted, detected before the request
} else if (err instanceof JtlHttpError) {
console.log(err.status, err.detail);
} else if (err instanceof JtlTimeoutError) {
// most likely a wrong filter name
}
}diagnose() maps the common failures to a kind: instance-down, wrong-protocol,
app-headers, scope, not-found, timeout or unreachable. TLS errors and
refused connections come with a message that names the likely cause.
Pagination
List endpoints return an envelope (TotalItems, PageNumber, PageSize, Items).
/shippingMethods returns a plain array instead; unwrapList() handles both.
paginate() walks any generated query method page by page:
import { collect, paginate } from '@vxnsin/jtl-client';
for await (const invoice of paginate(jtl.invoices.queryInvoices, { customerId: 4 })) {
// …
}
const first500 = await collect(paginate(jtl.items.queryItems, { manufacturerId: 4 }), 500);salesOrders.iterate() and deliveryNotes.iterate() do the same for the hand-written
resources. Without a filter they walk the whole table, so set a limit.
Scopes
Scope names follow the operation ID, <area>.<operation><entity> in lowercase:
SalesOrderHeader_QuerySalesOrdersAsync salesorder.querysalesorders
DeliveryNoteHeader_PostPackageDataForDeliveryNoteAsync deliverynote.postpackagedatafordeliverynoteEvery generated method carries its scope, so the scope list for a registration can be derived from the code that uses the client:
import { buildScopes } from '@vxnsin/jtl-client';
jtl.salesOrders.query.scope; // 'salesorder.querysalesorders'
buildScopes([jtl.customers.queryCustomers, jtl.items.getItem]);
// ['customer.querycustomers', 'item.getitem']SPEC_SCOPES lists every scope in the specification; SCOPES is a curated subset
that also contains a few scopes the specification omits. With grantedScopes set,
the client reports a missing permission before the request goes out, and
jtl.missingScopes() lists everything the current registration cannot do.
App registration
Registration needs someone at the JTL-Wawi desktop client at the same time:
Wawi: Admin → App-Registrierung → Hinzufügen → Weiter (the wizard now waits)
Code: POST /authentication no Authorization header, x-challengecode only
Wawi: app information → user → permissions → Fertigstellen
Code: GET /authentication/{id} returns Token.ApiKey and GrantedScopesjtl register drives this flow. In code:
import { JtlRegistration, SCOPES } from '@vxnsin/jtl-client';
const registration = new JtlRegistration({ host: 'wawi01', challengeCode: 'setup-2026' });
const request = await registration.begin({
AppId: 'acme/shipping',
DisplayName: 'Shipping',
Version: '1.0.0',
MandatoryApiScopes: [SCOPES.salesOrder.query],
OptionalApiScopes: [SCOPES.deliveryNote.query],
});
const { Token, GrantedScopes } = await registration.waitForToken(request.RegistrationRequestId);Easy to get wrong:
- The POST is not authenticated. There is no API key yet.
- The challenge code is free text of at most 30 characters, but it must be identical on the POST and the following GET.
RegChallengeIpMismatchis transient;status()retries it.- The app ID is permanent. Renaming the app means registering a new one.
- Users can deselect optional scopes. Read
GrantedScopesinstead of assuming that everything requested was granted. - An invalid scope name aborts the wizard;
begin()validates the names first.
API versions
Each JTL-Wawi release ships a specific REST API version (source):
| JTL-Wawi | REST API v1 | REST API v2 | | -------- | ----------- | ----------- | | 1.11.x | 1.1 | | | 2.0.x | 1.2 | | | 2.1.x | 1.3 | 2.0 | | 2.2.x | 1.4 | 2.1 | | 2.3.x | 1.5 | 2.2 |
The client is generated from v1.3. The v1 versions differ only in details: 1.1 lacks
a few optional fields (for example Return.TransmitToSalesChannel and
Supplier.LastChangedDate), 1.2 additionally has a delivery API and invoice
finalization, 1.4 adds the deprecated foreign-app registration routes, and 1.5 marks
every v1 route as deprecated in favour of v2. Routes an instance does not know answer
404; jtl discover shows which ones. jtl doctor compares the instance version with
the generated one.
To generate from a different version:
npm run sync:spec -- 1.1
npm run sync:spec -- --file ./spec.jsonThis rewrites src/generated/. Do not edit those files by hand.
Behaviour worth knowing
- Authentication is
Authorization: Wawi <key>, notBearer.X-AppIDandX-AppVersionare required on every request; without them the API answers 400. - Unknown routes answer 404 with an empty body. The client never parses an empty response as JSON.
- A 401 almost always means a missing scope, not a wrong key.
- Unset dates come back as
0001-01-01T00:00:00instead ofnull. The client normalizes them by default;isDateSet()checks raw values. - The delivery address field is
Shipmentaddress, with a lowercasea. The generated types catch the typoShipmentAddress. - Some queries take longer than 20 seconds on large databases. GET requests are retried on timeout; writes are never retried.
Development
npm install
npm run typecheck
npm test # builds, then runs the tests against an in-process mock server
npm run build # dist/index.mjs, dist/index.cjs, dist/index.d.ts, dist/cli.mjs
npm run jtl -- doctorThe tests do not need a JTL-Wawi instance.
