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.
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.
409Conflict — the request is valid but the resource is not in the right state.
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.
400 Validation Error
{
  "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"
}
404 Not Found
{
  "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.

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. 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_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.
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.
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).",
  "requestId": "req_ghi789rst"
}
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.
verification_failedRegistry verification failed. Inspect the legal entity status or the legal_entity.verification_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.
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/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
The SDK handles idempotency automatically. It's safe to retry send() — the same invoice won't be sent twice.
retry.ts
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;
      }
    }
  }
}