@getpeppr/sdk
v5.10.0
Published
Send Peppol e-invoices in under 20 lines of code
Maintainers
Readme
@getpeppr/sdk
Send Peppol e-invoices in under 20 lines of code.
import { Peppol } from "@getpeppr/sdk";
const peppol = new Peppol({ apiKey: "sk_sandbox_..." });
const identity = await peppol.identity.get();
if (identity.sandboxFirstSend?.status !== "ready") {
throw new Error(identity.sandboxFirstSend?.message ?? "Sandbox profile unavailable");
}
const result = await peppol.invoices.send({
number: "INV-2026-001",
// 9925:BE0314595348 = SPF Economie, the Peppol test receiver —
// sandbox delivers only to test recipients. In production,
// replace with your customer's Peppol ID.
to: {
name: "SPF Economie (test receiver)",
peppolId: "9925:BE0314595348",
street: "Rue du Progrès 50",
city: "Brussels",
postalCode: "1210",
country: "BE",
},
lines: [
{ description: "Consulting services", quantity: 10, unitPrice: 150, ...identity.sandboxFirstSend.line },
],
});
console.log(`Invoice sent! ID: ${result.id}`);Installation
npm install @getpeppr/sdkUpgrading to 5.0.0
Three things changed for callers, all measured against the published 4.8.1.
The full list is in CHANGELOG.md, shipped inside the package.
BGN and HRK are no longer currencies. Bulgaria and Croatia joined the
euro, and Peppol BIS Billing 3.0.21 — mandatory on the network since
2026-08-17 — dropped both from BR-CL-04/BR-CL-05. getCurrency("BGN") now
returns undefined and validateInvoice rejects the code. Use EUR. In the
other direction, XCG (Caribbean guilder) is accepted; it replaces ANG, which
this SDK never carried.
A blank idempotency key now throws instead of travelling. idempotencyKey:
" " used to be sent; HTTP strips the whitespace, so the gateway received
nothing, skipped its cache and its lock, and treated every retry as a fresh
request. It now raises a PeppolValidationError before anything leaves. If you
passed one, you were already unprotected — this is where you find out.
A negative allowance or charge amount is refused. 4.8.1 accepted it and rendered a document whose totals disagreed with its lines. An allowance reduces the invoice and a charge increases it — the direction is carried by the field, never by the sign.
If you pass none of the three, upgrading changes nothing you have to fix.
What did NOT change, despite an older promise. The 3.2.0 release notes said
L (IGIC) and M (IPSI) would leave the vatCategory type "in the next major
release". They are still there, and this is the second major since. The reason is
that buildInvoiceXml, buildCreditNoteXml and Peppol.toXml() take the same
input type as invoices.send(), and they deliberately render those categories —
an IGIC invoice is a valid EN 16931 document you may deliver through another
channel. Removing them from the type would take that away. What is true is
narrower: invoices.send() refuses L and M with 422
unsupported_vat_category, because our provider has no vocabulary for them.
Tracked as GPR-1218.
Upgrading to 3.0.0
Two changes are worth a minute before you upgrade. The full list is in
CHANGELOG.md, shipped inside the package.
GB:CRN was removed from SCHEMES_BY_COUNTRY.GB. The Companies House
number is not a Peppol scheme — it has no EAS code and is absent from the
OpenPeppol code list, so an entity registered under it is reachable by nobody.
If you read that constant to build a scheme picker, the entry is now gone and
UK users should be offered GB:VAT.
Calling validatePeppolIdentifier("GB:CRN", …) directly now fails open:
since the scheme is no longer listed, it takes the permissive path any
unrecognised scheme takes, so ("GB:CRN", "abc") returns valid: true where
2.6.0 returned valid: false. If you relied on that call to reject malformed
Companies House numbers, it no longer does.
Since GPR-1350 that permissive path is no longer unconditional. An unrecognised scheme still skips pattern and checksum, but the identifier VALUE must use POLICY 1's alphabet —
a-z,A-Z,0-9,-,.,_,~. The example above is unaffected (abcis inside the set), but("GB:CRN", "AB/CD")now returnsvalid: false. See the CHANGELOG.
Scheme lookup became case-insensitive, so format rules now apply to spellings
that used to escape them. In 2.6.0,
validatePeppolIdentifier("gb:vat", "not-a-vat") returned valid: true,
because the lowercase spelling matched no known scheme and fell through to a
permissive path. It now returns valid: false. The same applies to de:lwid
and to the numeric spelling 9932 — spellings that resolved to nothing before.
0204 is not affected: it was already recognised and already rejected malformed
values. Nothing that was correct becomes incorrect — but input that silently
passed may now be rejected.
Features
- Stripe-like DX -- JSON in, Peppol out
- Local UBL 2.1 tooling -- build and validate Peppol BIS documents before you transmit them
- Document type codes in local UBL -- builders preserve BR-CL-01 invoice and credit-note codes, including standard (380), credit note (381), debit (383), prepayment (386), and self-billed (389)
- Honest JSON send contract --
POST /v1/invoicescurrently derives invoice vs credit note fromisCreditNote;invoiceTypeCodeis inert until the provider route carries it into UBL - Client-side + server-side validation -- partial offline pre-flight checks and UBL generation sanity checks
- Peppol Directory lookup -- verify participants before sending
- Webhook signature verification -- HMAC-SHA256 with replay protection
- Batch send with concurrency control
- Built-in retry with exponential backoff and rate limit handling
- Async pagination -- iterate over all invoices, events, contacts
- TypeScript-first, zero dependencies
Quick Examples
Credit note
const creditNote = await peppol.invoices.send({
number: "CN-2026-001",
isCreditNote: true,
invoiceReference: "INV-2026-001",
to: {
name: "SPF Economie (test receiver)",
peppolId: "9925:BE0314595348",
street: "Rue du Progrès 50",
city: "Brussels",
postalCode: "1210",
country: "BE",
},
lines: [
// Positive quantities: `isCreditNote` carries the sign. Reduce a credit with an allowance.
{ description: "Refund for consulting services", quantity: 2, unitPrice: 150, ...identity.sandboxFirstSend.line },
],
});Webhook verification
import { webhooks } from "@getpeppr/sdk";
app.post("/webhooks/peppol", async (req, res) => {
try {
const event = await webhooks.constructEvent(
req.body, // Raw body string (NOT parsed JSON)
String(req.headers["getpeppr-signature"] ?? ""), // Getpeppr-Signature header
"whsec_your_webhook_secret", // Your webhook secret
);
switch (event.type) {
case "invoice.accepted":
console.log("Invoice accepted:", event.data);
break;
case "invoice.received":
console.log("New invoice received:", event.data);
break;
}
res.sendStatus(200);
} catch (err) {
res.status(400).send("Webhook verification failed");
}
});Directory lookup
const entry = await peppol.directory.lookup("0208:0685660237");
console.log(entry.name); // "ACMEDIA"
console.log(entry.country); // "BE"
console.log(entry.capabilities); // ["invoice", "credit_note"]Batch send
const invoices = [invoice1, invoice2, invoice3];
const result = await peppol.invoices.sendBatch(invoices, {
concurrency: 5,
stopOnError: false,
});
console.log(`${result.succeeded.length} sent, ${result.failed.length} failed`);Validation
const result = peppol.validate({
number: "INV-001",
to: {
name: "Acme",
peppolId: "0208:0685660237",
street: "123 Business Ave",
city: "Brussels",
postalCode: "1000",
country: "BE",
},
lines: [{ description: "Item", quantity: 1, unitPrice: 100, vatRate: 21 }],
});
if (!result.valid) {
for (const err of result.errors) {
console.log(`${err.field}: ${err.message}`);
}
}peppol.validate() runs instantly offline and checks the structured input, including
country-specific advisories. It does not run the Schematron-style registry.
Import validateSchematron(input) when you also want the 40 registered pre-flight
checks. Some use exact network-rule IDs; the ten local checks use explicit
GETPEPPR-* IDs. The legacy provider-capability diagnostic
unsupported_vat_category can also appear, but is outside that count. This partial
pass is not a Peppol conformance verdict.
For the same gateway-side checks exposed by POST /v1/validate/server, use invoices.validateServer(). It does not send the invoice to Storecove or consume billing usage.
const serverResult = await peppol.invoices.validateServer({
number: "INV-001",
to: {
name: "Acme",
peppolId: "0208:0685660237",
street: "123 Business Ave",
city: "Brussels",
postalCode: "1000",
country: "BE",
},
lines: [{ description: "Item", quantity: 1, unitPrice: 100, vatRate: 21 }],
});
console.log(serverResult.ubl.valid);
console.log(serverResult.schematron.valid);
console.log(serverResult.providerSendability); // "not_checked"serverResult.xsd is kept as a deprecated compatibility field. It mirrors the UBL generation sanity check; the gateway does not run a standalone XSD validator.
serverResult.valid covers getpeppr's offline checks only. Storecove Standard JSON validation is not called, so providerSendability: "not_checked" means a later send can still return 422.
Export PDF / XML
import { writeFileSync } from "fs";
const pdf = await peppol.invoices.getAs("inv-abc123", "pdf");
writeFileSync("invoice.pdf", Buffer.from(pdf));
const xml = await peppol.invoices.getAs("inv-abc123", "xml.ubl.invoice.bis3");
writeFileSync("invoice.xml", Buffer.from(xml));For a short window after a send, the sending evidence is not registered yet and
EVERY format answers 404 — poll rather than treating the first one as a
failure. Beyond that window, pdf and the two schema-named formats can still
legitimately answer 404 invoices.export_format_unavailable: the invoice exists,
that representation does not. xml.ubl.invoice.bis3 resolves only when the business document is UBL,
xml.facturae.3.2 only when it is Facturae; on the Peppol network the document is
UBL, so the Facturae format normally has nothing to return.
⛔ Those bytes still carry the Peppol SBDH envelope: original and
xml.ubl.invoice.bis3 are rooted on <sh:StandardBusinessDocument>, not
<Invoice>. Use payload when you need the business document bare — to feed a
schema validator, for instance.
Legal Entities (multi-tenant)
Platform customers (master key) can manage Legal Entities for their own customers and send invoices on their behalf.
This surface needs platform mode on your account. In the sandbox it is self-service: an organisation admin chooses "A platform for my customers" at signup, or starts the platform sandbox trial from the console overview, then creates the sandbox master key at console.getpeppr.dev/api-keys. Production platform access is set up with our team — email [email protected] to request it. Without a master key, every call below returns
403 master_key_required. Onboarding your own company needs none of this — that is done in the console, on the Peppol identity page.
const peppol = new Peppol({ apiKey: "sk_sandbox_..." }); // sandbox master key (trial); sk_live_ once production is set up
// Create a sub-tenant Legal Entity (idempotent on externalId)
const le = await peppol.legalEntities.create({
externalId: "tenant-42",
companyName: "Acme Health AB",
country: "SE",
address: { line1: "Storgatan 1", city: "Stockholm", zip: "11122" },
identifier: { scheme: "0007", value: "5560000001" },
});
// Several identifiers, in order: pass `identifiers` instead. For a French customer,
// the SIREN (0002) first, then the directory address (0225). Sandbox only for now —
// see https://getpeppr.dev/docs/platform/legal-entities/
// Poll after 202. A registration failure has one stable, coarse reason:
const current = await peppol.legalEntities.get(le.id);
if (current.status === "registration_failed") {
console.log(current.registrationDetail?.reason);
// "already_registered" | "invalid_format" | "provider_error"
}
await peppol.legalEntities.get(le.id);
await peppol.legalEntities.list({ limit: 50 });
await peppol.legalEntities.list({ externalId: "acme-42" }); // find one customer by YOUR reference
for await (const e of peppol.legalEntities.listAll()) { /* … */ } // listAll takes every list() option except offset
await peppol.legalEntities.archive(le.id);
// Production: trigger the co-branded attestation email
await peppol.legalEntities.requestAttestation(le.id, { contactEmail: "[email protected]", language: "en" });
// Send an invoice as a sub-tenant
await peppol.invoices.send({ number: "INV-1", to, lines, sender: { externalSubTenantId: "tenant-42" } });Gateway error codes are available via PeppolApiError.code (e.g. le_cap_exceeded,
identifier_immutable, identifier_already_in_use, legal_entity_creation_in_progress, legal_entity_locked, forbidden).
An exact externalId + normalized participant replay returns the existing Legal
Entity; using the same participant under another active externalId returns
409 identifier_already_in_use before provider creation.
Configuration
import { Peppol } from "@getpeppr/sdk";
const peppol = new Peppol({
apiKey: "sk_live_...", // Required. Starts with sk_sandbox_ or sk_live_
baseUrl: "https://...", // Default: https://api.getpeppr.dev/v1
timeout: 30000, // Request timeout in ms (default: 30s)
retry: {
maxRetries: 3, // Max retry attempts (default: 3)
initialDelayMs: 500, // Initial retry delay (default: 500ms)
maxDelayMs: 30000, // Max retry delay (default: 30s)
},
onRequest: (entry) => {}, // Optional request hook
onResponse: (entry) => {}, // Optional response hook — see note below
});onResponse receives a copy of the response body, never the object the
parsers go on to read. Mutating entry.body — redacting a field before logging
it, say — therefore has no effect on what the SDK parses. Before 4.0.0 it did,
and a hook that removed status made the SDK blame the gateway for an omission
the hook had just committed. A body too deeply nested to clone yields a short
marker string instead of the payload.
Error Handling
The SDK provides four error classes for precise error handling. All three
specific ones extend PeppolError, so a single instanceof PeppolError catches
every error the SDK raises itself. It does not catch errors that come from
below it: a fetch that fails at the network layer surfaces as the runtime's
own TypeError, and a timeout or an aborted request as an Error named
AbortError — two different types, neither of them ours. A catch that must
handle everything needs a final branch:
import {
Peppol,
PeppolError,
PeppolValidationError,
PeppolApiError,
PeppolProtocolError,
} from "@getpeppr/sdk";
try {
await peppol.invoices.send(input);
} catch (err) {
if (err instanceof PeppolValidationError) {
// Client-side validation failed
console.log(err.validation.errors); // ValidationError[]
console.log(err.validation.warnings); // ValidationWarning[]
} else if (err instanceof PeppolApiError) {
// API returned an error
console.log(err.statusCode); // HTTP status code
console.log(err.responseBody); // Raw response body
console.log(err.retryAfterMs); // Retry-After delay (on 429)
} else if (err instanceof PeppolProtocolError) {
// The API answered 2xx with a body the SDK cannot parse honestly —
// a mandatory field is missing, or the body is not an object.
// Nothing you sent caused this; it is not worth retrying.
console.log(err.field); // e.g. "status"
console.log(err.responseBody); // the offending body, capped at 2000 chars
} else if (err instanceof PeppolError) {
// General SDK error (invalid config, timeout, etc.)
console.log(err.message);
} else {
// Not raised by the SDK: a network-layer failure the runtime threw
// (`TypeError: fetch failed`, DNS, TLS…). Reaching here is normal.
console.log(err);
}
}Structured results
When the gateway sends its result headers, PeppolApiError surfaces them
directly — no body parsing:
try {
await peppol.invoices.send(invoice);
} catch (err) {
if (err instanceof PeppolApiError) {
err.resultCode; // "auth.api_key_invalid" — stable, machine-readable
err.resultMessage; // the catalogue's sentence for that code
err.requestId; // "req_…" — quote this to support
err.retryable; // true | false | undefined ("we did not say")
err.remediation; // "fix_request" | "retry_after" | "authenticate" | …
err.docs; // https documentation link, when the code has one
}
}The same block reaches the onResponse hook as entry.result, on successes too.
Each attempt gets its own copy, so a hook may inspect or redact it freely without
changing what the SDK does next.
⚠️ Every one of these is undefined until the gateway publishes the headers,
and behind any proxy that strips unknown ones. Write your code for their absence
first: treat it as "not stated", never as a fact about the request. err.code is
a different field and is unchanged — it still reads code from the response
body, where routes put a request-specific sub-reason, so the two can both be
present and differ.
Values that arrive malformed are dropped, not repaired: an over-long
sentence, a code carrying a control character, or a docs link that is not a
plain https:// URL all leave the field undefined rather than surfacing a
cleaned-up guess.
Retries
Transient failures are retried with exponential backoff and jitter. Which failures is decided by the gateway's result code when it sends one, not by the status alone:
retryable: falseis honoured even on a500— nine public catalogue codes are permanently fatal, and retrying those is pure waste.retryable: trueis honoured on statuses that were never retried before, such as the409s raised while a concurrent request is still in flight.- When the gateway says nothing — an older deployment, a stripping proxy, or an unreadable value — the SDK falls back to its historic list: 429, 500, 502, 503, 504.
Retry-After is honoured on a 429; the wait is capped by maxDelayMs.
⛔ A retryable result does not, on its own, make a write safe to replay. For
POST, PUT and PATCH the SDK retries only when you supplied an
Idempotency-Key — with one deliberate exception: a 429 does not need the
key, because a rate-limited request was rejected before it was processed, so
there is no side effect to duplicate. Every other status needs the key.
⚠️ That exception lifts the REPLAY guard, not the retryability verdict above. A
429 the gateway marks Getpeppr-Retryable: false is still not retried — both
questions have to answer yes.
Where you can pass one. Every operation the API contract lists
Idempotency-Key on takes an idempotencyKey: invoices.send,
invoices.create, invoices.sendById, invoices.acknowledge,
invoices.importFile, contacts.create and bankAccounts.create. A drift lock
in the gateway repository fails if the contract ever keys an operation this
package cannot send a key on.
⚠️ legalEntities.create and legalEntities.requestAttestation accept one too,
and the routes behind them do not read the header. The key still unlocks the
SDK's retry there, so what a replay does is decided by the route, not by the key:
legalEntities.create— a replay of the identical request returns the existing entity, and a retry the SDK issues is identical by construction, so it never creates a second one. ⚠️ If your retry overlaps the first attempt — the usual case after a client timeout — you get409 legal_entities.creation_in_progressinstead of the entity. That code is marked retryable, so the SDK's next attempt converges on the existing entity; a hand-rolled client should treat it as "wait and retry", not as a failure. Sending the sameexternalIdwith changed details is not a replay at all, and what happens then depends on what changed: a different participant is refused outright, while a different company name is refused if the entity is live and sending, archives it and creates a new one if its verification has failed, and — if a verification is still running — returns the existing entity unchanged. Read the entity you get back rather than assuming a rename took effect.legalEntities.requestAttestation— a replay is not free. Re-issuing mints a fresh token, which sends a second email to your contact and makes the link from the first one stop working. If the first attempt reached the gateway and only its response was lost, the retry costs your contact a dead link.
The key changes nothing about either: it only decides whether the SDK retries at all. Worth knowing if you are reasoning about what the header does.
The key must be one the gateway can actually use, and that is checked twice.
The SDK checks first: idempotencyKey: " " is refused with a
PeppolValidationError and nothing is sent. The gateway checks too — a blank
key that reaches its idempotency check over plain HTTP is refused with
400 idempotency_key_blank rather than ignored, so an older SDK release, or a
client that does not use this package at all, gets an answer instead of silence.
That check sits behind authentication, rate limiting and the production
eligibility gates, so a request that fails one of those receives that failure
instead.
A transport strips the whitespace at the edges of a header value, so a blank key
arrives empty and can protect nothing. If you derive keys from your own data — a
padded reference, a field that can be empty — the refusal is where you find that
out, instead of a duplicate invoice on the network. If you do not need
idempotency for a request, omit the option rather than passing an empty string.
Padding around a real key is fine: " inv-42 " is sent as inv-42, exactly
what the wire would have carried anyway.
Webhook Events
Invoice events (the legal-entity and identifier events are listed in the
WEBHOOK_EVENT_TYPES constant and on getpeppr.dev/docs/webhooks):
| Event | Description |
|-------|-------------|
| invoice.sent | Invoice sent to the Peppol network |
| invoice.accepted | Recipient accepted the invoice |
| invoice.refused | Recipient refused the invoice |
| invoice.error | The invoice failed (final state): delivery failed, or the recipient's accredited platform rejected it after receiving it (France: Rejetée, 213). Correct it and send again; detail.platformFiscal on the invoice tells which |
| invoice.registered | Cleared by the tax authority (clearance jurisdictions) |
| invoice.received | Receipt acknowledged by recipient |
| invoice.paid | Payment confirmed by recipient |
| invoice.undeliverable | Not deliverable — no receiving capability found for the recipient on the Peppol network (the document ends in status no_action). Also sent when no delivery evidence has appeared after 7 days |
| invoice.delivery_unconfirmed | No delivery evidence yet. Not a failure — if delivery is confirmed later, invoice.sent follows and supersedes it |
| invoice.partially_paid | Recipient confirmed a partial payment |
| invoice.under_query | Recipient raised a question about the invoice |
| invoice.conditionally_accepted | Recipient accepted the invoice subject to conditions |
| invoice.status_changed | Generic status notification with the full per-axis state — opt-in: never matched by the * wildcard, subscribe to it explicitly |
| test.ping | Test event for endpoint verification |
Reception events, for documents sent to you over Peppol:
| Event | Description |
|-------|-------------|
| inbound.invoice.received | An invoice sent to you was received. Deduplicate on data.receivedDocumentId |
| inbound.creditnote.received | A credit note sent to you was received. Deduplicate on data.receivedDocumentId |
| inbound.document.undeliverable | A document sent to you arrived but could not be delivered to you (data.reason: too_large or legal_entity_unresolved). There is no received document to fetch. Endpoints subscribed to the reception event of the document's type receive it without subscribing to it. Deduplicate on data.undeliverableDocumentId |
Status events carry a deterministic event id derived from the underlying provider occurrence: a provider redelivery reproduces the same id, so deduplicate by event id (deliveries are at-least-once).
Every new event also carries the business environment of its resource at the root: environment: "sandbox" | "production" (GPR-1329). Read it after verifying the signature and call the API with the matching key (sk_sandbox_* / sk_live_*) — a key from the other environment gets a 404 on the event's resources. The field is optional by contract, for three reasons: events emitted before it existed are replayed verbatim without it; test.ping (a synthetic event tied to no resource) deliberately carries none; and a status event whose submission environment is not yet resolved transiently carries none either — the field appears on the next event once resolution lands, and its absence never means "production by default". Treat "no field" as "not yet known", and keep whatever key-selection fallback you already use for pre-GPR-1329 payloads.
The full list of subscribable event types is exported as the WEBHOOK_EVENT_TYPES constant (the WebhookEventType type is derived from it) — it mirrors exactly the event types the gateway accepts for webhook subscriptions.
Status Tracking
- The SDK never invents a status. A 2xx response that carries no
statusraisesPeppolProtocolError(withfieldandresponseBody) instead of returning a plausible one. Before 4.0.0 the SDK substituted"submitted", which is not a terminal state — so unless you were waiting forsubmitteditself,waitFor()andgetpeppr send --watchcould only expire on it, on invoices that had already been delivered. Nothing you sent causes this error and retrying will not clear it; it is worth reporting to [email protected]. SendResult.createdAtis optional for the same reason: it is present when the gateway measured one, and absent rather than stamped with the moment of your call, or borrowed fromupdatedAt.SendResultand invoice list rows carryrawStatus(the raw gateway status string before SDK coercion — preserved even whenstatusfalls back tounknown) anddetail(structured national status detail, one entry per axis, with the jurisdiction's native code and label — e.g. French DGFiP lifecycle codes).waitFor(id, target)throws on the terminal failuresfailed,rejectedandno_action. If the document reaches the terminal successpaidwhile you wait for an earlier progress status (e.g.delivered), it resolves with the real result instead of timing out — the target was passed, not missed.- The
STATUS_PRECEDENCEtable (most final first),statusFamily()andTERMINAL_FAILURE_STATUSESare exported for consumers that need the same ordering the gateway uses.
Links
License
MIT
