Error Handling
Understand error types, HTTP status codes, and implement robust retry strategies.
Error Types
The SDK throws typed errors you can catch and handle precisely. Every one of them extends PeppolError, so catching that class matches them all — including the base class itself, which the SDK throws directly for an invalid config and from waitFor(), the directory and the webhook helpers.
PeppolValidationError— The invoice failed local validation and was never sent. The details are onvalidation, not on the error: readerror.validation.errorsfor the field paths and messages.PeppolProtocolError— The gateway answered2xxwith a body missing a field the contract makes mandatory. CarriesfieldandresponseBody(serialised, and truncated past 2000 characters). Your payload does not cause it, so a retry loop is the wrong response — but a later call can succeed, since nothing about it is sticky. Added in SDK 4.0.0.PeppolApiError— The gateway answered something the SDK could not accept. Usually a 4xx or 5xx, but also a2xxwhose body is not JSON — so readstatusCoderather than assuming it is an error code. Always carriesstatusCodeandresponseBody. Two more are best-effort and can beundefined:retryAfterMs, which needs a 429 carrying a readableRetry-After, and thecodegetter, which reads the body'scodefield — some routes put their machine code inerrorinstead. AndresponseBodyis what arrived only when the body was JSON; otherwise it holds a note the SDK wrote in its place.
Some failures stay outside that hierarchy. When the request never completes, the rejection comes from the runtime, not from us: a TypeError for a failed connection (DNS, a refused or reset socket), and an Error named AbortError for a timeout or a request you aborted. The SDK retries both when its retry policy allows, then rethrows them untouched — so no instanceof PeppolError check matches either. Always keep a final else branch that rethrows.
import {
Peppol,
PeppolError,
PeppolValidationError,
PeppolProtocolError,
PeppolApiError,
} from "@getpeppr/sdk";
const peppol = new Peppol({ apiKey: "sk_live_..." });
try {
const result = await peppol.invoices.send(invoiceData);
console.log(`Success: ${result.id}`);
} catch (error) {
if (error instanceof PeppolValidationError) {
// Local validation failed — invoice never sent.
// The details live on `validation`, not on the error itself.
console.error("Validation errors:");
for (const e of error.validation.errors) {
console.error(` ${e.field}: ${e.message}`);
}
} else if (error instanceof PeppolProtocolError) {
// A 2xx body the SDK cannot honestly parse (SDK 4.0.0).
// Not caused by your payload — report it rather than looping on it.
console.error(`Missing "${error.field}": ${error.message}`);
console.error(error.responseBody); // serialised, truncated past 2000 chars
} else if (error instanceof PeppolApiError) {
// The gateway answered something the SDK could not accept.
// Usually a 4xx/5xx — but also a 2xx whose body is not JSON.
console.error(`API error [${error.statusCode}]: ${error.message}`);
console.error(error.code); // may be undefined — some routes
// put the code in `error` instead
console.error(error.responseBody); // the body, or an SDK note if it was not JSON
} else if (error instanceof PeppolError) {
// The base class is thrown directly too: bad config, waitFor, directory.
console.error(`SDK error: ${error.message}`);
} else {
// Not ours at all: a failed connection rejects with the runtime's
// TypeError, a timeout or abort with an AbortError. Neither is a
// PeppolError, so this branch has to exist.
throw error;
}
}HTTP Status Codes
The API uses standard HTTP status codes. Here are the ones you'll encounter:
| Status | Meaning |
|---|---|
200 | Success — request completed. |
201 | Created — invoice sent successfully. |
400 | Bad Request — invalid parameters or validation error. |
401 | Unauthorized — missing or invalid API key. |
403 | Forbidden — your API key is valid but lacks the required scope. |
404 | Not Found — resource doesn't exist. |
409 | Conflict — the request is valid but the resource is not in the right state. |
422 | Unprocessable Entity — business-rule gate failed, such as identity verification or send readiness. |
429 | Too Many Requests — rate limit exceeded (see Rate Limits). |
500 | Server Error — retry with exponential backoff. |
{
"error": "validation_error",
"message": "Invoice validation failed",
"errors": [
{
"field": "from.vatNumber",
"message": "VAT number is required for invoices exceeding EUR 400",
"suggestion": "Add vatNumber to the seller party"
}
],
"requestId": "req_abc123xyz"
}{
"error": "not_found",
"message": "Invoice inv_xyz789 not found",
"statusCode": 404,
"requestId": "req_def456uvw"
}Pre-send Compliance Gates
The Peppol network validates documents after your provider accepts them, so a non-compliant invoice would otherwise return 201 and then fail asynchronously, hours later, with no way for you to notice. getpeppr runs those checks up front instead and returns a 422 before the document leaves — every code below names the network rule it enforces, so you can look it up rather than guess.
| Code | Meaning / next step |
|---|---|
invalid_country_code | A country field is present but is not a code the network accepts. The message names the exact field and echoes what we received. Case and surrounding spaces are repaired for you — "nl" is fine; "NLD" and country names are not. |
country_rule_violation | A national Peppol rule for the supplier's country is not met. The code field carries the official rule id (for example NL-R-003) and docs links to it. For the Dutch identity error, follow the NL-R-003 fix guide. |
peppol_identity_incomplete | Your account has no registered Peppol identifier, so a production send is refused before the invoice is read. A related refusal — the identity exists but carries no VAT number, which blocks any invoice carrying VAT in sandbox as well as production — arrives without a machine code, with the guidance in error. Both are covered by the sender VAT fix guide. |
unsupported_payment_means | The payment means cannot be routed over Peppol by our provider. Cheques (20) and generic bank-account transfers (42) have no route; the message lists the codes that do. |
payment_mandate_required | Direct debit (49 or 59) requires a mandate reference getpeppr cannot yet send, and the network rejects such invoices (rule PEPPOL-EN16931-R061). Use a credit transfer — 30, or 58 for SEPA. |
POST /v1/validate/server reports these same findings in its countryRules array without sending anything, so you can surface them in your own UI before your user hits send.{
"error": "invalid_country_code",
"message": "to.country must be a country code the Peppol network accepts (2 letters, e.g. \"NL\"). Received: \"NLD\". The network rejects any other form (rule BR-CL-14).",
"requestId": "req_ghi789rst"
}{
"error": "country_rule_violation",
"code": "NL-R-003",
"message": "Dutch suppliers must include a KVK or OIN number. Register one on the Peppol identity page (scheme \"0106\" for KVK, \"0190\" for OIN) — the Peppol network rejects Dutch invoices without it (rule NL-R-003).",
"docs": "https://docs.peppol.eu/poacc/billing/3.0/rules/ubl-peppol/NL-R-003/"
}Platform Errors
Platform accounts use a master key to manage sub-tenants and send on their behalf. These endpoints add a few multi-tenant error semantics on top of the generic API errors above.
Status codes
| Status | Meaning |
|---|---|
403 | Authenticated key is not a master key, or lacks the required legal_entities:* scope. |
404 | Sub-tenant is unknown, disabled, malformed, or belongs to another platform. getpeppr returns 404 instead of 403 to prevent resource enumeration. |
409 | Operation conflicts with the sub-tenant lifecycle, for example requesting attestation too early or after the customer already attested. |
422 | Sending is blocked by a business gate. Check the code field to decide what your UI should show. |
502 | Attestation email delivery failed. Retrying can mint a fresh authorisation link. |
Send-as gate codes
When POST /v1/invoices includes sender, a 422 response uses error: "peppol_identity_not_verified" with one of these code values:
| Code | Meaning / next step |
|---|---|
verification_pending | Registry verification has not completed yet. Poll the legal entity or wait for a lifecycle webhook. |
verification_failed | Registry verification failed. Inspect the legal entity status or the legal_entity.verification_failed webhook. |
attestation_required | Production customer authorisation is required before sending. Request attestation and wait for the customer to authorise. |
peppol_identity_expired | The authorisation window expired. Request a new attestation. |
provisioning | Sub-tenant is attested but still being registered on the network. Wait until the legal entity becomes active. |
legal_entity.verification_failed as a lifecycle webhook, not as a retryable HTTP failure. Your UI should map it to the customer record via subTenantId and show the remediation path.{
"error": "sender requires exactly one of legalEntityId or externalSubTenantId"
}{
"error": "peppol_identity_not_verified",
"code": "attestation_required",
"message": "Sub-tenant attestation is required before sending in production.",
"docs": "https://getpeppr.dev/docs/platform/sending-and-webhooks/"
}Retry Strategies
For transient failures (5xx errors, network timeouts), implement exponential backoff. Never retry client errors (4xx) — they require fixing the request.
Best Practices
- Retry only 5xx — server errors are transient, client errors need fixes
- Exponential backoff — wait 1s, 2s, 4s between retries
- Max 3 retries — avoid hammering the API
- Include requestId — send it to support for debugging persistent failures
send() — the same invoice won't be sent twice.import { Peppol, PeppolApiError } from "@getpeppr/sdk";
const peppol = new Peppol({ apiKey: "sk_live_..." });
async function sendWithRetry(invoice: any, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await peppol.invoices.send(invoice);
} catch (error) {
if (error instanceof PeppolApiError) {
// Don't retry client errors (4xx) — only server errors (5xx)
if (error.statusCode < 500) throw error;
// Don't retry on last attempt
if (attempt === maxRetries) throw error;
// Exponential backoff: 1s, 2s, 4s
const delay = Math.pow(2, attempt - 1) * 1000;
await new Promise((r) => setTimeout(r, delay));
} else {
throw error;
}
}
}
}