Skip to content
Back to News
Changelog

SDK 4.1.0 — four rule identifiers were pointing at the wrong rule

sdkpeppolbugfix

If you looked up BR-S-01 after this SDK reported it, you found a real Peppol rule. It just had nothing to do with your invoice.

Four identifiers in the local pre-flight named the wrong rule. Not typos, not inventions — all four exist in the official rulebook, which is exactly what made them worse than a misspelling. A missing identifier tells you something is off. A wrong one sends you reading the wrong half of your document.

Reported before Reports now What the check actually verifies
BR-S-01 BR-S-05 standard-rated line must carry a VAT rate above zero
BR-S-05 BR-Z-05 zero-rated line must carry a rate of exactly zero
BR-S-06 BR-E-05 VAT-exempt line must carry a rate of exactly zero
BR-S-08 BR-AE-05 reverse-charge line must carry a rate of exactly zero

The checks were right the whole time. A zero-rated line with a non-zero rate was always caught. Only the label was wrong.

If your code branches on any of the four old strings, update it. The condition still fires — under its correct name. This ships as a minor release rather than a major one on purpose: anyone matching on the old identifiers is already matching against a rule that describes something else, and we would rather that reach you than wait for a migration.

How they were found

Two things were answering the same question: these 18 hand-written rules in the SDK, and the full official engine the gateway now runs. Rather than keep them in sync by hand — two copies of a rule drift, and this repository has the scars — both are run over the OpenPeppol conformance corpus, and the build fails if they ever disagree about a shared rule.

That test is what surfaced all four. An identifier that merely exists passes any spell-check you can write; only comparing behaviour catches one that points somewhere else.

validateSchematron() now tells you what it covered

The result carries a coverage field:

const { valid, coverage } = validateSchematron(invoice);
// coverage → { rulesChecked: 18, ofNetworkFatalRules: "partial" }

This runs on your machine, offline, in milliseconds. It implements 18 rules. A Peppol invoice is judged against 333 or more — 281 from EN 16931, 52 Peppol-wide, plus a national family where one applies.

valid: true has always meant "nothing wrong among the checks performed", never "this invoice is conformant". It now says so in the payload, so a clean local pass cannot be mistaken for a network verdict. SDK_SCHEMATRON_RULE_IDS exports the exact list if you want to see it.

For the complete rulebooks, send the document to POST /v1/validate/ubl — it runs the official engine and answers with the rule that refused, if any.

importFile() returns the rulebook it was judged against

const result = await client.invoices.importFile({ file, filename, to });
// result.rulebook → { peppol: "v3.0.20", verifiedAt: "2026-08-17" }

Absent is not a pass. It means one of three things: a JSON send with no UBL to judge, an explicit x-skip-validation, or a gateway older than the gate.

Not every 422 is terminal

We documented that retrying an import after a 422 always fails the same way. That is true for a rejected document — the same bytes fail forever, fix the document. It is false for peppol_identity_not_verified, platform_billing_not_active and production_access_expired: those describe account state, and account state changes. A verification completes, a contract goes live, and the identical document goes through.

Treating the second family as terminal costs you a real invoice. The two are now documented separately, in the SDK and in the API reference.