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. The first three errors below extend PeppolError, so catching that class matches them and the base class itself. Runtime failures sit outside that hierarchy. The SDK throws the base class directly for invalid config and from waitFor(), the directory and the webhook helpers.
PeppolValidationError- Local validation failed, so the invoice was never sent. Read
error.validation.errorsfor field paths and messages. PeppolProtocolError- The gateway returned a
2xxbody without a required field. The error carriesfieldand a serialisedresponseBody, truncated after 2,000 characters. Report it instead of retrying in a loop. PeppolApiError- The gateway returned a response the SDK could not accept: usually a 4xx or 5xx, or a
2xxbody that is not JSON. It always carriesstatusCode. ItsresponseBodyis the raw response body for an HTTP error, or an SDK note when a successful body could not be parsed as JSON.retryAfterMsis undefined unless the status is429and the response carries a readableRetry-After.codereads the response body'scodefield and can be undefined when a route puts its machine code inerrorinstead. - Runtime failures
- Failed connections reject with
TypeError; a timeout or aborted request rejects with anErrornamedAbortError. The SDK retries them when its policy allows, then rethrows them unchanged. Keep a finalelsebranch 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); // raw error body, or an SDK note for invalid 2xx
} 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 Error named 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. |
410 | Gone — the resource existed and is yours, but its content is no longer retained: the original UBL of a received document, removed 90 days after receipt, or earlier when a Platform contract's exit window closes. The document's details stay readable. Not retryable. |
413 | Payload Too Large — the request is rejected when its declared Content-Length exceeds the route's cap, or when its actual bytes cross it: 1 MB, or 4 MB where a document travels in the body. Chunked requests and requests with a missing or under-declared Content-Length are still measured. Above roughly 4.5 MB this 413 is not ours: the hosting platform refuses the body before our code runs and answers plain text with none of the Getpeppr-* headers — no getpeppr request id and no result code, only the platform's own x-vercel-id — see Responses getpeppr did not write. |
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 — with paymentIban. |
payment_account_required | A credit transfer (30, or 58 for SEPA) needs the account to pay into, and the network rejects such invoices without it (rule BR-61). Provide a non-empty paymentIban. A blank value counts as missing. |
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. |
403 | platform_exit_read_only — your platform contract has ended. For 30 days after termination, or 60 days after a termination for abandonment (the exit window, until exitEndsAt in the body), your production master key can still read and export documents and legal entities, and archive a legal entity; sending, importing, creating or attesting a legal entity, and marking an invoice are refused. After the window the key stops working (401). |
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. In the sandbox, test sends with a separate 9915 sub-tenant (not for Dutch, Danish, Swedish, Norwegian, Greek or Icelandic suppliers, see choosing a test identity); for a real company, 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 one of three codes for an identity that is verified but not registered on the network yet: smp_not_registered — publication is in progress, or will be retried automatically; address_held_elsewhere — another Peppol provider still holds the address, so ask it to remove it: getpeppr registers it automatically once it is released (see Switching provider); smp_registration_failed — the network refused the registration and retrying will not change that, so contact support.
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;
}
}