npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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
  • jtl CLI 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

The rest of this file covers the library in depth.

Contents

Installation

npm install @vxnsin/jtl-client

Command-line tool

The package installs a jtl command. To use it without installing:

npx @vxnsin/jtl-client help

Setting 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 permissions

Typical 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.staging

Safety

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 --yes

register 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 /salesOrders

number 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.postpackagedatafordeliverynote

Every 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 GrantedScopes

jtl 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.
  • RegChallengeIpMismatch is transient; status() retries it.
  • The app ID is permanent. Renaming the app means registering a new one.
  • Users can deselect optional scopes. Read GrantedScopes instead 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.json

This rewrites src/generated/. Do not edit those files by hand.

Behaviour worth knowing

  • Authentication is Authorization: Wawi <key>, not Bearer. X-AppID and X-AppVersion are 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:00 instead of null. The client normalizes them by default; isDateSet() checks raw values.
  • The delivery address field is Shipmentaddress, with a lowercase a. The generated types catch the typo ShipmentAddress.
  • 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 -- doctor

The tests do not need a JTL-Wawi instance.

License

MIT