# Changelog

All notable changes to `@getpeppr/sdk` are documented here.

## [5.8.0] — 2026-09-20

### Added

- **`legalEntities.list()` accepts an `externalId` filter** (GPR-1231). Pass the
  reference you chose when you created a Legal Entity and the page carries that
  customer alone — you never need to have stored our id to find one again.

  ```ts
  const { data } = await peppol.legalEntities.list({ externalId: "acme-42" });
  const customer = data[0]; // undefined if you never registered that one
  ```

  **`listAll()` takes it too** — it accepts every `list()` option except
  `offset`, so `listAll({ externalId })` iterates the matching customer alone,
  or yields nothing. Filtering is usually what you want from `list()`; the
  iterator form is there so a filter you build once can be passed to either.

  Matched exactly: no prefix search, no case folding, no trimming. Because the
  reference is unique among your live customers, the page carries at most one
  entity; an unknown or archived reference is an empty `data`, not an error. An
  empty string, or a reference longer than 64 characters, is refused with `400`
  rather than answered with an empty list.

### Changed

- **`validatePeppolIdentifier` now enforces POLICY 1's alphabet on the identifier
  value** (GPR-1350). Only `a-z`, `A-Z`, `0-9`, `-`, `.`, `_` and `~` are accepted
  — the set named by the *Peppol Policy for use of Identifiers* 4.4.0, POLICY 1,
  third bullet for Participant Identifiers.

  ⚠️ **This can refuse input that 5.7.0 accepted, and it applies under BOTH
  `addressing` and `registration`.** The check runs BEFORE the per-scheme pattern,
  which most routable schemes do not have — for those, it is the only check the
  value ever meets. Concretely, `validatePeppolIdentifier("GB:CRN", "AB/CD")`
  returned `valid: true` in 5.7.0 and returns `valid: false` now.

  ⚠️ **The set is NARROWER than ASCII.** A space, a slash and a colon are ASCII
  and are refused.

  ⚠️ **The `error` TEXT can change on a value that was already refused.** The
  checks run in this order: empty, leading/trailing whitespace, length, POLICY 1
  alphabet, then the per-scheme pattern. A value that breaks both the alphabet
  and its scheme's pattern now reports POLICY 1, where 5.7.0 reported the
  pattern. **A space INSIDE the value is the ordinary way to hit this** — the
  shape of a human copy-paste. Measured on the published 5.7.0 against this
  release:

  ```ts
  validatePeppolIdentifier("GB:VAT", "GB 123 4567 89");
  // 5.7.0 → { valid: false, error: "Format mismatch: expected 9 or 12 digits,
  //            optionally prefixed GB or XI (or GD/HA + 3 digits)." }
  // 5.8.0 → { valid: false, error: "Identifier may only contain letters (a-z, A-Z), digits (0-9), '-', '.', '_' or '~' (Peppol Policy for use of Identifiers 4.4.0, POLICY 1)." }
  ```

  The same swap happens on `0002` (`"732 829 320"`), `0184` (`"1234 5678"`) and
  `9944` (`"NL 123456789B01"`). `valid` is `false` in every one of these, in
  both versions — only the text moved.

  ⚠️ **Two cases do NOT change, so do not over-correct.** A LEADING or
  TRAILING space still reports `"Identifier must not have leading or trailing
  whitespace."`, because that check runs first. And a separator POLICY 1 allows
  still reaches the pattern: `("GB:VAT", "GB-123-4567-89")` reports the format
  hint in both versions.

  If you surface `error` in a form and want the format hint back, strip the
  formatting characters rather than trimming — `value.replace(/\s/g, "")`
  removes the inner spaces that `trim()` leaves in place.

  Why: such a value cannot be carried by the Peppol network, and admitting it was
  actively harmful rather than merely permissive — the SML participant lookup
  lower-cases in full Unicode, so `A\u212A9` and `Ak9` resolve to the SAME network
  participant while our records held them apart. The refusal carries the existing
  error shape; only the message is new, and it names POLICY 1 so the rule can be
  looked up.

  If you call this helper for a preflight or a form, expect the new refusal on
  values that were never routable. No identifier in our own production data was
  affected (287 rows checked, zero violations).

  ⚠️ **Released as a MINOR, and here is why** — it tightens accepted input, so
  the decision was not automatic. The gateway's three identifier write doors
  already enforce this rule, so a value this helper used to call `valid` was
  answered with a `400` by the API and could not be carried by the network
  either. The helper's contract is "is this a valid Peppol identifier", not
  "accept anything": correcting a false `true` removes no capability. If you
  used it as a permissive filter, this is still a behaviour change for you —
  hence this entry leading the release.

### Documentation

- The `webhooks.constructEvent` docblock example narrowed `sender.peppolId` as
  non-nullable (GPR-1259). It is nullable, and the null is meaningful: the value
  is read from the document's own `AccountingSupplierParty` and is never
  guessed, so `null` means the document did not state a supplier endpoint. The
  example no longer invites you to reconcile a supplier on an absent value. This
  text ships inside the generated `.d.ts`.

## [5.7.0] — 2026-09-17

### Added

- Optional `language` on `legalEntities.requestAttestation()` input, and
  `language` / `languageSource` on its result (GPR-1318). `language` is a BCP 47
  tag choosing the language of the authorisation email, the confirmation page
  and the wording confirmed; omit it for English. The gateway refuses a language
  it does not serve — including a translation still awaiting review — with
  `400 attestation.language_unsupported`, never by sending in another language.
  Both result fields are optional: a gateway that predates languages sends
  neither, and the SDK does not fill them in. `languageSource` is typed `string`
  (`"explicit"` or `"default"` today) so a value added later is passed through.

## [5.6.0] — 2026-09-16

### Added

- Optional root `environment: "sandbox" | "production"` on `WebhookEvent`
  (GPR-1329), plus the exported `WebhookEnvironment` type. The gateway stamps
  it on every new outgoing webhook with the business environment of the
  resource the event is about — read it after verifying the signature, then
  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 this
  change are replayed verbatim from the delivery outbox 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. No runtime change; the `User-Agent`
  version string advances to 5.6.0.

## [5.5.0] — 2026-09-15

### Added

- `inbound.document.undeliverable` in `WEBHOOK_EVENT_TYPES` and the
  `WebhookEventType` union (GPR-1319). The gateway sends it when a document
  arrives for your account over Peppol but cannot be delivered to you
  (`reason`: `too_large` or `legal_entity_unresolved`). Endpoints subscribed to
  the reception event of the document's type receive it without subscribing to
  it. A `switch` over `WebhookEventType` that checks exhaustiveness needs a new
  `case` when you upgrade. No other type or runtime change; the `User-Agent`
  version string advances to 5.5.0.

### Documentation

- The README lists the three reception events (`inbound.invoice.received`,
  `inbound.creditnote.received`, `inbound.document.undeliverable`) and their
  deduplication keys.

## [5.4.2] — 2026-09-13

### Documentation

- Publish the README and declaration comments corrected after 5.4.1: `sender`
  is refused with HTTP 403 on standard API keys, rather than ignored; invoice
  identifier comments describe the gateway's accepted identifiers and its
  ambiguity/idempotency exceptions; export documentation distinguishes XML
  schemas, the Peppol envelope, bare `payload`, and temporary export
  unavailability after sending.
- These are documentation corrections for existing gateway behavior. Public
  type signatures, validation rules and SDK operations are unchanged. The
  runtime change is the SDK version string in the `User-Agent` header.

## [5.4.1] — 2026-09-07

### Fixed

- **Strict NodeNext consumers compile again** (GPR-1287). 5.4.0 shipped
  `dist/core/status-precedence.d.ts` importing `../types/invoice` without the
  `.js` extension, so `tsc --strict --module NodeNext --moduleResolution
  NodeNext` failed with TS2835 in every fresh consumer that does not set
  `skipLibCheck: true` (it is off by default) — `import { Peppol }
  from "@getpeppr/sdk"` alone was enough to trigger it. The relative import
  now carries the extension, like every other module in the package. No API,
  type or behavioural change — the only runtime-visible difference is the SDK
  version string sent in the `User-Agent` header. `moduleResolution: bundler`
  consumers are unaffected.
- This family of drift is now locked: a test packs the tarball npm would
  serve, compiles the minimal NodeNext strict consumer against it without
  `skipLibCheck`, sweeps every emitted `.js`/`.d.ts` for extensionless
  relative specifiers (`from` clauses, dynamic `import()`, side-effect
  imports — each matcher proven live by a unit fixture, since the emitted
  corpus contains no dynamic or side-effect import), and re-fails when the
  exact 5.4.0 defect shape is reintroduced.

