Back to blogTechnical Guides

Building an E-Invoice Validation Pipeline: Architecture Guide

How to architect a production e-invoice validation pipeline — from single-invoice checks to batch processing with auto-remediation. Patterns, code, and deployment strategies.

Invoice Navigator TeamMay 19, 202610 min read

You're building an ERP integration, a Peppol access point, or a compliance layer for a finance platform. At some point, every invoice that leaves your system needs to be valid EN 16931 before it hits a receiver. Invalid invoices get rejected. Rejections cost time, money, and customer trust.

This guide covers three pipeline architectures for e-invoice validation, with code you can ship. We'll start with the simplest pattern and work up to high-volume batch processing with auto-remediation.

The Three Pipeline Patterns

Not every pipeline needs the same approach. Your choice depends on volume, tolerance for manual intervention, and how much you trust your invoice generation layer.

PatternFlowBest For
Validate-onlyCheck compliance → report errors → humans fixMonitoring, audit logging
Validate-then-fixValidate → auto-remediate fixable errors → flag the restProduction pipelines (recommended)
Fix-firstApply all safe fixes upfront → validate the resultHigh-volume batch processing

Validate-then-fix is the default for most teams. It catches errors before they reach a Peppol Access Point, fixes structural issues automatically, and only escalates what genuinely requires human input. The other two patterns have their place — validate-only for read-only monitoring, fix-first for bulk migrations — but validate-then-fix is where most production systems land.

Pattern 1: Single Invoice Validation

The simplest integration. Send one invoice, get back a structured validation result.

curl

curl -X POST https://api.invoicenavigator.eu/v1/validate \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Accept: application/json" \
  -F "file=@invoice.xml"

TypeScript

