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

pingen2-sdk-js

v0.3.0

Published

A lightweight, fully typed Node.js SDK for the Pingen REST API

Readme

pingen2-sdk-js

A lightweight, fully typed Node.js SDK for the Pingen REST API.


Table of contents


Requirements

  • Node.js 18+
  • TypeScript 5+ (optional — full typings included)
  • A Pingen account with OAuth credentials (how to obtain)

Installation

npm install pingen2-sdk-js

Quick 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); // staging

Config 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 down

Versioning

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.