## [5.4.0] — 2026-09-06

### Added

- `ServerValidationResult.schematron.coverage` now declares the field already
  returned by `POST /v1/validate/server` (GPR-1282): required `rulesChecked: number`
  and `ofNetworkFatalRules: "partial"`. TypeScript callers of
  `invoices.validateServer()` can read it without a cast. The count is zero when
  UBL generation fails before the offline checks run; it is not a percentage or
  a guarantee of network acceptance. Validation behaviour is unchanged.
- A compiler-backed parity test compares the built public SDK declarations with
  the API response projection. It detects missing, optional or incorrectly typed
  coverage fields rather than relying on Vitest to typecheck test files.

## [5.3.0] — 2026-09-06

### Fixed

- **`validateInvoice()` structure findings now cite the rules they actually
  verify** (GPR-1267). Predicates, severities and the `valid` verdict are
  unchanged; `ValidationError.ruleId` changes:
  - a missing or blank buyer name becomes the exact official match `BR-07`
    (it was `BR-06`, the seller-name rule).
  - buyer street, city and postal code become `GETPEPPR-BUYER-ADDRESS` (they
    were `BR-50`/`BR-51`/`BR-53` — the payment account, card-number
    truncation and accounting-currency rules). Universally, EN 16931 only
    requires the address to exist (`BR-10`, `BR-11`); `NL-R-002`/`004`/`006`
    mandate the elements for NL parties only. This check is the getpeppr
    delivery contract, and a delivery contract must not borrow a network
    identifier.
  - a missing line VAT rate becomes `GETPEPPR-LINE-VAT-RATE` (it was
    `BR-CO-17`, the BT-117 equation).
  - a malformed `taxCurrencyRate` becomes `GETPEPPR-TAX-CURRENCY-RATE` (it
    was `BR-53`).
  - the buyer/order-reference advisory warning becomes `PEPPOL-EN16931-R003`,
    the rule that actually recommends one (it was `BR-10`).
  - the due-date advisory warning carries no rule id at all (no rulebook rule
    mandates a due date; it was `BR-09`).
  A branch-pinned lock (`validator-rule-ids.test.ts`) proves every
  official-shaped id exists in the graved rulebooks and pins both the absent
  and the blank construction of each renamed site, so an id cannot drift back
  to a plausible neighbour undetected.

### Changed

- **`validateSchematron()` enforces the three Peppol billing-profile rules
  that govern `invoiceTypeCode`** (GPR-884). Documents that previously passed
  can now fail:
  - `PEPPOL-EN16931-P0100` — invoices must use one of the 26 type codes the
    billing profile allows.
  - `PEPPOL-EN16931-P0101` — credit notes are restricted to their 5 codes.
  - `PEPPOL-EN16931-P0112` — codes `326` and `384` are reserved for domestic
    German documents: both buyer and seller must be German organizations.
  These mirror the profile constraints the rulebooks place on the documents
  the local UBL builder serialises. The JSON send route (`POST /v1/invoices`)
  derives the document kind from `isCreditNote` and ignores
  `invoiceTypeCode`, so these rules describe the UBL document, not what the
  gateway rejects at send time.
- `directory.search()` documentation now states that pagination is exact and
  participant-based, and that queries wider than the public Directory's
  accessible result window are rejected (GPR-1284). Runtime behaviour is
  unchanged — the docstrings describe what the route already does.

If you filter validation output by `ruleId`, update those filters using the
mappings above. Consumers that only use `valid`, `errors` and `warnings` need
no decision-logic change, but documents outside the billing profile now fail
`validateSchematron()` where they previously passed.

## [5.2.0] — 2026-09-04

### Fixed

- **Offline Schematron findings no longer borrow the identifiers of different
  official rules** (GPR-1084). The check predicates, severities and `valid`
  verdict are unchanged; `SchematronViolation.ruleId` changes:
  - `BR-08` becomes the exact official match `BR-16`.
  - `BR-03`, `BR-06`, `BR-09`, `BR-10`, `BR-CO-10`, `BR-CO-13`,
    `BR-CO-15`, `BR-CO-16`, `PEPPOL-EN16931-R004` and
    `PEPPOL-EN16931-R080` become explicit
    `GETPEPPR-*` diagnostics, because the SDK checks a different predicate,
    severity or code-list subset from the similarly named network rule.
- The unit warning now says a code is absent from the SDK's small convenience
  list, not that it is unknown to UN/ECE Rec20. Codes such as `KWH` are valid
  on the network even though the SDK does not provide a convenience alias.
- A negative computed payable amount no longer claims `prepaidAmount` exceeded
  the invoice total: allowances and rounding can also produce that result. The
  warning now names the values to inspect without inventing a cause.
- A behaviour lock now runs each renamed SDK check and the graved Peppol
  Schematron engine on equivalent input. It proves the one exact mapping,
  proves the ten local diagnostics are not the official rule they used to
  cite, and prevents an existing-but-unrelated rule ID from passing an
  existence-only check again.

If you filter validation output by `ruleId` or warning text, update those
filters. Consumers that only use `valid`, `errors` and `warnings` need no
decision-logic change. This release changes the `validateSchematron()` result;
the separate `validateInvoice()` structure findings are unchanged and are
tracked for their own reverse audit in GPR-1267.

## [5.1.0] — 2026-09-04

### Fixed

- **`DocumentFormat` now includes `"payload"`** (GPR-1270). The route
  `GET /invoices/{id}/as/payload` has served since GPR-1053 (the business
  document with the Peppol SBDH envelope stripped) and `openapi.yaml`
  declared it, but the SDK type had four members — so
  `invoices.getAs(id, "payload")` did not compile without a cast. Locked by
  a console-side parity test (`document-format-parity.test.ts`) asserting
  the route's `VALID_FORMATS`, the published OpenAPI enum and the built SDK
  union stay identical.
- **`PaginationMeta.truncated` is documented as always `false` today**
  (GPR-1270). No gateway list route emits `truncated` (measured — every list
  route goes through `buildPaginationMeta`, which emits `total_count`,
  `offset`, `limit` and `has_more` only), so the previous doc promised a
  state that cannot occur. The field is kept, not removed: the client
  derives it from the wire, so a future gateway that emits it will surface
  without a breaking release.

### Changed

- **`invoiceTypeCode` now follows the two vocabularies of network rule
  BR-CL-01 instead of a hand-written list of seven codes** (GPR-1234). The
  rule is contextual: 50 codes are legal as `<cbc:InvoiceTypeCode>`, a
  different, disjoint set of 13 as `<cbc:CreditNoteTypeCode>` (only `81`
  belongs to both). `validateInvoice` picks the vocabulary from
  `isCreditNote`, cites `BR-CL-01` in the error, and suggests the codes legal
  for the actual document kind.
  - **Stricter, and deliberately so:** a credit note carrying `383`, `384`,
    `386`, `389` or `751` is now refused locally — every one of them is fatal
    as `CreditNoteTypeCode` on the network. The previous list advertised six
    of its seven codes as valid on a credit note; only `381` was.
  - **Looser where we falsely refused:** invoice codes `382`, `326` (partial
    invoice — named by DE-R-017) and 42 others now pass. The default
    behaviour is unchanged: omit the field and the document kind decides
    (`380` invoice, `381` credit note).
  - The public `InvoiceTypeCode` type is now the exact union of both
    vocabularies (62 values) — widening, so existing code keeps compiling.
    The type cannot express the context; runtime validation does.
  - New exports: `getInvoiceTypeCodes()`, `getCreditNoteTypeCodes()`.

## [5.0.0] — 2026-08-28

**The headline breaking change is two currency codes the Peppol network stopped
accepting on 2026-08-17.** Two narrower ones come with it, both measured against
the published 4.8.1: an idempotency key that cannot protect anything now throws
instead of travelling (`"   "` used to be sent, and the gateway received
nothing), and a negative allowance or charge amount is refused instead of
rendering a document whose totals disagree with its lines.

This is also the first publish since 4.8.1 (2026-08-23), so it carries fixes
merged over the days since and never shipped.

⚠️ Entries below describe what changes **relative to the published 4.8.1**, not
to the repository — the two diverge whenever a release is prepared and deferred.
`invoices.list({ number })` and `events.list({ documentId })` were listed here
in error and have been removed: both are in the 4.8.1 tarball already.

See "Upgrading to 5.0.0" in the README for the migration.

### Changed