async function validateInvoice(invoiceXml: Buffer): Promise<ValidationResult> {
  const form = new FormData();
  form.append('file', new Blob([invoiceXml]), 'invoice.xml');

  const response = await fetch('https://api.invoicenavigator.eu/v1/validate', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.INVOICE_NAVIGATOR_API_KEY}`,
    },
    body: form,
  });

  if (!response.ok) {
    throw new Error(`Validation request failed: ${response.status}`);
  }

  return response.json();
}

Response Structure

The API returns a structured result with every fired rule, its severity, and its location in the XML:

{
  "valid": false,
  "format": "ubl-peppol-bis3",
  "errors": [
    {
      "error_code": "BR-16",
      "severity": "fatal",
      "message": "Amount due for payment (BT-115) = Invoice total amount with VAT (BT-112) - Paid amount (BT-113) + Rounding amount (BT-114)",
      "location": "/Invoice/LegalMonetaryTotal/PayableAmount",
      "category": "business",
      "fix_type": "blocked"
    },
    {
      "error_code": "BR-CL-10",
      "severity": "warning",
      "message": "Mime code must be according to subset of IANA code list",
      "location": "/Invoice/AdditionalDocumentReference/Attachment/EmbeddedDocumentBinaryObject/@mimeCode",
      "category": "codelist",
      "fix_type": "auto"
    }
  ],
  "warnings": 1,
  "fatal_errors": 1
}

Each error includes a fix_type field that tells you what can be done about it. We'll cover this in detail in the error handling section.

For the full API reference, see the API documentation. For a step-by-step first integration, start with the quickstart guide.

Pattern 2: Validate + Auto-Fix

This is the validate-then-fix pattern. A single API call validates the invoice, applies safe fixes to structural and codelist errors, and returns the corrected invoice alongside a full audit trail.

curl

curl -X POST https://api.invoicenavigator.eu/v1/fix-and-validate \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Accept: application/json" \
  -F "file=@invoice.xml"

TypeScript

import { InvoiceNavigator } from '@invoice-navigator/sdk';

const client = new InvoiceNavigator({
  apiKey: process.env.INVOICE_NAVIGATOR_API_KEY,
});

async function fixAndValidate(invoiceXml: Buffer) {
  const result = await client.process({
    invoice: invoiceXml,
    remediate: true,
    format: 'auto',
  });

  if (result.status === 'compliant') {
    // Invoice is valid (possibly after fixes) — send it
    await sendToAccessPoint(result.invoice);
    await storeEvidencePack(result.evidencePack);
    return;
  }

  if (result.status === 'input_required') {
    // Missing data that only a human can provide
    await queueForInput(result.inputFields);
    return;
  }

  if (result.status === 'blocked') {
    // Financial field issues — cannot be auto-fixed
    await routeForReview(result.blockedErrors);
  }
}

What the Response Looks Like

The fix-and-validate response is richer than validate-only. It contains the original errors, which fixes were applied, what errors remain, and an Evidence Pack documenting every action:

{
  "status": "compliant",
  "original_errors": 4,
  "applied_fixes": [
    {
      "error_code": "BR-CL-10",
      "field": "EmbeddedDocumentBinaryObject/@mimeCode",
      "original_value": "application/xml",
      "fixed_value": "text/xml",
      "fix_type": "auto"
    },
    {
      "error_code": "BR-CL-25",
      "field": "EndpointID/@schemeID",
      "original_value": "9925",
      "fixed_value": "0208",
      "fix_type": "auto"
    }
  ],
  "remaining_errors": 0,
  "invoice": "<base64-encoded corrected invoice>",
  "evidence_pack_url": "https://api.invoicenavigator.eu/v1/evidence/ep_abc123"
}

The Evidence Pack is the compliance proof. It records the original invoice, every fix applied, and the post-fix validation result. When a tax authority asks about an invoice two years from now, the Evidence Pack is your answer. See how Evidence Packs work.

For the full remediation logic — what gets fixed and what doesn't — read E-Invoice Remediation.

Pattern 3: Batch Processing

When you're processing hundreds or thousands of invoices in a nightly run, sequential API calls are wasteful. The batch endpoint accepts multiple invoices in a single request.

When to Use Batch vs. Sequential

FactorSequentialBatch
Latency requirementsReal-time (invoice-by-invoice)Can wait minutes to hours
Volume< 50 invoices/hour50+ invoices/run
Error handlingPer-invoice, immediateAggregated, post-run
Use casePre-send gate, user-triggeredNightly runs, migrations, bulk imports

TypeScript

async function processBatch(invoices: Buffer[]) {
  const results = await client.processBatch({
    invoices: invoices.map((xml) => ({
      invoice: xml,
      remediate: true,
      format: 'auto',
    })),
  });

  const compliant = results.filter((r) => r.status === 'compliant');
  const needsInput = results.filter((r) => r.status === 'input_required');
  const blocked = results.filter((r) => r.status === 'blocked');

  // Send compliant invoices downstream
  await Promise.all(compliant.map((r) => sendToAccessPoint(r.invoice)));

  // Queue the rest for human review
  if (needsInput.length > 0) await queueForInput(needsInput);
  if (blocked.length > 0) await routeForReview(blocked);

  console.log(
    `Batch complete: ${compliant.length} sent, ` +
    `${needsInput.length} need input, ${blocked.length} blocked`
  );
}

Rate Limiting

The API enforces per-second and per-minute rate limits based on your plan. For batch processing:

  • Use the batch endpoint rather than firing parallel single requests. The batch endpoint is optimized for throughput and counts as fewer API calls.
  • Implement exponential backoff on 429 responses. The Retry-After header tells you exactly how long to wait.
  • Chunk large batches. If you have 10,000 invoices, send them in batches of 100-500 rather than one massive request.

See the API documentation for current rate limits per plan.

Error Handling in Production

Every validation error returns a fix_type that determines how your pipeline should handle it. This is the most important field for automation.

The Three Fix Types

auto — Applied automatically. These are structural and codelist errors that can be fixed without changing the financial meaning of the invoice. Wrong MIME codes, deprecated scheme identifiers, date format issues. Log the fix, store the Evidence Pack, and continue.

// auto: log and move on
if (error.fix_type === 'auto') {
  logger.info(`Auto-fixed ${error.error_code}: ${error.field}`);
  // The corrected invoice is already in result.invoice
}

input — Requires human input. The error can be fixed, but the fix requires data your system doesn't have. A missing buyer reference (BT-10), an unknown endpoint ID, a tax category that needs manual classification. Queue these for a human operator and provide the specific fields needed.

// input: queue for human with specific field requests
if (error.fix_type === 'input') {
  await createTask({
    invoiceId: invoice.id,
    field: error.field,
    message: error.message,
    currentValue: error.original_value,
    suggestedAction: error.suggestion,
  });
}

blocked — Cannot be auto-fixed. Financial calculation errors, VAT amount mismatches, total inconsistencies. These errors touch monetary values that must match the source transaction. Fixing them automatically would mean changing the financial content of the invoice — which is never safe. Alert the source system to regenerate the invoice with correct data.

// blocked: alert source system
if (error.fix_type === 'blocked') {
  await alertSourceSystem({
    invoiceId: invoice.id,
    errorCode: error.error_code,
    severity: 'critical',
    message: `Invoice must be regenerated: ${error.message}`,
  });
}

For a full catalog of errors by category, see business rule errors and format errors.

Deployment Considerations

Where you place the validation gate matters. Three deployment positions, each solving a different problem.

Pre-Send Validation

The most common position. Validate every invoice after your ERP generates it and before it reaches the Peppol Access Point or government portal.

ERP Export → [Validation Gate] → Access Point → Peppol Network
                  ↓ (if invalid)
             Error Queue / Dashboard

This catches errors before they cause rejections. Your Access Point only receives invoices that have already passed all four validation layers (schema, EN 16931 business rules, format rules, country CIUS). Rejection rate drops to near zero.

Post-Receive Validation

Validate incoming invoices from trading partners. This is useful if you're an Access Point operator or a large buyer receiving invoices from many suppliers.

Peppol Network → Access Point → [Validation Gate] → ERP Import
                                       ↓ (if invalid)
                                  Supplier Notification

Post-receive validation protects your ERP from ingesting malformed data. It also gives you leverage: you can report specific compliance issues back to suppliers with error codes they can act on.

CI/CD Integration

Run validation against test invoices as part of your build pipeline. This catches regressions when your invoice template changes.

# .github/workflows/invoice-tests.yml
- name: Validate test invoices
  run: |
    for f in test/fixtures/*.xml; do
      response=$(curl -s -w "%{http_code}" \
        -X POST https://api.invoicenavigator.eu/v1/validate \
        -H "Authorization: Bearer ${{ secrets.IN_API_KEY_DEV }}" \
        -F "file=@$f")
      status=$(echo "$response" | tail -1)
      if [ "$status" != "200" ]; then
        echo "FAIL: $f"
        exit 1
      fi
    done

Use a sandbox API key (inv_dev_*) for CI/CD. Sandbox requests don't count against your production quota and return realistic validation results.

Start Building

Read the API docs, grab a free API key, and validate your first invoice in under 60 seconds.

Read the API Docs

FAQ

How fast is the validation API?

Single-invoice validation typically returns in 200-400ms. Fix-and-validate takes 300-600ms because it applies corrections and revalidates. Batch processing throughput depends on batch size, but the API is optimized for sustained loads — expect 50-100 invoices per second on production plans.

What happens if the API is down?

Design your pipeline to queue invoices for retry rather than sending them unvalidated. A delayed compliant invoice is always better than an immediate rejected one. The API has 99.9% uptime SLA on production plans, and the Retry-After header on 503 responses tells your client exactly when to retry.

if (error.status >= 500) {
  const retryAfter = error.headers.get('Retry-After') || 30;
  await queueForRetry(invoiceXml, { delaySeconds: Number(retryAfter) });
}

Can I run validation locally?

The API is cloud-based. For offline or air-gapped environments, you can build your own validation layer using the open-source KoSIT validator for German invoices or the EU Commission ITB validator for EN 16931 base rules. The trade-off is maintaining the validation artifacts yourself — rule updates ship 2-3 times per year across EN 16931, Peppol, and country CIUS layers. See our comparison in How to Validate E-Invoices Programmatically.

How do I handle blocked errors in an automated pipeline?

Route them back to the source system. Blocked errors are financial calculation issues — VAT mismatches, total inconsistencies, amount errors — that cannot be safely auto-fixed. Your pipeline should flag the original transaction, notify the responsible team, and request a corrected invoice from the ERP. Never suppress or work around blocked errors.

Which invoice formats does the API support?

UBL 2.1, CII (Cross Industry Invoice), Peppol BIS 3.0, XRechnung, ZUGFeRD (all profiles from Minimum to Extended), Factur-X, and 20+ country-specific CIUS variants. Format detection is automatic — send the XML and the API identifies the applicable validation rules. For a detailed format comparison, see ZUGFeRD vs. XRechnung.

Check Your Compliance Status

Find out exactly what your business needs to do for e-invoicing compliance.

Use Obligation Finder