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. |
202 | Accepted — the request was accepted; remaining steps (verification, webhook notification) continue after the response. |
204 | No Content — success with an empty response body (e.g. a delete, or an accepted send). |
302 | Found — redirect, used by the newsletter confirm and unsubscribe links. |
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. |
405 | Method Not Allowed — the resource exists but this verb is not supported; e.g. transports are managed by the provider (transports.managed_by_provider). |
409 | Conflict — the request is valid but the resource is not in the right state. |
413 | Payload Too Large — the declared Content-Length exceeds the route's cap: 1 MB, or 4 MB where a document travels in the body. |
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. |
502 | Bad Gateway — an upstream getpeppr depends on failed. Usually retryable; some need support. |
501 | Not Implemented — the operation is intentionally unavailable on the provider; e.g. draft invoices do not exist (there is no draft state to send). |
503 | Service Unavailable — an upstream dependency is temporarily unavailable, or the provider asked to retry later. Usually retryable; if the provider capability is not configured, contact support. |
{
"error": "invalid_base_quantity",
"message": "baseQuantity must be a finite number greater than zero.",
"field": "lines[0].baseQuantity",
"rule": "PEPPOL-EN16931-R121"
}{
"error": "Invoice not found"
}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. Sender tax mismatches use the local codes sender_tax_identifier_missing and outside_scope_sender_has_tax_identifier; both are refused before document submission and covered by the sender tax identity 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. |
invalid_provider_tax_rate | Storecove rejected vatRate for the seller legal entity's tax country at the invoice date. The response carries field: "vatRate", rule: "provider_tax_rate_catalogue", and this locally-authored safe message; no provider text or invoice value is echoed. Check the rate or send with the legal entity registered for the intended tax jurisdiction. |
POST /v1/validate/server reports gateway-owned findings in its countryRules array without sending anything. It does not run Storecove validation; read providerSendability: "not_checked" and do not treat valid: true as proof that the provider will accept a later 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)."
}{
"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. |
unsupported_scheme | Automatic verification is unavailable for this identifier scheme. Do not keep polling; contact support before sending. |
verification_failed | Registry verification failed. Inspect the legal entity status or the legal_entity.verification_failed webhook. |
registration_failed | Network registration (SMP publication) failed — with or without a registry verification behind it (a registryless test identity can fail here too). Inspect the legal entity status or the legal_entity.registration_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. |
When the gate runs on your own account identity — any standard key, or a master key without a customer sender to resolve — the same 422 peppol_identity_not_verified can also carry name_mismatch — the declared company name does not match the name on your Legal Entity — or smp_not_registered — your identity is not marked as registered on the network yet: publication may still be in progress, have failed, or have been retracted. Poll the legal entity status or the lifecycle webhooks to see which.
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/onboarding/verification"
}Retry Strategies
Transient failures and network timeouts are retried for you, with exponential backoff — you configure that policy rather than writing it. The SDK retries 429, 500, 502, 503 and 504 — but the status is only the fallback. When a response carries Getpeppr-Retryable, that header decides instead, in both directions: a few 409 conflicts are retried because they resolve on their own, and some 5xx results are not, because they never will. One deliberate exception: on a 5xx it will not replay a POST sent without an Idempotency-Key, because an unacknowledged write may already have landed. A 429 is different — the request was refused before it was processed, so replaying it is safe with or without a key.
Most 4xx answers need a fix to the request rather than a retry, but not all of them: a few 409 conflicts say Retry shortly in the message, because they mean an identical request is still in flight. Read the message before deciding.
Best Practices
- Retry 429 and 5xx — a rate limit clears on its own; most other client errors need a fix
- Honour Retry-After — when a
429carries it, wait that many seconds; a rate limit raised by the provider rather than by us arrives without it, so keep your own backoff as the fallback - Exponential backoff — what the SDK already does between attempts: double the wait each time, plus jitter so parallel clients do not retry in lockstep
- Cap the attempts — the SDK stops after 3 retries by default
- Tell support the route, the status and roughly when — the route template rather than the URL you called, since a directory path carries the identifier in it, and error messages sometimes quote one too. Authentication failures are deliberately not logged, so for a
401say what you were trying to do instead
idempotencyKey in the request options to make retries safe: an identical retry replays the original response, so the same invoice won't be sent twice. Reusing a key for a different request is rejected with 422 idempotency_key_reuse — use a new key for each distinct request.400 idempotency_key_blank — behind authentication, rate limiting and the production gates, so a request failing one of those gets that failure instead. A transport strips the whitespace at the edges of a header value, so a blank key reaches us empty, and a key that cannot protect anything is worse than no key at all — you would believe you were covered. This matters most when you derive keys from your own data, where an empty field or a padded reference can produce one without you noticing. If you do not need idempotency for a request, omit the header rather than sending it empty. Padding around a real key stays harmless: a key written as " inv-42 " is sent as inv-42, which is what the wire carried anyway.import { Peppol, PeppolApiError } from "@getpeppr/sdk";
// The SDK already retries 429, 500, 502, 503 and 504 with exponential
// backoff. Tune it here rather than wrapping it in a loop of your own —
// a loop around a client that retries multiplies the calls, it does not
// make the send more likely to land.
const peppol = new Peppol({
apiKey: "sk_live_...",
retry: { maxRetries: 3 }, // the default
});
async function send(invoice: any) {
// A key derived from the document, not a random one: after a crash and a
// restart, this retry still matches the first attempt and replays its
// response instead of sending a second invoice.
const idempotencyKey = `send-${invoice.number}`;
try {
// Without a key the SDK refuses to replay a POST — it cannot tell
// "never sent" from "sent, answer lost". With one, it retries safely.
return await peppol.invoices.send(invoice, { idempotencyKey });
} catch (error) {
if (!(error instanceof PeppolApiError)) throw error;
// Everything retryable has already been retried by here. What is left
// needs a decision, not another attempt.
console.error(`send failed: ${error.statusCode}`, error.responseBody);
throw error;
}
}