- **The currency and ICD code lists now follow Peppol BIS Billing 3.0.21**, which
  became mandatory on the network on 2026-08-17 (GPR-1205).
  - `getCurrency("BGN")` and `getCurrency("HRK")` return `undefined`, and
    `validateInvoice` rejects both: Bulgaria and Croatia joined the euro, and
    neither code is in `BR-CL-04`/`BR-CL-05` any more. **This is a breaking change
    for anyone passing them** — but the network refuses those documents today, so
    the alternative is a send that dies silently half an hour later.
  - `XCG` (Caribbean guilder) is accepted, replacing `ANG`, which this SDK never
    carried.
  - `ISO6523_ICD_CODES` gains `0246`, `0247` and `0248` — 243 codes, `0002`–`0248`.
    Purely additive: no scheme was removed.

- **Structured API results, and retryability driven by the response headers**
  (GPR-1178). `PeppolApiError` gains `resultCode`, `resultMessage`, `requestId`,
  `retryable`, `remediation` and `docs`. **Each is independent**: they are read
  from separate headers and kept separately, so any subset can arrive — a
  stripping proxy, a gateway with no result catalogue enabled, or a code with no
  guide behind it all produce partial results. Read every field as optional; the
  only guarantee is that `error.result` is `undefined` when none arrived.
  `isRetryableError` now reads `Getpeppr-Retryable` **before** the status list,
  and the header decides in both directions — nine public 5xx codes are
  definitively fatal, three 409s repair themselves. An absent or unparseable
  header falls back to the historical status list: it must neither grant a retry
  the status refused nor withdraw one it allowed.

  ⚠️ Retryable does **not** relax the idempotency guard. "This result repairs
  itself" and "replaying THIS request is safe" are different questions, and only
  the second protects you from sending an invoice twice.

- **`0007` (Swedish organisation number) is a first-class scheme** (GPR-1199),
  with its registration-side format rule.

- **Signed allowance and charge amounts are refused at the boundary** rather
  than silently flipping a total (GPR-1170). An allowance reduces the invoice
  and a charge increases it; the direction is carried by the field, never by the
  sign. `validateInvoice` and the builders now reject a negative amount instead
  of rendering a document whose totals disagree with its lines.

### Deferred

- **`L` and `M` stay in the `vatCategory` type**, and the 3.2.0 promise to
  remove them "in the next major release" is **still not honoured** — a third
  major now. This release attempted the removal and then reverted it, for a
  reason worth stating rather than repeating the promise a fourth time.

  One type feeds two questions. `invoices.send()` cannot transmit `L` (IGIC,
  Canary Islands) or `M` (IPSI, Ceuta & Melilla): our provider has no vocabulary
  for them, and the gateway refuses both with 422 `unsupported_vat_category`.
  But `buildInvoiceXml`, `buildCreditNoteXml` and `Peppol.toXml()` take that
  same `InvoiceInput` and deliberately apply **no** routability gate — they
  render `<cbc:ID>L</cbc:ID>` on purpose, because an IGIC invoice is a valid EN
  16931 document that you may deliver through another channel. Measured against
  the official rulebook on 2026-08-28: an IGIC document and a standard-rate one
  produce the *same* validation verdict — the network has no objection to `L`.

  So "L and M are not deliverable" — which earlier drafts of these notes said —
  is wrong. They are not **routable by this gateway's JSON path**. Narrowing the
  union would have removed local UBL generation for valid documents in order to
  forbid sending them. The promise is honoured by splitting the types, send-side
  and builder-side: GPR-1218.

### Fixed

- A blank `idempotencyKey` no longer unlocks the POST retry it could not protect.
  The SDK tested whether the option was supplied, never whether it carried
  anything: `"   "` is truthy in JavaScript, so the retry guard opened, while
  HTTP strips whitespace from the edges of a header value, so the gateway
  received an empty key, skipped its cache and its lock, and treated every
  attempt as a fresh request. A single `POST /v1/invoices` left four times under
  the default retry config (`maxRetries: 3`), each able to submit the invoice —
  and a duplicate on the Peppol network cannot be recalled. The retry guard now
  reads the value that would actually travel, so a blank header cannot authorise
  a replay whatever put it there (GPR-1185).

### Changed

- **A key that cannot protect anything is now refused instead of sent.** Passing
  `idempotencyKey: ""` or `"   "` throws a `PeppolValidationError` naming the
  option, before any request leaves. So does a key carrying a character no HTTP
  header can hold: a value may contain only HTAB, space, `U+0021-U+007E` and
  `U+0080-U+00FF` (`field-value`, RFC 9110 §5.5), so every control character
  OTHER than the tab, plus DEL and anything above `U+00FF`, is refused. Those previously escaped as a
  bare `TypeError` from the runtime, which `instanceof PeppolError` does not
  catch. Callers affected by this were already
  unprotected; the error is where they find out, rather than a duplicate invoice.
  Whitespace around a real key stays harmless: `"  inv-42  "` is sent as
  `inv-42`, which is what the wire carried before (GPR-1185).

### Added

- **An `idempotencyKey` option on every operation the API contract keys.** The
  contract declares `Idempotency-Key` on six operations; this package accepted
  one on `invoices.send` / `invoices.create` alone. `invoices.sendById`,
  `invoices.acknowledge`, `invoices.importFile`, `contacts.create` and
  `bankAccounts.create` now take `{ idempotencyKey }` as well. It was a false
  promise rather than a gap: the public contacts documentation told developers to
  send the header to retry safely without creating duplicate contacts, and no
  user of this package could follow that instruction. Because those five never
  wrote the header, the SDK's retry guard also — correctly — refused to replay
  them on a transient failure other than a `429`, which needs no key because the
  request was refused before it was processed. A caller had no way to change
  that (GPR-1189).
- `LegalEntity.registrationDetail` exposes the stable registration-failure reason
  (`already_registered`, `invalid_format`, or `provider_error`) on create, get,
  and list results when `status === "registration_failed"` (GPR-1172).
- Legal Entity creation now documents `409 identifier_already_in_use` when the
  same normalized Peppol participant is assigned to another active `externalId`.
  Exact `externalId` + participant replays remain idempotent.

## [4.8.1] — 2026-08-23

### Fixed

- Invoice lines with a `baseQuantity` (BT-149, "price per box of N") now compute
  `LineExtensionAmount` as `quantity × (unitPrice / baseQuantity)` on both
  invoices and credit notes. The previous build multiplied the per-lot price by
  every unit — a 24-unit order at "€30 per box of 12" rendered €720 instead of
  €60, a fatal `PEPPOL-EN16931-R120` violation of the official rulebook v3.0.20
  (GPR-1134).

## [4.8.0] — 2026-08-23

### Added

- `identity.get()` now returns `sandboxFirstSend`: a provider-compatible O/0 or
  AE/0 integration line derived from the sender's exact Storecove identifier
  inventory, or a fail-closed reason. Production keys return `null`.
- `SandboxFirstSendProfile` is exported for applications that want to name the
  profile contract explicitly.

### Fixed

- The Quick Start and credit-note examples consume the returned profile instead
  of assuming every sandbox sender lacks a Tax Identifier. Both symmetric
  sender-tax mismatches are now refused locally before document submission
  (GPR-1149).

## [4.7.0] — 2026-08-22

### Added

- `LegalEntityStatus` now includes `unsupported_scheme` (GPR-1138). It means
  that no automatic validator is active for the identifier scheme: no registry
  rejected the customer, and no verification job remains in progress.
- `legal_entity.unsupported_scheme` is available in `WebhookEventType`. Its
  payload carries `status: "unsupported_scheme"` without a failure `reason`.
  Global `*` subscriptions receive it automatically; explicit subscriptions
  must add the new event name.

The gateway continues to block sending under this status. The internal row
remains resumable so verification can run later if support for the scheme is
added.

### Fixed

- The package Quick Start and credit-note example now use the sandbox test
  receiver with `vatCategory: "O"`, `vatRate: 0`, and the builder-only
  `taxExemptReason`. The previous VAT-bearing examples returned 422 on a fresh
  sandbox sender with no VAT identifier (GPR-1148).

## [4.6.0] — 2026-08-21

**The public UBL builders now produce valid VAT breakdowns for exempt and
out-of-scope categories** (GPR-1068). `buildInvoiceXml()`,
`buildCreditNoteXml()`, and `Peppol.toXml()` no longer write a VAT rate for
category `O`, where Peppol requires the `Percent` element to be absent rather
than zero.

`InvoiceLine` and document-level allowances/charges gain the optional
`taxExemptReason` field. The builders consolidate one reason into the matching
VAT breakdown for `E`, `AE`, `G`, `O`, and `K`; a missing or conflicting reason
is rejected instead of producing an XML document the network will refuse. The
field is ignored for `S` and `Z`, where Peppol forbids an exemption reason.

This field is deliberately **builder-only**. `invoices.create()`,
`invoices.send()`, and `creditNotes.send()` strip it from their JSON requests;
the gateway provider derives its own exemption text on that path. Existing
send payloads and monetary calculations therefore keep their prior semantics.

