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

@peppol-sh/sdk

v0.1.0

Published

Official TypeScript SDK for the peppol.sh API — send and receive Peppol e-invoices.

Readme

@peppol-sh/sdk

Send e-invoices over the Peppol network with one API call. JSON in, e-invoice out, delivered to the recipient's access point.

npm CI License: MIT

The client is a typed wrapper over the peppol.sh /v1 API. Every request and response type is generated from the OpenAPI contract the API itself is served from, and regenerated whenever that contract changes.

Install

bun add @peppol-sh/sdk
# npm install @peppol-sh/sdk
# pnpm add @peppol-sh/sdk
# yarn add @peppol-sh/sdk

Quickstart

Sign up at peppol.sh — or with POST /v1/signup — and you get a sandbox key (ps_test_…) straight away. Point the client at https://sandbox.peppol.sh, create a company to send from, and send an invoice.

import { Peppol } from "@peppol-sh/sdk";

const peppol = new Peppol({
  apiKey: process.env.PEPPOL_API_KEY!, // ps_test_…
  baseUrl: "https://sandbox.peppol.sh", // omit for production
});

const company = await peppol.companies.create({
  name: "Acme BV",
  country: "BE",
  company_registration_id: "0123456749",
  peppol_id: "0208:0123456749", // 0208 = Belgian enterprise number
});
const companyId = company.id!;

const invoice = await peppol.documents.send({
  company_id: companyId,
  type: "invoice",
  number: "INV-2026-001",
  issue_date: "2026-03-01",
  due_date: "2026-03-31",
  currency: "EUR",
  from: {
    name: "Acme BV",
    tax_id: "BE0123456749",
    peppol_id: "0208:0123456749",
    address: {
      street: "Keizerslaan 1",
      city: "Brussels",
      postal_code: "1000",
      country: "BE",
    },
  },
  to: {
    name: "Globex NV",
    tax_id: "BE0987654394",
    peppol_id: "0208:0987654394",
  },
  lines: [
    {
      description: "API integration services",
      quantity: 1,
      unit: "C62", // UN/CEFACT unit code — C62 is "piece"
      unit_price: 500.0,
      tax_rate: 21.0,
    },
  ],
  payment_means: { method: "bank_transfer", iban: "BE68539007547034" },
  idempotency_key: "acme-INV-2026-001",
});

console.log(invoice.id, invoice.status, invoice.total);
// doc_a1b2c3d4 queued 605

Sending is asynchronous. Read the document back for its current status, or subscribe to webhooks instead of polling.

const current = await peppol.documents.get(invoice.id!, {
  company_id: companyId,
});
console.log(current.status); // queued | sending | delivered | failed

const timeline = await peppol.documents.history(invoice.id!);
const ubl = await peppol.documents.ubl(invoice.id!); // Send-ready UBL XML

Authentication and environments

Every request sends Authorization: Bearer <apiKey>. Keys are long-lived and carry their environment in the prefix.

| Key prefix | Base URL | Delivery | | --- | --- | --- | | ps_test_ | https://sandbox.peppol.sh | Sandbox — delivered by email, never touches the real Peppol network | | ps_live_ | https://api.peppol.sh (default) | Production — delivered over Peppol |

baseUrl defaults to https://api.peppol.sh, so a sandbox key needs the sandbox URL set explicitly. A trailing slash is tolerated.

const client = new Peppol({
  apiKey: process.env.PEPPOL_API_KEY!,
  baseUrl: process.env.PEPPOL_BASE_URL, // undefined falls back to production
  timeoutMs: 15_000,
  maxRetries: 3,
});

console.log(await client.health());
// { status: "ok", version: "2.1.0", environment: "production", checks: { db: "ok" } }

Client options

| Option | Default | What it does | | --- | --- | --- | | apiKey | (required) | Bearer key. The constructor throws PeppolError if it is empty. | | baseUrl | https://api.peppol.sh | Sandbox, production, or a local API. | | fetch | globalThis.fetch | Bring your own fetch: a proxy agent, an instrumented wrapper, a test stub. The constructor throws if no fetch exists and none is passed. | | timeoutMs | 30000 | Per-attempt timeout. On expiry the call rejects with PeppolTimeoutError. | | maxRetries | 2 | Extra attempts after a retryable failure. | | sleep | setTimeout | Replaces the backoff timer. Useful in tests. |

