Peppol BIS Billing 3.0.21 became mandatory on the network on 17 August 2026.
Bulgaria and Croatia have joined the euro, and BR-CL-04/BR-CL-05 — the rules
that decide which currency codes a document may carry — dropped BGN and HRK
with it.
Until today this SDK still accepted both. It validated your invoice, told you it was fine, and handed it to a network that refuses it. That is the headline breaking change.
Two narrower ones travel with it, both measured against the published 4.8.1:
- An idempotency key that cannot protect anything now throws instead of
travelling. On 4.8.1,
idempotencyKey: " "was sent — HTTP strips the whitespace, so the gateway received nothing and treated each retry as a fresh request. On 5.0.0 it raises aPeppolValidationErrorbefore anything leaves. If you passed a blank key, 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 the field's job, never the sign's.
This is also the first publish since 4.8.1 on 23 August, so several fixes that had been merged and were sitting unreleased travel with it.
BGN and HRK are no longer currencies
getCurrency("BGN") and getCurrency("HRK") return undefined, and
validateInvoice rejects both. Use EUR.
In the other direction, XCG — the Caribbean guilder that replaced the
Netherlands Antillean guilder — is now accepted. ANG never existed in this
SDK, so nothing was swapped out for it.
The ISO 6523 ICD list gains 0246, 0247 and 0248, bringing it to 243 codes
running 0002–0248. That change is purely additive — no scheme was removed —
and it is the list BR-CL-10 judges a PartyIdentification against, so a
seller registered under one of the three is no longer dropped from it.
Structured API results, and retryability the response decides
PeppolApiError now carries resultCode, resultMessage, requestId,
retryable, remediation and docs.
Each one is independent. They are read from separate response headers and
kept separately, so any of them can be present while the others are not — a
stripping proxy, a gateway that has not enabled its result catalogue, or a code
with no guide behind it all produce partial results. Read every field as
optional; the only guarantee is that error.result itself is undefined when
none of them arrived.
isRetryableError now reads the Getpeppr-Retryable header before falling
back to the status code, and the header decides in both directions: nine public
5xx result codes are definitively fatal, while three 409s are marked as
repairing themselves. If the header is absent or unreadable, the historical
status list applies unchanged — an ambiguous header must not grant a retry the
status refused, nor withdraw one it allowed.
⚠️ Retryable is not the same as replayable, and the SDK asks both. "Can
retrying succeed?" is what the header answers. "Is replaying THIS request safe?"
is a separate guard, and it is unchanged: a 429 is always replayable (the
request was never processed), so are GET, DELETE and HEAD — every other
method needs a usable Idempotency-Key. So a 409 marked retryable on a POST
is still not replayed unless you supplied one.
Also in this release
- A blank idempotency key is refused instead of sent.
" "is truthy in JavaScript, so it used to unlock the POST retry, while HTTP strips whitespace from a header value — so the gateway received an empty key, skipped its cache, and treated each attempt as a fresh request. OnePOST /v1/invoicescould leave four times under the default retry config. An unusable key now throws before anything leaves. - Every operation the contract keys now accepts an
idempotencyKey. The contract declaresIdempotency-Keyon six operations; this package took one oninvoices.send/invoices.createalone. 0007, the Swedish organisation number, is a first-class scheme.LegalEntity.registrationDetailexposes the stable reason a registration failed —already_registered,invalid_formatorprovider_error.
An old promise we are still not keeping, and why
The 3.2.0 notes said the VAT categories L (IGIC, Canary Islands) and M
(IPSI, Ceuta & Melilla) would leave the vatCategory type "in the next major
release". 4.0.0 shipped without doing it and said so. This is the third major,
and they are still there.
We tried, during this release, and reverted it. Here is the reason, because "still pending" three times running deserves better than a repeat.
One type feeds two different questions. invoices.send() genuinely cannot
transmit L or M — our provider has no vocabulary for either, and the gateway
refuses them with 422 unsupported_vat_category. But buildInvoiceXml,
buildCreditNoteXml and Peppol.toXml() take that same input type, and
they deliberately apply no routability gate: they render <cbc:ID>L</cbc:ID> on
purpose. An IGIC invoice is a perfectly valid EN 16931 document. Measured
against the official rulebook on the day of this release: an IGIC document and
an otherwise identical standard-rate one came back from POST /v1/validate/ubl
with the same single violation, and it had nothing to do with VAT — the
rulebook raised no objection to the category itself.
So the honest statement is narrower than the one we were about to publish: these categories are not routable by our JSON send path, not "undeliverable". Removing them from the type would have taken away local UBL generation for valid documents in order to forbid sending them.
The promise gets kept by splitting the type — one for sending, one for the builders. That is tracked, and it is the version that ships it which will close this out.
CLI 0.10.0
@getpeppr/cli bundles the SDK, so getpeppr validate and getpeppr convert
run these rules on your machine. 0.10.0 rebundles 5.0.0.
One verdict changes: BGN and HRK. A document carrying either used to
exit 0 and now exits 1, which matters if you gate a pipeline on it. L and M
are unaffected — measured on the published 0.9.0, getpeppr validate already
exits 1 on them, and getpeppr convert without --validate still renders them,
as it should.
It also picks up a fix the CLI had missed: 0.9.0 shipped with SDK 4.8.0
bundled, one release behind, so it never carried the baseQuantity correction
from 4.8.1. A 24-unit order priced "€30 per box of 12" was rendering €720
instead of €60 — a fatal PEPPOL-EN16931-R120 violation. getpeppr convert
gets that right now.
Upgrading
npm install @getpeppr/sdk@5.0.0
npm install -g @getpeppr/cli@0.10.0
If you pass neither BGN nor HRK, there is nothing to change. The migration
notes ship inside the package, in README.md under "Upgrading to 5.0.0" and in
CHANGELOG.md.