The local Schematron pre-check now covers 37 executable rules, including line,
document allowance, document charge, and exemption-reason rules for all
sendable VAT categories. `Peppol.toXml()` uses those VAT-specific checks before
rendering. The low-level builders still do not claim unconditional Peppol
conformance: required business data such as a BuyerReference or OrderReference
remains the caller's responsibility.

## [4.5.0] — 2026-08-21

**`importFile()` can now send on behalf of a sub-tenant** (GPR-1129).
`ImportInvoiceOptions` gains an optional `sender` — `{ legalEntityId }` or
`{ externalSubTenantId }`, exactly the field `send()` already took. Platform
accounts can therefore import a customer's own UBL, untouched, and have it
leave under that customer's Peppol identity.

Until now the gateway had no way to know: raw import only ever compared the
document's supplier against your **own** company, so a document issued by one of
your sub-tenants was refused whatever its scheme — and registering that
sub-tenant did not change it. A real platform hit that wall five times in a row
on a document our own validator called conformant.

⚠️ The field was **silently dropped** before this release, not rejected: the SDK
builds that request body field by field, so a `sender` you passed never reached
the network and the refusal you got back named an identity you had not claimed.
If you worked around this by calling the endpoint directly, you can go back
through the SDK.

Two things worth knowing when you use it:

- **The document and `sender` must agree.** If the document states a supplier
  endpoint, it has to be the sub-tenant's own — getpeppr never rewrites your
  document, so a disagreement is refused (`supplier_identity_mismatch`) rather
  than resolved for you.
- **A standard key is refused with `403`**, not ignored. This differs from
  `POST /invoices`, deliberately: on the raw-import path a security field that is
  read and then dropped is exactly the defect this release closes.

## [4.4.0] — 2026-08-20

**A scheme has two spellings, and the XML only accepts one** (GPR-1110). The
official code list publishes every scheme under a numeric EAS code (`9932`) and
a symbolic form (`GB:VAT`); 98 of its 105 entries contain a `:` — the same
character that separates a scheme from its value. Splitting by position was
therefore wrong, and `GB:VAT:123456789` produced `schemeID="GB"` with the value
`VAT:123456789`: both invalid, both silent. `GB:VAT` is the only code our own
guidance offers for the United Kingdom, so this hit anyone following it.

Both spellings are now accepted anywhere a `peppolId` is taken, and always
resolved to the numeric code the network requires. This affects `buildInvoiceXml`,
`buildCreditNoteXml`, `toXml()` and `directory.lookup()`, which had the same flaw
independently.

**Seller identifiers outside the ISO 6523 ICD list are no longer written into
`PartyIdentification`.** `BR-CL-25` (EndpointID) and `BR-CL-10`
(PartyIdentification) judge against **different** lists: `9932` (GB), `9935` (IE)
and `9930` (DE) are legal in the first and fatal in the second. Measured against
the live schematron, an invoice from any such seller was rejected — including
when the numeric form was used, so this was never only a spelling problem. The
field is optional and is now omitted, which clears `BR-CL-10`.

⚠️ Clearing `BR-CL-10` is not the same as being conformant. Dropping BT-29 means
`BR-CO-26` now needs `vatNumber` (BT-31) or `companyId` (BT-30) to be present;
a seller carrying neither still gets a fatal document, and `validate()` warns.

When that omission leaves the seller with no identifier at all, `validate()`
adds a **warning** (`BR-CO-26`) telling you to supply `vatNumber` or `companyId`.
It is a warning and not an error: `from` is ignored on send, so the document you
actually transmit is unaffected.

`BR-CL-10` also carries an exception this release honours: `SEPA` is legal in
`PartyIdentification` under the seller and the payee, and fatal under the buyer.
A payee's SEPA creditor identifier is therefore kept, not dropped — measured, it
is the one place where dropping it went unnoticed, since a `PayeeParty` writes no
`EndpointID` and nothing else refuses the document.

**Stricter input checks**, on all three places that take a `peppolId` — buyer,
seller and payee. A malformed identifier is a validation error instead of
producing an empty or invalid `schemeID`: an empty scheme or value (`":x"`,
`"0208:"`, `"GB:VAT:"`), and — the one worth knowing — **the scheme code copied
without its identifier** (`"GB:VAT"`, `"DE:LWID"`), which is exactly what
`SCHEMES_BY_COUNTRY` displays. Leading whitespace no longer shifts where the
identifier is split.

New exports: `canonicalScheme`, `lookupCanonicalScheme`, `parsePeppolId`,
`ISO6523_ICD_CODES`, `isIso6523IcdCode`. Use `parsePeppolId` rather than
splitting on `:` yourself — the separator and the scheme share that character.
⚠️ The pre-existing `PEPPOL_ICD_CODES` is misnamed — it holds the EAS list. Use
`isIso6523IcdCode` when the question is `BR-CL-10`.

## [4.3.0] — 2026-08-19

**`peppol.identity.get()`** — read the Peppol identity of the account behind
your API key (GPR-1094). Works with **any** key, standard keys included: this
is how your integration checks that your own identifier is registered and
published. `/legal-entities` remains the platform sub-tenant surface and still
requires a master key — five accounts in a row hit its 403 while looking for
exactly this call.

Returns `{ environment, legalEntity, identifiers }`. `legalEntity: null` means
no identity exists in that environment yet — it is an answer, not an error, so
the call resolves instead of throwing. Each identifier carries a public
`status`; the known values are documented on `AccountIdentifier`, but the set
is not closed — compare against the values you know, do not exhaustively match.

The two `legalEntities` docstrings that pointed you at the console for reading
your own identity now point here as well.

## [4.2.0] — 2026-08-18

**`SendResult.transmission`** — the import receipt now says out loud what
4.1.1 could only say in a docstring (GPR-1089).

A successful `importFile()` returns `transmission: { mode: "enveloped",
bytePreservation: "not_guaranteed" }`. The gateway sends it on every import
`201`.

⚠️ An earlier draft of this entry added "including when you pass
`x-skip-validation`". True of the HTTP API, and **not something this SDK can
do**: `importFile()` takes no header or validation option, and `PeppolConfig`
carries none either. The sentence described a call you cannot write with this
package. It holds for a raw REST call, and that is where it belongs.

⚠️ Absent is not `false`, and there are three ways to get it: a JSON send (you
supplied no bytes), a gateway deployed before this field existed, and a replayed
`Idempotency-Key` whose cached body predates it. Read the value; do not read the
silence.

Both fields are typed `string`, not a literal union, on purpose: a union would
force this SDK to drop any value it predates, and a dropped object is an
**absent** `transmission` — which reads as "a JSON send, none of my bytes
involved". Compare against the values you know; do not assume the set is closed.

⛔ Read it as a **guarantee, not a measurement of your document**.
`not_guaranteed` does not mean "getpeppr changed it": we forward your bytes
verbatim and take no decision from their content. It means byte equality is
never something to rely on, because the network re-serialises in transit. The
field is absent on a JSON send — you supplied no bytes there, so the question
does not arise, and absent is not `false`.

⛔ And do not read it as "canonicalise first and my bytes will survive". A
canonical document was observed to come back unchanged **once**, on a document
the provider had itself generated — the very measurement that produced the
promise 4.1.1 had to withdraw. An idempotent normaliser is not a contract.

In one line: byte-for-byte equality is not guaranteed, and the remedy is to seal
a canonical form (C14N) rather than the raw bytes.

Asked for by the integrator who measured the rewrite: *"saying so on the receipt
would cost you a field and would have saved me a day."*

## [4.1.1] — 2026-08-18

Documentation only — no runtime change. But it corrects a promise this SDK made
in your editor, and an integrator built against it.

`importFile()` and `ImportInvoiceOptions` said the document travels **byte for
byte**, and that "what your recipient receives is exactly what you signed or
hashed". That is not true, and a customer measured it before we did.

What actually holds:

- getpeppr does not regenerate, normalise or repair your document — we forward
  the bytes you supplied, unchanged, to the network. That half is ours and it
  is real.
- **Byte-for-byte equality past that point is not guaranteed**, because the
  network re-serialises the document in transit.

Measured 2026-08-18 on a test document: namespace declarations came back
reordered, numeric character references were resolved (`&#65;` became `A`),
whitespace inside tags was dropped, and no element was added, removed or
altered. That is one document and four kinds of difference — read it as
indicative, not as a warranty of what is preserved.

**If you seal your documents, hash a canonical form (C14N) rather than the raw
bytes.** Doing so also makes your evidence independent of any single provider.

## [4.1.0] — 2026-08-18