The defaults are exported as DEFAULT_BASE_URL, DEFAULT_TIMEOUT_MS, and DEFAULT_MAX_RETRIES; the package version the client reports in its x-peppol-sdk request header is exported as SDK_VERSION.

Error handling

Every non-2xx response becomes a typed error carrying the API's canonical envelope — { error: { type, code, message, param?, details? } }. Branch on code, never on message.

import {
  PeppolApiError,
  PeppolConnectionError,
  PeppolRateLimitError,
  PeppolTimeoutError,
  PeppolValidationError,
} from "@peppol-sh/sdk";

try {
  await peppol.documents.get("doc_a1b2c3d4", { company_id: "com_abc123" });
} catch (error) {
  if (error instanceof PeppolValidationError) {
    console.error(`${error.code} on ${error.param}: ${error.message}`);
  } else if (error instanceof PeppolRateLimitError) {
    console.error(`Rate limited, retry in ${error.retryAfter ?? 1}s`);
  } else if (error instanceof PeppolApiError) {
    console.error(`HTTP ${error.status} ${error.code} (req ${error.requestId})`);
  } else if (error instanceof PeppolTimeoutError) {
    console.error(`Timed out after ${error.timeoutMs}ms`);
  } else if (error instanceof PeppolConnectionError) {
    console.error("Could not reach the API", error.cause);
  } else {
    throw error;
  }
}

| Class | Raised when | | --- | --- | | PeppolValidationError | HTTP 400 or 422 — the body or query failed validation. Read param for the field. | | PeppolAuthenticationError | HTTP 401 — the key is missing, malformed, or unknown. | | PeppolPermissionError | HTTP 403 — authenticated, but not allowed to do this. | | PeppolNotFoundError | HTTP 404 — no such resource, or not visible to this key. | | PeppolConflictError | HTTP 409 — the request conflicts with current state. | | PeppolRateLimitError | HTTP 429 — too many requests. retryAfter holds the Retry-After seconds when the API sent one. | | PeppolServerError | HTTP 5xx — a valid request the API failed to process. | | PeppolApiError | Any other non-2xx status, for example 402 when the workspace is out of credits. Base class of all of the above. | | PeppolTimeoutError | No answer within timeoutMs. Carries timeoutMs. | | PeppolConnectionError | The request never arrived: DNS, TLS, or socket failure. The underlying failure is on cause. | | PeppolError | Base class of everything the SDK throws, including client-side misconfiguration such as a missing apiKey. |

PeppolApiError and its subclasses expose status, type, code, param, details, and requestId (from the x-request-id response header — quote it in support requests).

Pagination

List endpoints answer with { data, has_more, next_cursor }. Pass next_cursor back as cursor until it stops coming.

let cursor: string | undefined;

do {
  const page = await peppol.documents.list({
    company_id: "com_abc123",
    status: "delivered",
    limit: 50,
    cursor,
  });

  for (const doc of page.data ?? []) {
    console.log(doc.id, doc.status, doc.total);
  }

  cursor = page.has_more ? (page.next_cursor ?? undefined) : undefined;
} while (cursor);

documents.list, events.list, webhooks.listDeliveries, and both listAuditEvents methods page this way. companies.list, webhooks.list, and workspaces.list return the full set in a data envelope.

Retries and timeouts

Each attempt gets its own timeoutMs budget; on expiry the call rejects with PeppolTimeoutError and is not retried. What is retried, up to maxRetries extra attempts:

| Failure | Retried | | --- | --- | | HTTP 429 | Yes, for every method — the request was rejected before it was processed | | HTTP 5xx | GET only | | Network failure | GET only | | HTTP 4xx other than 429 | Never | | Timeout, or an aborted caller signal | Never |

Backoff is exponential with jitter, starting at 250 ms and capped at 2 s. A Retry-After header wins over the computed delay (still capped at 2 s). Set maxRetries: 0 to handle failures yourself.

