postnl-client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NickTacke
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,206 @@
1
+ # postnl-client
2
+
3
+ A well-typed TypeScript client for the PostNL eCommerce API: V4-first, with the legacy endpoints supported where no V4 equivalent exists.
4
+
5
+ > Unofficial. Not affiliated with or endorsed by PostNL. You need your own PostNL API key.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ bun add postnl-client
11
+ ```
12
+
13
+ ```bash
14
+ npm install postnl-client
15
+ pnpm add postnl-client
16
+ yarn add postnl-client
17
+ ```
18
+
19
+ Requires Node 18+ (or Bun / Deno / any runtime with global `fetch` and `atob`). Ships ESM + CJS + types.
20
+
21
+ ## Quick start
22
+
23
+ ```ts
24
+ import { writeFileSync } from "node:fs";
25
+ import { PostNLClient } from "postnl-client";
26
+
27
+ const client = new PostNLClient({
28
+ apiKey: process.env.POSTNL_APIKEY!,
29
+ environment: "sandbox", // omit for "production"
30
+ });
31
+
32
+ // create + confirm a shipment in one call, then decode the label
33
+ const result = await client.shipping.create({
34
+ shipmentType: "parcel",
35
+ sender: {
36
+ customerNumber: "11223344",
37
+ customerCode: "DEVC",
38
+ address: { countryIso: "NL", city: "Hoofddorp", street: "Siriusdreef", houseNumber: "42", postalCode: "2132WT" },
39
+ },
40
+ receiver: {
41
+ address: { countryIso: "NL", city: "Utrecht", street: "Stationsplein", houseNumber: "1", postalCode: "3511ED" },
42
+ contact: { email: "buyer@example.com", firstName: "Jane", lastName: "Doe" },
43
+ },
44
+ labelSettings: { outputType: "pdf" },
45
+ });
46
+
47
+ const item = result.items[0];
48
+ console.log(item?.barcode);
49
+ const label = item?.labels?.[0];
50
+ // label.bytes() is a Uint8Array; works in node, bun, and deno
51
+ if (label) writeFileSync("label.pdf", label.bytes());
52
+ ```
53
+
54
+ See [`examples/`](./examples) for runnable scripts (`create-shipment.ts`, `track.ts`, `nearest-locations.ts`).
55
+
56
+ ## The V4-first surface
57
+
58
+ V4 is the default everywhere a V4 endpoint exists. Older operations that PostNL never migrated (tracking, delivery options, locations, checkout, address) are exposed directly under their namespace. Where both a V4 and a legacy variant exist, the legacy one lives under `.legacy`.
59
+
60
+ | Call | Method + endpoint | Notes |
61
+ | --- | --- | --- |
62
+ | `client.barcode.generate(input)` | `POST /shipment/delivery/v4/barcode` | V4 |
63
+ | `client.barcode.legacy.generate(input)` | `GET /shipment/v1_1/barcode` | legacy |
64
+ | `client.shipping.create(input)` | `POST /shipment/delivery/v4/labelconfirm` | V4 (label + confirm) |
65
+ | `client.shipping.label(input)` | `POST /shipment/delivery/v4/label` | V4 (label only) |
66
+ | `client.shipping.confirm(input)` | `POST /shipment/delivery/v4/confirm` | V4 |
67
+ | `client.shipping.legacy.label(input, { confirm? })` | `POST /shipment/v2_2/label` | legacy; `confirm` defaults `true` |
68
+ | `client.shipping.legacy.confirm(input)` | `POST /shipment/v2/confirm` | legacy |
69
+ | `client.return.generate(input)` | `POST /shipment/delivery/v4/return/generate` | V4 |
70
+ | `client.tracking.byBarcode(barcode, opts?)` | `GET /shipment/v2/status/barcode/{barcode}` | legacy-only |
71
+ | `client.tracking.byReference(referenceId, opts)` | `GET /shipment/v2/status/reference/{referenceId}` | legacy-only |
72
+ | `client.tracking.signature(barcode)` | `GET /shipment/v2/status/signature/{barcode}` | legacy-only |
73
+ | `client.tracking.updated(customerNumber, opts)` | `GET /shipment/v2/status/{customernumber}/updatedshipments` | legacy-only |
74
+ | `client.deliveryDate.calculate(input)` | `GET /shipment/v2_2/calculate/date/delivery` | legacy-only |
75
+ | `client.deliveryDate.sentDate(input)` | `GET /shipment/v2_2/calculate/date/shipping` | legacy-only |
76
+ | `client.timeframe.get(input)` | `GET /shipment/v2_1/calculate/timeframes` | legacy-only |
77
+ | `client.location.nearest(input)` | `GET /shipment/v2_1/locations/nearest` | legacy-only |
78
+ | `client.location.nearestByGeocode(input)` | `GET /shipment/v2_1/locations/nearest/geocode` | legacy-only |
79
+ | `client.location.area(input)` | `GET /shipment/v2_1/locations/area` | legacy-only |
80
+ | `client.location.lookup(input)` | `GET /shipment/v2_1/locations/lookup` | legacy-only |
81
+ | `client.checkout.get(input)` | `POST /shipment/v1/checkout` | legacy-only |
82
+ | `client.address.check(input)` | `GET /shipment/checkout/v1/postalcodecheck` | legacy-only, **production-only** |
83
+ | `client.request(args)` | any | raw escape hatch (see below) |
84
+
85
+ ## Environments
86
+
87
+ ```ts
88
+ new PostNLClient({ apiKey, environment: "sandbox" }); // https://api-sandbox.postnl.nl
89
+ new PostNLClient({ apiKey, environment: "production" }); // https://api.postnl.nl (default)
90
+ ```
91
+
92
+ `address.check` is production-only: calling it on `sandbox` rejects with a `PostNLError` before any request is sent.
93
+
94
+ ## Authentication
95
+
96
+ A single API key, sent as the `apikey` header on every request. Get one from the [PostNL developer portal](https://developer.postnl.nl/).
97
+
98
+ ```ts
99
+ new PostNLClient({ apiKey: process.env.POSTNL_APIKEY! });
100
+ ```
101
+
102
+ ## Error handling
103
+
104
+ Failed requests reject with a typed error. `PostNLApiError` is the base for all HTTP error responses and carries `status`, `code`, `detail`, and `raw`. Subclasses let you branch on the failure kind:
105
+
106
+ | Class | When |
107
+ | --- | --- |
108
+ | `PostNLAuthError` | 401 (bad / missing API key) |
109
+ | `PostNLRateLimitError` | 429 (has `retryAfter` in seconds when sent) |
110
+ | `PostNLBadRequestError` | 400 (validation / business errors) |
111
+ | `PostNLMethodNotAllowedError` | 405 |
112
+ | `PostNLServerError` | 5xx |
113
+ | `PostNLApiError` | any other non-2xx |
114
+
115
+ Two non-HTTP errors extend `PostNLError` directly: `PostNLValidationError` (response failed schema validation) and `PostNLTimeoutError`.
116
+
117
+ ```ts
118
+ import { PostNLClient, PostNLApiError, PostNLRateLimitError } from "postnl-client";
119
+
120
+ try {
121
+ await client.barcode.generate(/* ... */);
122
+ } catch (err) {
123
+ if (err instanceof PostNLRateLimitError) {
124
+ console.warn(`rate limited, retry after ${err.retryAfter}s`);
125
+ } else if (err instanceof PostNLApiError) {
126
+ console.error(`postnl ${err.status} (${err.code ?? "?"}): ${err.message}`);
127
+ } else {
128
+ throw err;
129
+ }
130
+ }
131
+ ```
132
+
133
+ ## Labels and signatures
134
+
135
+ Label and signature payloads come back base64-encoded. Each decodes to a small helper object:
136
+
137
+ ```ts
138
+ const label = result.items[0]?.labels?.[0];
139
+ if (label) {
140
+ label.base64; // raw base64 string
141
+ label.contentType; // e.g. "application/pdf" (derived from the output type)
142
+ label.bytes(); // Uint8Array, ready to write to disk or stream
143
+ }
144
+ ```
145
+
146
+ `client.tracking.signature(barcode)` returns the signature image the same way (`signature.signatureImage.bytes()`).
147
+
148
+ ## Configuration
149
+
150
+ ```ts
151
+ new PostNLClient({
152
+ apiKey,
153
+ environment: "production",
154
+ timeoutMs: 60_000, // per-attempt timeout (default 60s) -> PostNLTimeoutError
155
+ fetch: customFetch, // inject your own fetch (default: global fetch)
156
+ retry: {
157
+ maxRetries: 3, // default 3
158
+ backoffFactor: 2, // default 2 (exponential)
159
+ retryMethods: ["GET", "PUT"], // only idempotent methods by default
160
+ retryStatuses: [408, 413, 429, 500, 502, 503, 504, 521, 522, 524],
161
+ },
162
+ hooks: {
163
+ onRequest: (req) => console.debug(req.method, req.url),
164
+ onResponse: (res) => console.debug(res.status),
165
+ onError: (err) => console.error(err),
166
+ },
167
+ });
168
+ ```
169
+
170
+ Retries apply only to the methods in `retryMethods` (idempotent by default, so POSTs are never silently retried) and only for the listed statuses and transient network/timeout failures.
171
+
172
+ ## Wire quirks normalized for you
173
+
174
+ The PostNL API has an inconsistent wire format. This client absorbs that so you work with clean, typed, camelCase objects:
175
+
176
+ - numbers and booleans returned as strings (`"2"`, `"true"`) are coerced to real `number` / `boolean`
177
+ - single-value-or-array fields are always given to you as arrays
178
+ - `{ string: ... }` wrapper objects are unwrapped
179
+ - `dd-MM-yyyy[ HH:mm:ss]` dates are parsed to `Date`; `Date` inputs are formatted back to the wire format
180
+ - PascalCase wire keys become camelCase
181
+
182
+ ## Raw request escape hatch
183
+
184
+ For anything not yet wrapped, call the transport directly. Auth, retry, timeout, and hooks still apply; pass an optional Zod `schema` to validate/parse the response.
185
+
186
+ ```ts
187
+ const data = await client.request({
188
+ family: "legacy",
189
+ method: "GET",
190
+ path: "/shipment/v2/status/barcode/{barcode}",
191
+ pathParams: { barcode: "3SDEVC1234567" },
192
+ query: { detail: "true" },
193
+ });
194
+ ```
195
+
196
+ ## Releasing (maintainers)
197
+
198
+ Releases are driven by [Changesets](https://github.com/changesets/changesets) and the `release` GitHub Actions workflow, which publishes via npm [trusted publishing (OIDC)](https://docs.npmjs.com/trusted-publishers) — no `NPM_TOKEN` secret required.
199
+
200
+ 1. On npmjs.com, configure a trusted publisher for the package: org/user `NickTacke`, repository `postnl-client`, workflow `release.yml`, action `npm publish`.
201
+ 2. Merging a PR with a changeset to `main` opens an auto-generated "Version Packages" PR.
202
+ 3. Merging that "Version Packages" PR versions the package and publishes it to npm via short-lived OIDC credentials (with provenance).
203
+
204
+ ## License
205
+
206
+ MIT