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.