Send is idempotent

Pass idempotency_key on documents.send and a repeated call is safe. The API answers 202 when it queues a new send and 200 when it replays an existing one; both carry the same document shape, so the SDK returns the same type either way and you always get the record that exists. A retried send therefore never produces a duplicate invoice on the network.

API surface

Nine namespaces hang off the client, one per /v1 area.

peppol.documents

| Method | Endpoint | Purpose | | --- | --- | --- | | send(params) | POST /v1/documents | Create and send one document | | sendBatch(params) | POST /v1/documents/batch | Send up to 100 documents for one company; results map 1:1 to the input | | list(params) | GET /v1/documents | One cursor page of a company's documents | | get(id, params) | GET /v1/documents/{id} | One document, scoped to its company | | history(id) | GET /v1/documents/{id}/history | Delivery timeline: created, validated, queued, sending, delivered, failed | | ubl(id) | GET /v1/documents/{id}/ubl | The stored Send-ready UBL XML, as text | | listAttachments(id) | GET /v1/documents/{id}/attachments | Attachment metadata | | getAttachment(id, attId) | GET /v1/documents/{id}/attachments/{att_id} | One attachment's raw bytes |

peppol.companies

| Method | Endpoint | Purpose | | --- | --- | --- | | create(params) | POST /v1/companies | Create a company in the caller's workspace | | list() | GET /v1/companies | Every company in the workspace, newest first | | get(id) | GET /v1/companies/{id} | Full details for one company | | update(id, params) | PATCH /v1/companies/{id} | Partial update; owners and admins only |

peppol.webhooks

| Method | Endpoint | Purpose | | --- | --- | --- | | list() | GET /v1/webhooks | Every webhook in the workspace | | create(params) | POST /v1/webhooks | Register an endpoint; returns the signing secret once | | get(id) | GET /v1/webhooks/{id} | One webhook, without the secret | | delete(id) | DELETE /v1/webhooks/{id} | Retire a webhook | | listDeliveries(id, params?) | GET /v1/webhooks/{id}/deliveries | One page of the delivery log | | test(id) | POST /v1/webhooks/{id}/test | Dispatch a synthetic webhook.test event | | rotateSecret(id) | POST /v1/webhooks/{id}/rotate-secret | Mint a new secret, with a 24-hour overlap |

peppol.workspaces

| Method | Endpoint | Purpose | | --- | --- | --- | | create(params) | POST /v1/workspaces | Create a workspace owned by the caller | | list() | GET /v1/workspaces | Every workspace the account belongs to | | get(id) | GET /v1/workspaces/{id} | One workspace, with the caller's role | | update(id, params) | PATCH /v1/workspaces/{id} | Rename or reconfigure; owners and admins only | | delete(id) | DELETE /v1/workspaces/{id} | Delete a workspace; owners only | | listMembers(id) | GET /v1/workspaces/{id}/members | Every member and their role | | inviteMember(id, params) | POST /v1/workspaces/{id}/members | Add a member; owners and admins only | | changeMemberRole(id, accountId, params) | PATCH /v1/workspaces/{id}/members/{accountId} | Change a role; owners only | | removeMember(id, accountId) | DELETE /v1/workspaces/{id}/members/{accountId} | Remove a member and revoke their keys for this workspace | | transferOwnership(id, params) | POST /v1/workspaces/{id}/transfer-ownership | Promote another account to owner; the caller becomes admin | | listAuditEvents(id, params?) | GET /v1/workspaces/{id}/audit | Audit events for this workspace |

peppol.account, peppol.kyc, peppol.events, peppol.lookup, peppol.validate, and the client itself

