pingen2-sdk-js
v0.3.0
Published
A lightweight, fully typed Node.js SDK for the Pingen REST API
Maintainers
Readme
pingen2-sdk-js
A lightweight, fully typed Node.js SDK for the Pingen REST API.
Table of contents
- Requirements
- Installation
- Quick start
- Authentication
- Resources
- Typed responses
- Error handling
- Configuration
- Scripts
- Development
- Versioning
- License
Requirements
- Node.js 18+
- TypeScript 5+ (optional — full typings included)
- A Pingen account with OAuth credentials (how to obtain)
Installation
npm install pingen2-sdk-jsQuick start
Upload a PDF and send it as a physical letter in one call:
import {
PingenClient,
AddressPosition,
DeliveryProduct,
PrintMode,
PrintSpectrum,
type LetterAttributes,
} from 'pingen2-sdk-js';
const { PINGEN_CLIENT_ID, PINGEN_CLIENT_SECRET, PINGEN_ORG_ID } = process.env;
if (!PINGEN_CLIENT_ID || !PINGEN_CLIENT_SECRET || !PINGEN_ORG_ID) {
throw new Error('Missing PINGEN_CLIENT_ID / PINGEN_CLIENT_SECRET / PINGEN_ORG_ID');
}
const client = new PingenClient(PINGEN_CLIENT_ID, PINGEN_CLIENT_SECRET);
const res = await client.letters(PINGEN_ORG_ID).uploadAndCreate({
filePath: './invoice.pdf',
fileOriginalName: 'invoice.pdf',
addressPosition: AddressPosition.Left,
autoSend: true,
deliveryProduct: DeliveryProduct.Fast,
printMode: PrintMode.Simplex,
printSpectrum: PrintSpectrum.Color,
});
const letter = res.toResource<LetterAttributes>();
console.log(`letter ${letter.id} → status ${letter.attributes.status}`);That single uploadAndCreate call does three things: requests a signed upload URL from Pingen, PUTs the PDF to it, and creates the letter resource with auto_send=true so Pingen prints and dispatches it immediately. Drop autoSend: true (and the three print/delivery fields) if you want to inspect the letter before sending — then call letters.send({ letterId, … }) later.
The client handles OAuth lazily: the first request fetches a token, subsequent requests reuse it from cache, and a 401 triggers an automatic refresh.
Authentication
PingenClient accepts credentials in two equivalent forms — pick whichever reads cleaner at the call site.
Positional (simple case)
new PingenClient('CLIENT_ID', 'CLIENT_SECRET'); // production
new PingenClient('CLIENT_ID', 'CLIENT_SECRET', true); // stagingConfig object (full control)
new PingenClient({
clientId: 'CLIENT_ID',
clientSecret: 'CLIENT_SECRET',
useStaging: true,
maxAttempts: 5,
timeoutMs: 30_000,
uploadTimeoutMs: 120_000,
});Optional: pre-fetch a token before the first request (e.g. before a long-running batch flow):
await client.ensureToken();Resources
Organisations
import { OrganisationAttributes } from 'pingen2-sdk-js';
const orgs = (await client.organisations().getCollection()).toCollection<OrganisationAttributes>();
const org = (await client.organisations().getDetails(orgId)).toResource<OrganisationAttributes>();Letters
import {
AddressPosition,
DeliveryProduct,
PrintMode,
PrintSpectrum,
PaperType,
LetterAttributes,
} from 'pingen2-sdk-js';
const letters = client.letters(orgId);
// Upload + create (no auto-send)
const res = await letters.uploadAndCreate({
filePath: './invoice.pdf',
fileOriginalName: 'invoice.pdf',
addressPosition: AddressPosition.Left,
autoSend: false,
});
const letter = res.toResource<LetterAttributes>();
// Upload + create + auto-send
await letters.uploadAndCreate({
filePath: './invoice.pdf',
fileOriginalName: 'invoice.pdf',
addressPosition: AddressPosition.Left,
autoSend: true,
deliveryProduct: DeliveryProduct.Fast,
printMode: PrintMode.Simplex,
printSpectrum: PrintSpectrum.Color,
});
// Send an existing letter
await letters.send({
letterId,
deliveryProduct: DeliveryProduct.Fast,
printMode: PrintMode.Simplex,
printSpectrum: PrintSpectrum.Color,
});
// Cancel / delete / edit / download
await letters.cancel(letterId);
await letters.delete(letterId);
await letters.edit(letterId, [PaperType.Normal, PaperType.Qr]);
const pdf = await letters.getFile(letterId);
// Calculate price
const price = (
await letters.calculatePrice({
country: 'CH',
paperTypes: [PaperType.Normal],
printMode: PrintMode.Simplex,
printSpectrum: PrintSpectrum.Color,
deliveryProduct: DeliveryProduct.Fast,
})
).toResource<{ currency: string; price: number }>();Batches
import {
AddressPosition,
BatchIcon,
GroupingType,
SplitType,
PrintMode,
PrintSpectrum,
DeliveryProduct,
BatchAttributes,
createBatchDeliveryProduct,
} from 'pingen2-sdk-js';
const batches = client.batches(orgId);
const res = await batches.uploadAndCreate({
filePath: './letters.pdf',
fileOriginalName: 'letters.pdf',
name: 'August invoices',
icon: BatchIcon.Campaign,
addressPosition: AddressPosition.Left,
groupingType: GroupingType.Merge,
splitType: SplitType.Page,
});
await batches.send({
batchId,
deliveryProducts: [
createBatchDeliveryProduct('CH', DeliveryProduct.Fast),
createBatchDeliveryProduct('DE', DeliveryProduct.Cheap),
],
printMode: PrintMode.Duplex,
printSpectrum: PrintSpectrum.Color,
});Events
import { EventAttributes } from 'pingen2-sdk-js';
const events = (await client.letterEvents(orgId).getCollection(letterId)).toCollection<EventAttributes>();
await client.letterEvents(orgId).getIssueCollection();
await client.letterEvents(orgId).getDeliveredCollection();
await client.batchEvents(orgId).getCollection(batchId);Webhooks
import { WebhookEventCategory, WebhookAttributes, constructWebhookEvent } from 'pingen2-sdk-js';
const webhooks = client.webhooks(orgId);
const wh = (
await webhooks.create({
eventCategory: WebhookEventCategory.Delivered,
url: 'https://myapp.com/webhook',
signingKey: 'my-signing-key',
})
).toResource<WebhookAttributes>();
await webhooks.delete(wh.id);
// Verify incoming webhook signature (constant-time comparison)
const event = constructWebhookEvent(rawBody, signatureHeader, 'my-signing-key');Ebills & Emails
import { EbillAttributes, EmailAttributes, PresetRelationship } from 'pingen2-sdk-js';
const ebill = (
await client.ebills(orgId).uploadAndCreate({
filePath: './invoice.pdf',
fileOriginalName: 'invoice.pdf',
autoSend: true,
metaData: {
invoice_number: 'INV-001',
invoice_date: '2025-01-01',
invoice_due_date: '2025-02-01',
recipient_identifier: '411000100',
},
preset: new PresetRelationship('preset-uuid'),
})
).toResource<EbillAttributes>();
const email = (
await client.emails(orgId).uploadAndCreate({
filePath: './invoice.pdf',
fileOriginalName: 'invoice.pdf',
autoSend: true,
metaData: {
sender_name: 'ACME GmbH',
recipient_email: '[email protected]',
recipient_name: 'Client AG',
reply_email: '[email protected]',
reply_name: 'ACME Billing',
subject: 'Invoice #123',
content: 'Please find your invoice attached.',
},
})
).toResource<EmailAttributes>();Typed responses
Every API call returns a PingenResponse. Cast it with .toResource<T>() or .toCollection<T>() for type-safe access:
const res = (await client.letters(orgId).getDetails(letterId)).toResource<LetterAttributes>();
res.id; // string
res.resourceType; // 'letters'
res.attributes.status; // typed
res.relationships; // JSON:API relationships
res.included; // included resources
res.statusCode; // HTTP status
res.headers; // response headers
const col = (await client.letters(orgId).getCollection({ page: { number: 2 } })).toCollection<LetterAttributes>();
col.meta?.total;
col.meta?.current_page;
col.links?.next;Error handling
Every SDK failure extends PingenError. HTTP responses dispatch to a per-status subclass so callers can match the cases they care about; everything else (local validation, missing credentials, webhook signature mismatch) shares the same base.
import {
PingenError,
PingenNotFoundError,
PingenRateLimitError,
PingenUnprocessableEntityError,
ValidationError,
AuthenticationError,
} from 'pingen2-sdk-js';
try {
await client.letters(orgId).delete(letterId);
} catch (e) {
if (e instanceof ValidationError) {
console.log('Local check failed:', e.field, e.message);
} else if (e instanceof PingenNotFoundError) {
console.log('Letter no longer exists');
} else if (e instanceof PingenRateLimitError) {
await sleep(e.retryAfterMs ?? 1000);
} else if (e instanceof PingenUnprocessableEntityError) {
console.log('API rejected payload:', e.body);
} else if (e instanceof PingenError) {
console.log(e.status, e.body, e.requestId);
}
}Every error carries .status, .body, .requestId, and (for 429/503) .retryAfterMs. Status-specific subclasses available: PingenBadRequestError (400), PingenUnauthorizedError (401), PingenForbiddenError (403), PingenNotFoundError (404), PingenMethodNotAllowedError (405), PingenNotAcceptableError (406), PingenConflictError (409), PingenGoneError (410), PingenUnsupportedMediaError (415), PingenUnprocessableEntityError (422), PingenDependencyError (424), PingenRateLimitError (429), PingenServerError (500), PingenServiceUnavailableError (503), PingenTimeoutError.
The SDK retries 429 and 5xx automatically (with jittered exponential backoff and a reused Idempotency-Key for mutations). The retry budget defaults to 3 attempts and is configurable via maxAttempts.
Configuration
const client = new PingenClient({
clientId: 'CLIENT_ID',
clientSecret: 'CLIENT_SECRET',
useStaging: false, // default false
maxAttempts: 3, // retry budget for 429/5xx
timeoutMs: 20_000, // per-request timeout
uploadTimeoutMs: 60_000, // PUT to signed URL
});The SDK identifies itself with a User-Agent: PINGEN.SDK.NODE header on every request.
Scripts
npm test # run tests with coverage
npm run build # clean + compile TypeScript
npm run lint # ESLint
npm run format # Prettier (auto-fix)
npm run format:check # Prettier (check only)
npm run clean # remove dist/Development
The project is developed exclusively inside Docker — there is no supported local-Node workflow. All scripts (npm test, npm run lint, npm run build, npm run manual-test) are run through docker compose exec.
docker compose build
docker compose up -d
docker compose exec nodejs-sdk npm ci
docker compose exec nodejs-sdk npm test
docker compose downVersioning
Follows semver. The published version is derived automatically from the latest git tag — tag a release (git tag v1.2.3 && git push --tags) and npm publish will set package.json accordingly via prepublishOnly.
License
BSD 3-Clause — see LICENSE
Copyright (c) 2026, Pingen GmbH.