Four rule identifiers this SDK reported were pointing at the wrong rule. They
were not invented — `BR-S-01`, `BR-S-06` and `BR-S-08` all exist in the official
Peppol rulebook — which is precisely what made them worse than a typo: you could
look one up, read a real rule, and find it had nothing to do with your problem.

| 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 themselves were right all along — a zero-rated line with a non-zero
rate was always caught. Only the label was wrong. **If you branch on any of the
four old strings, update it: the condition still fires, under its correct name.**

Read against `CEN-EN16931-UBL.sch` at Peppol BIS Billing 3.0 `v3.0.20`, and now
held there by a test that runs this SDK and the full official engine over the
OpenPeppol conformance corpus and fails if the two ever disagree on a shared
rule. That test is what surfaced these four.

### `validateSchematron()` now says what it covered

`SchematronResult` carries a `coverage` field:

```ts
coverage: { rulesChecked: number; ofNetworkFatalRules: "partial" }
```

This pre-flight 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 "conformant". It now says so in the payload, so a
caller cannot mistake a clean local pass for a network verdict.

`SDK_SCHEMATRON_RULE_IDS` is exported for callers who want the exact list.

### `importFile()` returns the rulebook it was judged against

`SendResult.rulebook` — `{ peppol, verifiedAt }` — is present on a raw-UBL
import that ran the gateway's validation gate.

⚠️ 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.
Never read a missing `rulebook` as "checked and fine".

### Corrected: not every `422` is terminal

The docs said retrying an import after a `422` always fails identically. True
for a rejected document — the same bytes fail forever, fix the document. **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 them as terminal costs a real invoice.

## [4.0.0] — 2026-08-16

`invoices.getStatus()` has been answering `"submitted"` for every invoice,
delivered ones included, and `waitFor()` has been timing out on documents that
arrived long before. Neither was a network problem: the SDK was inventing the
answer.

When a response carried no `status`, the parser fell back to `"submitted"`. That
value is not a terminal state, so unless `submitted` was itself the status you
were waiting for, a polling loop could do nothing with it but expire — 120
seconds in `waitFor()`, 60 in `getpeppr send --watch`. The invoice was fine. The
wait was not.

**The SDK no longer supplies a status it was not given.** A 2xx response with no
`status` now raises `PeppolProtocolError`. A fabricated status is
indistinguishable from a measured one for any consumer reading `status` alone;
an error is loud, attributable, and reaches the party who can fix it. (The one
tell was indirect: `rawStatus` was omitted in exactly that case. Nobody should
have to know that.)

The same reasoning removes two more inventions. `createdAt` used to fall back to
`new Date().toISOString()` — the moment of the call, presented as the moment of
creation — and then to `updatedAt`, a different measurement wearing the same
name.

### Breaking

- `invoices.getStatus()` and `invoices.list()` throw `PeppolProtocolError`
  instead of returning a status the gateway never sent. `invoices.send()` is
  unaffected: the gateway does report `submitted` there, truthfully, because it
  has just submitted the document.
- `SendResult.createdAt` is now optional (`createdAt?: string`). It is present
  whenever the gateway measured one, and absent rather than guessed otherwise.
  TypeScript consumers reading it as a `string` will need a narrowing check.
- `SendResult.rawStatus` and `InvoiceSummary.rawStatus` are now **required**
  (`rawStatus: string`), not optional. They were omitted only in the case that
  is now refused, so the type now says what the runtime already did.
- A `onResponse` hook receives a **copy** of the response body, not the object
  the parsers go on to read. A hook that mutated it — redacting a field before
  logging, say — used to change what the SDK parsed.

### Added

- `PeppolProtocolError` — a 2xx body the SDK cannot honestly parse. Carries
  `field` (what the response lacked, or `"body"`) and `responseBody` (the
  offending payload, serialised and capped at 2000 characters). Exported from
  the package root.

  It is raised on the invoice surfaces this release covers — `send()`,
  `getStatus()`, `list()` and the other methods returning a `SendResult` —
  where a missing `status`, a non-string `status`, or a body that is not an
  object is refused rather than papered over. Other list endpoints
  (`events`, `contacts`, …) keep their previous tolerant parsing for now.

  `responseBody` holds your own document data as the gateway returned it. It is
  there so you can see the shape that broke; treat it like any other payload
  before putting it somewhere it will be retained.

## [3.3.0] — 2026-08-14

`peppol.legalEntities` sat in autocomplete beside `invoices` and `contacts` with
nothing to distinguish it, and its documentation said only "Create a sub-tenant
Legal Entity for one of your customers". A developer typing `peppol.` reasonably
concluded it was for him. It is not: it is the platform surface, and it needs a
platform account and a master API key.

Worse, every surface that mentioned the requirement described the wall and never
the door. "Requires a master API key" is equally consistent with a checkbox, a
paid upgrade, and a sales conversation — a developer has no way to tell which,
and no way to act. It is the third: platform mode is enabled by us on request,
after which you create the master key yourself. That whole path is now in the
JSDoc of the `legalEntities` property and of all six of its methods, stated
before the first `@example`, because autocomplete truncates.

**Error messages no longer paste raw JSON.** The gateway answers 4xx with a JSON
envelope, and the SDK used to interpolate that object into `Error.message`
verbatim — braces, quotes and all — which the CLI then printed straight to the
terminal. When the envelope is recognisable you now get the sentence, followed
by the documentation link when one is sent:

```
before: getpeppr API error (403): {"error":"This endpoint manages sub-tenant
        legal entities…","code":"master_key_required","docs":"https://…"}

after:  getpeppr API error (403): This endpoint manages sub-tenant legal
        entities… See https://getpeppr.dev/docs/platform/legal-entities/
```

This applies to every gateway error, not just this one. Two envelope shapes are
in use and both are handled: most routes put the sentence in `error`, while some
— including the invoice-send path — put a machine code in `error` and the
sentence in `message`. The sentence is preferred wherever it lives, so you no
longer get `unsupported_vat_category` with the explanation dropped.

Anything unrecognised (a proxy's HTML, a body that is not an object, an envelope
with no usable sentence) still shows the body verbatim.
`PeppolApiError.responseBody` is untouched and stays raw — only the
human-readable message changed.

**Control characters are replaced with spaces throughout the message**, and the
docs link is only appended when it parses as an http(s) URL. Both matter because
a gateway error can quote a value you sent, and `Error.message` often lands in a
terminal, where an escape sequence would clear the line and overwrite what your
tooling just printed.

This is the one place the message is no longer byte-for-byte what it was: a body
carrying raw control bytes now shows them as spaces. DEL and the C1 range are
legal raw inside JSON, so the fallback had to be covered too — otherwise it was
a way straight back in.

**If you match on `error.message`, stop.** `PeppolApiError.statusCode` is the
stable signal. `.code` is useful but *not* universal: it reads the `code` field,
which the machine-code-in-`error` routes above do not always set — check it for
`undefined` rather than assuming it.

## [3.2.0] — 2026-08-12

Two VAT category codes have been advertised as usable for months and cannot be
delivered. `L` (IGIC, Canary Islands) and `M` (IPSI, Ceuta & Melilla) are valid
under EN 16931 — a developer reading the standard reasonably concludes they work
— but our provider has no vocabulary for them, so a document carrying one could
never reach its recipient. The gateway now refuses them with a 422
(`unsupported_vat_category`), and this release makes the SDK say so *before* the
send: in the types, in the catalogue, and in `validateSchematron`.

A third code moves the other way. `B` (Italian split payment) is accepted by the
network and the validator called it invalid, closing a corridor that was open.
Being stricter than the network is the worse of the two errors — a false refusal
costs a corridor, a malformed document costs one document — so `B` is now
recognised as a category, and separately reported as one we cannot route.

### Added

- `SENDABLE_VAT_CATEGORIES` — the seven codes getpeppr can actually put on the
  wire: `S` `Z` `E` `AE` `K` `G` `O`. Drift-locked against the gateway's own
  translation table, so it cannot quietly disagree with the endpoint it advises.
- `VALID_VAT_CATEGORIES` — the ten codes EN 16931 allows, taken verbatim from
  `BR-CL-17`'s test (`' AE L M E S Z G O K B '`). The two lists are deliberately
  separate: "is this a VAT category?" and "can we deliver it?" are different
  questions, and answering the second with the first is what produced the wrong
  message for two years.
- `VatCategory.sendable: boolean` on the entries returned by `getVatCategories()`.
  Filter on it to get only the deliverable codes; the rest are listed so the
  catalogue matches the standard, not because they can be used.

  ⚠️ Type-level nuance: this field is **required**, so code that builds a
  `VatCategory` object literal of its own now needs it. `VatCategory` is a return
  type of the catalogue rather than an input, so we expect no practical callers —
  but a compile error here means exactly that, and adding `sendable` is the fix.