| Method | Endpoint | Purpose | | --- | --- | --- | | account.get() | GET /v1/account | Profile, key prefixes, usage totals | | account.createKey(params?) | POST /v1/account/keys | Mint an extra key; the full key is returned once | | account.revokeKey(prefix) | DELETE /v1/account/keys/{prefix} | Revoke a key permanently | | account.listAuditEvents(params?) | GET /v1/account/audit | Audit events across every workspace joined | | account.getUsage(params?) | GET /v1/account/usage | Daily documents and API calls | | kyc.get() | GET /v1/kyc | Status, attestation, documents, and what is still missing | | kyc.uploadDocument(params) | POST /v1/kyc/documents | Upload one document as base64 (10 MB decoded max) | | kyc.submit(params) | POST /v1/kyc/submit | Submit the workspace for review | | events.list(params?) | GET /v1/events | The workspace event feed behind webhook deliveries, newest first | | lookup.participant(peppolId, options?) | GET /v1/lookup/{peppol_id} | Resolve a participant through its SMP: document types and AS4 endpoints | | lookup.dns(peppolId, options?) | GET /v1/lookup/{peppol_id}/dns | The NAPTR/SMP DNS layer only | | validate.document(params) | POST /v1/validate | Check a payload without creating or sending anything | | health() | GET /v1/health | Liveness probe with per-dependency checks | | signup(params) | POST /v1/signup | Create an account and get a sandbox key. Unauthenticated: no Authorization header is sent |

Every namespace also exposes .request(), the same transport the typed methods use, so an endpoint the SDK does not wrap yet is still one call away with the auth, retry, timeout, and error handling described above.

TypeScript

Types are generated from openapi.yaml with openapi-typescript and re-exported from the package root — parameter and payload aliases per namespace, plus the whole generated tree under ApiTypes as an escape hatch.

import type {
  ApiTypes,
  Document,
  DocumentSendParams,
  ValidationResult,
} from "@peppol-sh/sdk";

type DocumentStatus = ApiTypes.components["schemas"]["DocumentStatus"];

function describe(doc: Document): string {
  return `${doc.number} → ${doc.to?.name} (${doc.status})`;
}

function firstProblem(result: ValidationResult): string | undefined {
  return result.errors?.[0]?.message;
}

const draft: DocumentSendParams = {
  company_id: "com_abc123",
  type: "invoice",
  number: "INV-2026-002",
  issue_date: "2026-03-02",
  currency: "EUR",
  from: { name: "Acme BV", tax_id: "BE0123456749" },
  to: { name: "Globex NV", tax_id: "BE0987654394" },
  lines: [
    {
      description: "Support",
      quantity: 2,
      unit: "C62",
      unit_price: 75,
      tax_rate: 21,
    },
  ],
};

Two quirks of generation: response fields are mostly optional, because the contract marks few of them required — narrow or assert them. Request fields with a schema default (type, currency, unit, payment_means.method) are required, even though the API fills them in — pass them explicitly.

Webhooks

Register an endpoint and the API posts document events to it — no polling.

const hook = await peppol.webhooks.create({
  url: "https://example.com/hooks/peppol",
  events: ["document.delivered", "document.failed", "credits.low"],
});

console.log(hook.secret); // whsec_… — shown once, store it now

Each delivery carries X-Peppol-Signature-V2 (an HMAC-SHA256 signature over the timestamp and the raw body), X-Peppol-Timestamp, X-Peppol-Event, and X-Peppol-Delivery-Id. Verify the signature against the raw received bytes before parsing the JSON — re-serializing a parsed object breaks the HMAC — and deduplicate on X-Peppol-Delivery-Id, which is stable across retries. The algorithm, the rotation overlap, and the retry schedule are documented at https://peppol.sh/docs. webhooks.test(id) sends a synthetic event to check your receiver; webhooks.listDeliveries(id) shows every attempt.

Requirements

Any runtime with a global fetch: Node 18+, Bun, Deno, Cloudflare Workers, and browsers. ESM only. Zero runtime dependencies. On an older runtime, or behind a proxy, pass your own implementation with the fetch option.

Documentation

Contributing

This repository is a read-only mirror. The SDK is developed in a private monorepo next to the API and its OpenAPI contract, so an API change and its client update land in one commit and are tested together. Pull requests are still welcome here: maintainers apply accepted changes upstream, and they flow back with the next mirror push — your change ships, but not under the commit hash you pushed, and mirror pushes rewrite history, so do not build long-lived branches here. Issues and feature requests belong on this repository.

License

MIT © e-invoice bv