Skip to content

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 on validation, not on the error: read error.validation.errors for the field paths and messages.
  • PeppolProtocolError— The gateway answered 2xx with a body missing a field the contract makes mandatory. Carries field and responseBody (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 a 2xx whose body is not JSON — so read statusCode rather than assuming it is an error code. Always carries statusCode and responseBody. Two more are best-effort and can be undefined: retryAfterMs, which needs a 429 carrying a readable Retry-After, and the code getter, which reads the body's code field — some routes put their machine code in error instead. And responseBody is 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:

HTTP status codes returned by the API
StatusMeaning
200Success — request completed.
201Created — invoice sent successfully.
202Accepted — the request was accepted; remaining steps (verification, webhook notification) continue after the response.
204No Content — success with an empty response body (e.g. a delete, or an accepted send).
302Found — redirect, used by the newsletter confirm and unsubscribe links.
400Bad Request — invalid parameters or validation error.
401Unauthorized — missing or invalid API key.
403Forbidden — your API key is valid but lacks the required scope.
404Not Found — resource doesn't exist.
405Method Not Allowed — the resource exists but this verb is not supported; e.g. transports are managed by the provider (transports.managed_by_provider).
409Conflict — the request is valid but the resource is not in the right state.
413Payload Too Large — the declared Content-Length exceeds the route's cap: 1 MB, or 4 MB where a document travels in the body.
422Unprocessable Entity — business-rule gate failed, such as identity verification or send readiness.
429Too Many Requests — rate limit exceeded (see Rate Limits).
500Server Error — retry with exponential backoff.
502Bad Gateway — an upstream getpeppr depends on failed. Usually retryable; some need support.
501Not Implemented — the operation is intentionally unavailable on the provider; e.g. draft invoices do not exist (there is no draft state to send).
503Service 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.
422 Validation Error
{
  "error": "invalid_base_quantity",
  "message": "baseQuantity must be a finite number greater than zero.",
  "field": "lines[0].baseQuantity",
  "rule": "PEPPOL-EN16931-R121"
}
404 Not Found
{
  "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.

Pre-send compliance error codes
CodeMeaning / next step
invalid_country_codeA 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_violationA 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_incompleteYour 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_meansThe 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_requiredDirect 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_rateStorecove 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.
422 Invalid country code
{
  "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)."
}
422 Country rule violation
{
  "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

Platform-specific HTTP status codes
StatusMeaning
403Authenticated key is not a master key, or lacks the required legal_entities:* scope.
404Sub-tenant is unknown, disabled, malformed, or belongs to another platform. getpeppr returns 404 instead of 403 to prevent resource enumeration.
409Operation conflicts with the sub-tenant lifecycle, for example requesting attestation too early or after the customer already attested.
422Sending is blocked by a business gate. Check the code field to decide what your UI should show.
502Attestation 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:

Send-as gate error codes
CodeMeaning / next step
verification_pendingRegistry verification has not completed yet. Poll the legal entity or wait for a lifecycle webhook.
unsupported_schemeAutomatic verification is unavailable for this identifier scheme. Do not keep polling; contact support before sending.
verification_failedRegistry verification failed. Inspect the legal entity status or the legal_entity.verification_failed webhook.
registration_failedNetwork 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_requiredProduction customer authorisation is required before sending. Request attestation and wait for the customer to authorise.
peppol_identity_expiredThe authorisation window expired. Request a new attestation.
provisioningSub-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.

Treat 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.
400 Invalid sender
{
  "error": "sender requires exactly one of legalEntityId or externalSubTenantId"
}
422 Send-as gate
{
  "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 429 carries 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 401 say what you were trying to do instead
Pass an 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.
The key has to be one we can actually look up, and this is checked twice. A blank key — an empty string, or one made only of spaces — never leaves the SDK: it is refused as a validation error before any request is sent. Sent over plain HTTP, it is refused by the gateway with 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.
retry.ts
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;
  }
}