- `B` (Italian split payment) in the catalogue, marked `sendable: false`. It was
  missing entirely, which told developers the network refuses a code it accepts.

### Changed

- `validateSchematron` now reports **two distinct findings** where it previously
  reported one:
  - `BR-CL-17` — the value is not a VAT category code at all.
  - `unsupported_vat_category` — the code is valid under EN 16931, but getpeppr
    cannot route it (`L`, `M`, `B`). There is no Peppol rule to cite here: it is
    a limit of our provider, and calling it "invalid" was simply false.

  ⚠️ **The rule id `PEPPOL-EN16931-R006` is gone.** Code that filters violations
  on that string will stop matching. It is not a deprecation with a grace period,
  because the id never named a real rule — that Peppol family stops at R005, R007
  and R008 in rulebook v3.0.20, and R006 does not exist. Filter on `BR-CL-17`
  (a genuine EN 16931 rule) and `unsupported_vat_category` instead.
- A `vatCategory` of `null` now counts as **absent**, exactly like `undefined`.
  The gateway has always treated it that way; the validator disagreed and
  reported `"null" is not a VAT category code` for payloads that send fine — the
  shape any JSON serialiser emits for an unset field.
- Violation messages echo at most 16 characters of the offending value. A VAT
  category is two characters, so a longer value is already wrong and quoting it
  whole gains nothing — unbounded, a 100 kB category produced a 100 kB message.
- `vatCategory` is documented as **case-sensitive** on `InvoiceLine` and
  `AllowanceCharge`. `"AE"` is reverse charge; `"ae"` is not a category. This is
  load-bearing rather than pedantic: `"ae"` used to be coerced to standard rate,
  shipping a valid-looking invoice that never shifted the VAT liability to the
  buyer.

### Deprecated

- `L` and `M` remain in the `vatCategory` union so existing code still compiles,
  with the warning carried in the doc comment. They will be **removed in the next
  major release**. There is no `@deprecated` tag: TypeScript cannot deprecate
  individual union members, so tagging the property would strike through `"S"`
  and `"AE"` in the IDE too — telling a developer that a field they must use is
  obsolete (GPR-1012).

  > **Correction (2026-08-17).** That did not happen. 4.0.0 was the next major
  > release and shipped with both still in the union; the removal now tracks as
  > GPR-1072. Their behaviour is unchanged — still unroutable, still refused
  > with a `422`. Left in place above because a changelog entry records what was
  > said at the time.

### CLI

- `@getpeppr/cli` **is** republished as `0.6.0` alongside this release. It is not
  a courtesy rebundle: `getpeppr validate` runs `validateSchematron` locally from
  the bundled SDK, so the published `0.5.4` was still emitting `PEPPOL-EN16931-R006`
  and still accepting `L`/`M` in silence. See that package's changelog for the
  full catch-up.

## [3.1.0] — 2026-08-11

Type-only release. The field below has been arriving in API responses since the
gateway shipped it — `verificationDetail` is passed through whole, so JavaScript
callers could already read it. What was missing was the *declaration*, which made
TypeScript callers fail to compile on a field the public docs already told them to
read.

### Added

- `LegalEntity.verificationDetail.registryStatus?: "inactive"` — why a verification
  failed, when the national business registry is the source that decided. Present
  means the registry **knows** the company and does not consider it active: struck
  off, in liquidation, or not yet active. Re-sending the same details will not
  change the outcome until the registry itself changes its answer. Surfaced today
  by the Belgian (KBO/BCE) and French (Sirene) registries.

  ⚠️ **Read it as a positive signal only — never infer anything from its absence.**
  The field is absent whenever we have no such finding to show for the current
  state, which covers several unrelated situations: the registry has no entry for
  that identifier (check for a typo, and that the scheme matches the number); the
  registry was unreachable at check time; our team has since reviewed the identity,
  so an earlier finding is no longer current; the evidence aged out of retention;
  or no `inactive` finding was ever recorded — verification can succeed on the VAT
  registration alone, so the national registry is not always consulted, and where
  it is, it is not always the source that decides.

  The companion `reason` field (`"name_mismatch" | "not_found"`) is a **frozen**
  enum — no value will ever be added to it. `registryStatus` carries what `reason`
  cannot express, which is why it is a separate field rather than a new member
  (GPR-1008, GPR-1009).

### Unchanged

- No runtime behaviour changes, no new exports, no removals. `@getpeppr/cli` is
  **not** republished: it neither imports nor bundles this type, and its
  `^3.0.0` range already resolves to this release from the workspace.

## [3.0.0] — 2026-08-05

The scheme fixes below landed on `main` after 2.6.0 was published and had never
reached an `npm install`. The gateway endpoints that register a Peppol identity
(`POST /v1/legal-entities`, onboarding) already ran the corrected rules — only
the SDK's local, developer-side validation was behind.

⚠️ "Server-side" here means those registration endpoints, **not**
`invoices.validateServer()` / `POST /v1/validate/server`, which validates invoice
documents and has never checked scheme rules at all. Neither does
`POST /v1/invoices`, which only parses `scheme:id` for shape.

### Removed

- **BREAKING:** `GB:CRN` (Companies House number) is gone from `SCHEMES_BY_COUNTRY.GB`, where it was the `recommended` UK default. It is not a Peppol scheme: it has no EAS code and does not appear in the OpenPeppol code list at all, so a Legal Entity registered under it is reachable by nobody — while the SDK, the provider, our database and the Trust Layer all reported success. Companies House remains a fine registry to prove *who* you are; it is not an *address*. If you read `SCHEMES_BY_COUNTRY.GB` to build a scheme picker, the `GB:CRN` entry is now absent and UK users should be offered `GB:VAT`. Major bump per semver so this is read rather than picked up silently through a `^2.6.0` range (GPR-965).

  **Calling `validatePeppolIdentifier("GB:CRN", …)` directly now fails OPEN.** Because `GB:CRN` is no longer a listed scheme, it falls to the permissive path taken by any unrecognised scheme: `validatePeppolIdentifier("GB:CRN", "abc")` returned `valid: false` in 2.6.0 and returns `valid: true` here. That is deliberate — the SDK does not format-check schemes it does not recognise — but if you relied on this call to reject malformed Companies House numbers, it no longer does. Validate them against Companies House, or move the identifier to `GB:VAT`.

### Changed

- **`GB:VAT` is now the recommended UK scheme, and its format rule was rebuilt from measurement.** The previous `/^GB\d{9}$/` accepted **zero** of the 390 live `9932:` participants sampled from the Peppol Directory on 2026-08-03 — 351 of them write the prefix in lowercase (`gb`), and 39 carry no prefix at all. The UK was therefore closed by both of its doors at once. The new rule is case-insensitive and accepts 385 of the 390. The five refused are 7-, 8- and 10-character values, i.e. almost certainly Companies House numbers typed into a VAT field — the exact mistake this release exists to stop (GPR-965).

  Northern Ireland is now accepted too: an `XI`-prefixed number (`XI123456789`, `XI123456789001`) validates where 2.6.0 rejected it. Purely additive. NI traders write `XI` in front of the VRN they already hold under the NI Protocol, and OpenPeppol defines no separate NI scheme — the EAS list carries exactly one GB entry — so they can only ever register here. Note that `XI` binds to the numeric forms only; there is no `XIGD###`.

  The accepted forms are 9 digits (standard) and 12 digits (VAT groups and branch traders), which are the forms HMRC's own VAT-number service processes, plus the legacy `GD` and `HA` prefixes still in circulation for government departments and health bodies. **The numeric bounds we apply to `GD` and `HA` are ours, not an issuer's.** No HMRC document we have read establishes an `HA` + 3-digit form or those ranges; they encode our pattern's intent and nothing more. Treat them as a syntax filter, never as evidence that a number was validly issued.

- **Scheme lookup is now case-insensitive, so format rules apply to spellings that previously escaped them.** This is a behaviour change you can observe: 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 the permissive path. It now returns `valid: false`. The same applies to `de:lwid`, `DE:LWID` and `9932` — spellings that resolved to nothing before. Note that `0204` is **not** in that list: it was already recognised in 2.6.0 and already rejected malformed values; what changed for it is the widened Leitweg-ID pattern below, which makes it accept *more*, not less. Nothing that was correct becomes incorrect — but input that silently passed validation may now be rejected, which is the point (GPR-965, GPR-966).

### Added

