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.
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.
| Pattern | Flow | Best For |
|---|---|---|
| Validate-only | Check compliance → report errors → humans fix | Monitoring, audit logging |
| Validate-then-fix | Validate → auto-remediate fixable errors → flag the rest | Production pipelines (recommended) |
| Fix-first | Apply all safe fixes upfront → validate the result | High-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
| Factor | Sequential | Batch |
|---|---|---|
| Latency requirements | Real-time (invoice-by-invoice) | Can wait minutes to hours |
| Volume | < 50 invoices/hour | 50+ invoices/run |
| Error handling | Per-invoice, immediate | Aggregated, post-run |
| Use case | Pre-send gate, user-triggered | Nightly 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-Afterheader 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}`,
});
}
Never auto-fix blocked errors. They involve financial calculations (amounts, VAT, totals) where automated changes would create accounting discrepancies. Always route these back to the source system for regeneration.
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 DocsFAQ
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