# @getpeppr/sdk

Send Peppol e-invoices in under 20 lines of code.

[![npm version](https://img.shields.io/npm/v/@getpeppr/sdk.svg)](https://www.npmjs.com/package/@getpeppr/sdk)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
[![TypeScript](https://img.shields.io/badge/TypeScript-first-blue.svg)](https://www.typescriptlang.org/)
[![Node](https://img.shields.io/badge/Node-%3E%3D18-green.svg)](https://nodejs.org/)

```typescript
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

```bash
npm install @getpeppr/sdk
```

## Upgrading 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 (`abc` is inside the set), but
> `("GB:CRN", "AB/CD")` now returns `valid: 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/invoices` currently derives invoice vs credit note from `isCreditNote`; `invoiceTypeCode` is 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

```typescript
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: [
    { description: "Refund for consulting services", quantity: 2, unitPrice: 150, ...identity.sandboxFirstSend.line },
  ],
});
```

### Webhook verification

```typescript
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

```typescript
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

```typescript
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

```typescript
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.

```typescript
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

```typescript
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](https://console.getpeppr.dev/api-keys).
> Production platform access is set up with our team — email
> [hello@getpeppr.dev](mailto:hello@getpeppr.dev) 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.

```typescript
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" },
});

// 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: "owner@acme.example", 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

```typescript
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:

```typescript
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:

```ts
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: false` is honoured even on a `500` — nine public catalogue codes
  are permanently fatal, and retrying those is pure waste.
- `retryable: true` is honoured on statuses that were never retried before, such
  as the `409`s 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 get `409
  legal_entities.creation_in_progress` instead 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 same `externalId` with *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](https://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` | Delivery error |
| `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 `status` raises `PeppolProtocolError` (with `field` and `responseBody`) 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 for `submitted` itself, `waitFor()` and `getpeppr send --watch` could 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 support@getpeppr.dev.
- `SendResult.createdAt` is 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 from `updatedAt`.
- `SendResult` and invoice list rows carry `rawStatus` (the raw gateway status string before SDK coercion — preserved even when `status` falls back to `unknown`) and `detail` (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 failures `failed`, `rejected` and `no_action`. If the document reaches the terminal success `paid` while 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_PRECEDENCE` table (most final first), `statusFamily()` and `TERMINAL_FAILURE_STATUSES` are exported for consumers that need the same ordering the gateway uses.

## Links

- [Documentation](https://getpeppr.dev/docs)
- [Website](https://getpeppr.dev)
- [GitHub — examples, Postman collection](https://github.com/zerolooplabs/getpeppr-docs)

## License

MIT