- The error message for a malformed scheme string now offers `GB:VAT` as its country-prefix example instead of `GB:CRN`. Cosmetic, but it is user-visible text: the old copy taught the one scheme this release removes (GPR-965).
- `SchemeOption.aliases` — alternative spellings that denote the same scheme, matched case-insensitively. Provider vocabularies diverge from the numeric EAS codes, so a format rule attached to one spelling left the other silently unvalidated. `GB:VAT` now carries `9932` and the German Leitweg-ID `0204` carries `DE:LWID` (GPR-965, GPR-966).

### Fixed

- **The German Leitweg-ID (`0204`) format rule rejected 17 of the 19 real identifiers sampled from the network**, including Stadt Augsburg, Deutsche Rentenversicherung Bund and BMW Germany. The old `/^\d{2,12}-\d{4,5}-\d{2}$/` assumed a numeric 4–5 digit middle block; the middle block is in fact optional, alphanumeric and 1–30 characters. German B2G onboarding was walled off at the SDK before a request ever reached the gateway. The new rule departs from the OpenPeppol v9.7 regex on two points, each with its own authority: it is case-insensitive where the official pattern says `[0-9A-Z]` (no real middle block observed on the network carries an uppercase letter), and it requires 1–30 rather than 0–30 characters in the optional group, because `{0,30}` admits the degenerate `04011000--79` that KoSIT's own Leitweg-ID Format-Spezifikation v2.0.2 forbids. The spec-legal no-middle-block shape (`04011000-79`) still passes (GPR-966).
- `0204` is also no longer described as public-sector-only. It is the scheme that routes to German private companies too, and calling it "B2G" steered them away from the only address that reaches them (GPR-966).
- `SDK_VERSION` is now locked to `package.json` by a test, and a second test reads the `User-Agent` off an intercepted request so the constant is checked where it actually reaches the wire. A drift silently misattributes traffic to a version the caller is not running — releases 2.2.0 and 2.3.0 each had to repair the same drift after the fact. Note that binary downloads (`…/as/pdf`, `…/as/xml`) send no `User-Agent` at all; only JSON requests carry it (GPR-977).

## [2.6.0] — 2026-07-24

### Added

- `ServerValidationResult.countryRules` — an optional array of gateway-level country-rule findings (`{ code, message, docs }`) returned by `POST /v1/validate/server`. These are compliance checks that need the account's registered Peppol identity, which the client-side validators cannot see (currently the Dutch document rules NL-R-001..008). A non-empty array means `POST /v1/invoices` would reject the same payload with a `422 country_rule_violation` carrying the same `code`. Additive and never affects `valid`; the gateway has returned the field in production since the NL send-rules guard shipped (GPR-872), this release brings the published type in sync.

## [2.5.0] — 2026-07-24

### Added

- `WEBHOOK_EVENT_TYPES` — the subscribable webhook event types, now exported as a runtime constant; the `WebhookEventType` type is derived from it. The list mirrors exactly the event types the gateway accepts for webhook subscriptions, and a gateway↔SDK contract test now locks the two sides together (GPR-868).

### Fixed

- `directory.search()` now remaps the gateway's snake_case pagination meta (`total_count`, `has_more`) to the documented camelCase `meta` shape (`totalCount`, `hasMore`). Both fields were `undefined` at runtime despite the declared `number`/`boolean` types — `directory.search()` was the only paginated method missing the remap (GPR-868).

### Removed

- The phantom webhook event types `creditnote.sent`, `creditnote.received` and the deprecated `invoice.closed` are removed from `WebhookEventType`. None of them was ever accepted by the gateway — subscribing to any of them returned `400 Invalid event type` — so no working integration can break at runtime. TypeScript consumers referencing these literals will get a compile-time error; drop the reference (GPR-868).

## [2.4.0] — 2026-07-16

### Added

- **France country rules** (advisory warnings, buyer-side) (GPR-134):
  - `FR-01` now accepts a 9-digit SIREN as `to.companyId` (previously SIRET-only) and validates checksums — standard Luhn, with the INSEE-documented La Poste fallback (digit sum % 5 for SIREN `356000000` exactly). Skipped when `companyIdScheme` is a non-SIREN scheme (e.g. GLN `0088`).
  - `FR-03` (new): numeric French VAT keys are verified against the DGFiP formula `(12 + 3 × (SIREN mod 97)) mod 97` — catches VAT-number typos offline. Alphanumeric keys (rare but legitimate) are never rejected.

### Fixed

- **SIRET checksum model corrected** in the shared checksum helpers (also used by Peppol scheme validators `0002`/`0009`): the previous implementation applied the digit-sum rule to the whole `356xxx` SIREN range as a Luhn replacement, which **rejected La Poste's real head-office SIRET** `35600000000048` and accepted garbage in that range. The digit-sum rule is now a fallback for SIREN `356000000` only, and the SIREN embedded in any SIRET must itself be Luhn-valid (GPR-134).
- `FR-02` VAT format now excludes the letters `I`/`O` from the 2-character key alphabet (never issued — confusable with `1`/`0`).
- French identifier checks are hardened against non-string values from untyped JavaScript callers: numeric/`Symbol`/object `companyId` or `vatNumber` now produce a fixed-message warning instead of crashing (`input.slice is not a function`, Symbol interpolation `TypeError`) or silently passing an invalid value (GPR-134).

## [2.3.0] — 2026-07-16

### Added

- `computeUblPayableAmount(input: UblMonetaryInput): number` — the document's BT-115 `PayableAmount` (legal amount due) computed with the exact same formula `buildInvoiceXml`/`buildCreditNoteXml` render into `cac:LegalMonetaryTotal`: line- and document-level allowances/charges, VAT, prepaid and rounding included. Exposed so consumers never re-derive the amount from a parallel formula that could drift from the UBL sent to the network. New exported type `UblMonetaryInput` — the monetary-only projection of an invoice/credit note (`lines`, `allowances`, `charges`, `prepaidAmount`, `roundingAmount`) (GPR-833).

### Fixed

- **EN 16931 BR-CO-17 compliance**: `calculateTaxSubtotals` now computes each tax subtotal's `cbc:TaxAmount` (BT-117) by rounding ONCE per tax group (`round2(group taxable base × rate)`) instead of accumulating per-line rounded taxes. Per-line accumulation drifted a cent on sub-cent line amounts (two €0.03 lines @ 21% produced a €0.02 group VAT instead of the compliant €0.01, i.e. a payable of €0.08 instead of €0.07) and violated the BR-CO-17 Schematron rule (GPR-833).
- `cbc:PayableAmount` arithmetic now rounds via `toFixed` (matching `formatAmount` exactly) instead of relying on the raw float — eliminates a rare 1-cent XML drift on float dust (e.g. `roundingAmount: -0.325`) (GPR-833).

## [2.2.0] — 2026-07-14

### Added

- `rawStatus?: string` on `SendResult` and `InvoiceSummary` — the raw pre-coercion status string exactly as the gateway sent it. When the gateway emits a status the SDK doesn't recognize, `status` still coerces to `"unknown"` but the original value is no longer destroyed (GPR-825).
- `detail?: StatusDetail` on `SendResult` and `InvoiceSummary` — layer-2 structured national detail from the multi-jurisdiction status model, one `StatusDetailEntry` per axis (`platformFiscal`, `delivery`, `businessDisposition`, `settlement`). New exported types `StatusDetail` and `StatusDetailEntry`. Parsing is tolerant: malformed axis entries are dropped, never thrown on (GPR-825).
- `STATUS_PRECEDENCE`, `statusFamily()`, `TERMINAL_FAILURE_STATUSES` and types `StatusFamily` / `StatusPrecedenceEntry` exported — the §8.1 synthesis precedence as data, most final first (GPR-825).
- `invoice.partially_paid`, `invoice.under_query`, `invoice.conditionally_accepted` and `invoice.status_changed` added to the `WebhookEventType` union. `invoice.status_changed` is opt-in: it is never matched by a `"*"` wildcard subscription — subscribe to it explicitly. Additive at runtime; TypeScript consumers exhaustively switching on `WebhookEventType` must add cases for the new members (GPR-825).

### Changed

- `waitFor()` terminal sets are now derived from `STATUS_PRECEDENCE` instead of a hard-coded list (same set: `failed`, `rejected`, `no_action` — behavior unchanged). New: when the document reaches a terminal success (`paid`) that outranks a progress-only target (e.g. waiting for `delivered`), `waitFor()` now resolves with the real result (`result.status === "paid"`) instead of polling until the timeout — the target was passed, not missed. If any target is a terminal failure or `unknown`, it throws immediately instead. Code that previously ended in a timeout error on paid invoices now resolves (GPR-825).

### Deprecated

- `invoice.closed` in `WebhookEventType` — never emitted by the gateway; kept for type compatibility, removal at the next major (GPR-825).

### Fixed

- `SDK_VERSION` (sent in the `X-SDK-Version` header) had drifted to `2.0.0` — realigned to the package version (GPR-825).

## [2.1.0] — 2026-07-13

### Added

- `invoice.undeliverable` added to the `WebhookEventType` union — dispatched by the gateway when a recipient has no receiving capability on the Peppol network (the document ends in status `no_action`, "Not Deliverable"). Additive at runtime; TypeScript consumers exhaustively switching on `WebhookEventType` must add a case for the new member (GPR-830).

### Changed

- `waitFor()` now treats `no_action` as a terminal failure alongside `failed` and `rejected`: it throws immediately instead of polling until the timeout. Explicitly waiting for `no_action` as a target still resolves — targets are checked before terminal failures (GPR-830).

## [2.0.0] — 2026-06-21

### Changed

- **BREAKING:** `EventEntry` is now `{ id, eventType, documentId, metadata, createdAt }`. The previous fields (`name`, `text`, `notes`, `invoice`, `contact`) described a legacy B2BRouter feed shape the gateway never returns — any code referencing them was already receiving empty values at runtime. `peppol.events.list()` / `events.listAll()` now map the actual fields the gateway sends (rows are camelCase; pagination meta stays snake_case — same split as `invoices.list`). Update consumers to read `eventType` / `documentId` / `metadata`. Major bump per semver for the public type break. Part of GPR-725.

### Added

- `peppol_identifier.verified` and `peppol_identifier.verification_failed` added to the `WebhookEventType` union — both are dispatched by the gateway (Trust Layer identifier verification) but were missing from the SDK type. Additive (GPR-725 / GPR-743).

## [1.9.0] — 2026-06-18

### Fixed

- `peppol.invoices.list()` now returns the real invoice `number` and `createdAt` for every row (GPR-738). The mapping read snake_case / wrong field names (`number`, `created_at`) while the gateway returns camelCase (`invoiceNumber`, `createdAt`), so every summary came back with a blank `number` and an undefined `createdAt`. The legacy `number` field is still accepted as a fallback for back-compat.

### Added

- `InvoiceSummary` now surfaces `isCreditNote`, `recipientName`, `totalAmount` (minor units), `currency`, and `environment` — the gateway already returned them; they were being dropped. All fields are optional and additive.

## [1.8.0] — 2026-06-11

### Added

- `legal_entity.registration_failed` added to the `WebhookEventType` union (GPR-622): emitted when a sandbox sub-tenant's identity verified but its network (SMP) registration failed — the entity is not sendable. The payload carries `status: "registration_failed"` and a `reason` of `already_registered`, `invalid_format`, or `provider_error`. Type-only change.

## [1.7.0] — 2026-06-11

### Added

- `inbound.invoice.received` / `inbound.creditnote.received` added to the `WebhookEventType` union (GPR-696): inbound Peppol reception events pushed when a document is received for one of your legal entities. Type-only change.

## [1.6.0] — 2026-06-08

### Added

- Multi-tenant `peppol.legalEntities` resource: create, get, list, archive sub-tenant Legal Entities with a master API key (GPR-517).
- Typed `sender` field on invoice send (`legalEntityId` XOR `externalSubTenantId`) for sending on a customer's behalf.
- `PeppolApiError.code` getter exposing the gateway error code.
- Per-request `idempotencyKey` option.

## [1.5.2] — 2026-06-04

### Fixed

- Directory lookup now unwraps the gateway response into the SDK's documented `DirectoryEntry` shape.
- Public examples now use verified Belgian Peppol/VAT identifiers instead of placeholder IDs that fail copy-paste smoke tests.

## [1.5.1] — 2026-06-03

### Fixed

- Documentation and generated examples now use valid Belgian Peppol IDs for directory lookup and sandbox sending.
- SDK package metadata now ships with the corrected public examples used by the website and CLI docs.

## [1.5.0] — 2026-05-24

### Added

- `ServerValidationResult` now exposes UBL XML generation status under `ubl`.

### Changed

- `ServerValidationResult.xsd` is deprecated compatibility metadata; `xsd.valid` mirrors `ubl.valid` because the gateway does not run standalone XSD validation.
- Server-side validation responses now support the GPR-314 contract where validation findings return `200` with `valid: false` instead of requiring SDK clients to handle them as exceptions.

## [1.4.2] — 2026-05-07

### Fixed

- Repaired published package imports — the 1.4.1 tarball could fail to resolve internal module paths on consumer installs (#289).

## [1.4.1] — 2026-05-07

### Changed

- Package metadata normalization and version alignment across the monorepo (#287, #290). No API change.

## [1.4.0] — 2026-05-01

### Fixed

- Bug bundle from the GPR-414 review (PR-B, #270): `validateInvoice` no longer throws on malformed input (returns structured errors), `paginate` offset arithmetic corrected on partial pages, `Idempotency-Key` response header lookup made case-insensitive, `mapStatus` now warns on unknown gateway statuses instead of silently coercing.

## [1.3.0] — 2026-04-27

### Added

- `PEPPOL_ICD_CODES` (ReadonlySet of the 220 ISO 6523 ICD codes) and `isPeppolIcdCode()` exported as the shared single source of truth — the console mapper imports them instead of redeclaring locally (#246). Published alongside the `@getpeppr/cli` 0.3.x launch.

## [1.2.0] — 2026-04-21

### Added

- Peppol identifier rules module (GPR-313): `SCHEMES_BY_COUNTRY` and `validatePeppolIdentifier()` for scheme-aware identifier validation, supporting production onboarding flows.

## [1.1.3] — 2026-04-11

### Fixed

- Defensive hardening pass: JSON parse protection on responses, network-error retry, POST retry safety, `User-Agent` header, UBL quantity formatting, base URL normalization, webhook docstring fix.

## [1.1.2] — 2026-04-05

### Added

- `phone` field on `Contact`/`ContactInput` types and `parseContact()` mapper (GPR-257).

## [1.1.1] — 2026-04-05

### Added

- `directoryVerified`/`directoryLastChecked` on the `Contact` type and `parseContact()` mapper (GPR-255).

## [1.1.0] — 2026-04-04

### Removed

- `listReceived()` — misleading: it called the sent-invoices endpoint (GPR-181).
- Unused `type` filter on `listAll()`/`listInvoices()`; `listAll()` signature aligned with the `Omit` pattern.

## [1.0.4] — 2026-04-03

### Fixed

- Bug-hunt fixes: credit note `toXml`/validator support, tax rounding, `timingSafeEqual` for webhook signatures, date regex, `offset`/`limit` handling, `parseSendResult`, `waitFor` timeout behavior, `mapDocType`.

## [1.0.3] — 2026-03-31

### Fixed

- `mapStatus` mapping fix, `listReceived` fix, `waitFor` terminal statuses handling.

## [1.0.2] — 2026-03-31

### Fixed

- Default base URL corrected to `https://api.getpeppr.dev/v1`
- README: removed draft+send example (not supported by current provider)
- README: webhook events table aligned with actually emitted events
- Added `publishConfig.access: "public"` to package.json

## [1.0.1] — 2026-03-31

### Fixed

- npm README rendering issue (re-publish to force CDN refresh)

## [1.0.0] — 2026-03-31

### Breaking Changes

- **Package renamed** from `@getpeppol/sdk` to `@getpeppr/sdk` — update your imports and `package.json`
- **Webhook signature header** renamed from `X-B2Brouter-Signature` to `Getpeppr-Signature` — update your webhook handler to read the new header

### Added

- `test.ping` webhook event type for endpoint testing
- Outbound webhook support via dashboard (configure endpoints, events filter, secret)
- Webhook delivery with automatic retry (exponential backoff, max 4 attempts)

### Infrastructure

- SDK is now feature-complete: 50 features covering full Peppol BIS 3.0 spec
- 543 unit tests, full E2E coverage against Storecove sandbox

## [0.3.0] — 2026-03-16

- `BuyerParty` now requires `street`, `city`, `postalCode` (Peppol BIS 3.0 BG-8)
- Full Storecove provider support (migrated from B2BRouter)
- Country-specific validation rules (Belgium, Spain, Italy)
- Offline Schematron validation

## [0.2.0] — 2026-02-15

- Credit note support (`isCreditNote` + `invoiceReference`)
- Batch send with concurrency control
- Async pagination iterator (`listAll()`)
- Peppol Directory lookup
- Webhook signature verification (HMAC-SHA256)

## [0.1.0] — 2026-01-15

- Initial release
- Invoice CRUD (create, send, list, get)
- Client-side validation
- UBL 2.1 XML generation
- Automatic retry with exponential backoff
