How to add e-invoice compliance to your pipeline.

Between the ERP export and the delivery channel, one call decides whether the file goes out, goes back, or waits for a person. This guide wires that call in, with the code, in five steps.

Where it sits
StageSystemCarries
SourceYour ERPUBL or CII XML, exported per invoice or per batch.
CheckInvoice Navigator APIValidate → patch structure → re-validate → sign. One POST, one protocol.
DeliveryPeppol access point, portal, archiveThe file that passed, or the reason it did not.

Your pipeline logic stays yours; the API only answers about the file it was given.

Steps
  1. 01

    Get a key

    For development, an instant test key needs no account (10 requests, one hour). For production, sign up: a sk_live_ and a sk_test_ key are created together.

    curl -X POST https://www.invoicenavigator.eu/api/developers/instant-key
  2. 02

    Validate

    POST /v1/validate runs EN 16931, Peppol BIS and the country rules that apply to the file and reports without changing it. Use it where you only need a verdict.

    JavaScript
    const res = await fetch('https://www.invoicenavigator.eu/api/v1/validate', {
      method: 'POST',
      headers: { Authorization: 'Bearer sk_test_…', 'Content-Type': 'application/json' },
      body: JSON.stringify({ xml: invoiceXml, fileName: 'invoice-001.xml' }),
    })
    const { data } = await res.json()
    console.log(data.isValid, data.errors.length, data.warnings.length)
    for (const e of data.errors) console.log(e.code, e.location, e.message)
    Python
    r = requests.post(
        "https://www.invoicenavigator.eu/api/v1/validate",
        headers={"Authorization": "Bearer sk_test_…"},
        json={"xml": invoice_xml, "fileName": "invoice-001.xml"},
    )
    data = r.json()["data"]
    print(data["isValid"], len(data["errors"]))
    for e in data["errors"]:
        print(e["code"], e.get("location"), e["message"])
  3. 03

    Validate, patch, re-validate

    POST /v2/validate-and-fix does the whole loop. The response sorts every finding into one of three outcomes, and your code branches on exactly those:

    • fixesApplied — structural edits were made and the patched file passed re-validation. Deliver data.fixedXml.
    • fixSummary.needsInput — business data is missing. Collect it and POST to data._links.fixWithInput; the engine never guesses.
    • remainingIssues after patching — the source system must re-export. Amounts, VAT totals and payment details are never altered.
    JavaScript
    const res = await fetch('https://www.invoicenavigator.eu/api/v2/validate-and-fix', {
      method: 'POST',
      headers: { Authorization: 'Bearer sk_live_…', 'Content-Type': 'application/json' },
      body: JSON.stringify({ xml: invoiceXml, fileName: 'invoice-001.xml', autoFix: true }),
    })
    const { data } = await res.json()
    
    if (data.originalValid) deliver(invoiceXml)
    else if (data.fixedValid) deliver(data.fixedXml)          // patched, re-validated
    else if (data.fixSummary?.needsInput) askOperations(data)  // POST values to data._links.fixWithInput
    else reexport(data.remainingIssues)                        // structural failure at the source
    Python
    r = requests.post(
        "https://www.invoicenavigator.eu/api/v2/validate-and-fix",
        headers={"Authorization": "Bearer sk_live_…"},
        json={"xml": invoice_xml, "fileName": "invoice-001.xml", "autoFix": True},
    )
    data = r.json()["data"]
    
    if data["originalValid"]:
        deliver(invoice_xml)
    elif data.get("fixedValid"):
        deliver(data["fixedXml"])
    elif (data.get("fixSummary") or {}).get("needsInput"):
        ask_operations(data)   # POST values to data["_links"]["fixWithInput"]
    else:
        reexport(data["remainingIssues"])
  4. 04

    Keep the evidence

    Store validationRef with the invoice record. On Team, POST /v1/evidence-pack turns it into a signed pack (PDF or JSON) that anyone can verify later at GET /v1/verify/{'{id}'} or on /verify, without a key. The composite call can inline the pack with generate_evidence_pack: true.

    JavaScript
    // Team keys. The pack is bound to a validationRef from /v1/validate or /v2/validate-and-fix.
    const pack = await fetch('https://www.invoicenavigator.eu/api/v1/evidence-pack', {
      method: 'POST',
      headers: { Authorization: 'Bearer sk_live_…', 'Content-Type': 'application/json' },
      body: JSON.stringify({ validationRef: data.validationRef, format: 'json' }),
    }).then((r) => r.json())
    
    // Anyone can check it later, without a key:
    const check = await fetch(`https://www.invoicenavigator.eu/api/v1/verify/${pack.data.evidencePackId}`).then((r) => r.json())
    console.log(check.verified)
    Python
    pack = requests.post(
        "https://www.invoicenavigator.eu/api/v1/evidence-pack",
        headers={"Authorization": "Bearer sk_live_…"},
        json={"validationRef": data["validationRef"], "format": "json"},
    ).json()
    
    check = requests.get(f"https://www.invoicenavigator.eu/api/v1/verify/{pack['data']['evidencePackId']}").json()
    print(check["verified"])
  5. 05

    Go live

    • Swap sk_test_ for sk_live_ in the environment; nothing else changes.
    • Handle 429 with Retry-After and 5xx with backoff; never retry other 4xx.
    • Route needsInput to a person or a form, and remainingIssues back to the source system.
    • Log X-Request-Id on every call; watch the month counter on GET /v1/usage.
    • Test with your own invoices for your main trading partners before the switch, and confirm your access point accepts the patched output.
Questions
Can I test without an account or without touching my quota?
Yes. POST https://www.invoicenavigator.eu/api/developers/instant-key returns a sk_test_ key without an account (10 requests, one hour). Every account also gets a sk_test_ key next to its live key; test keys run the same engine, are not metered, and are limited to 100 requests an hour.
Which invoice formats does the API accept?
UBL 2.1 and UN/CEFACT CII XML, including the national profiles built on them such as XRechnung and Peppol BIS Billing 3.0. Format detection is automatic; send the XML as a JSON string.
What does the engine change, and what does it never change?
It patches structure: syntax, code lists, ordering, identifiers, missing containers. It never changes amounts, VAT totals or payment details; when business data is missing it reports needsInput and waits for your values.
What happens if the API is unavailable?
Calls fail with a 5xx and an X-Request-Id. Retry with exponential backoff, keep the invoice in your own queue until it validates, and store the validationRef and evidence pack id with your invoice record.
Do I need separate keys per environment?
Use the sk_test_ key in development and CI and the sk_live_ key in production. Only live calls are metered and stored as flows.
Prices

Free: 100 requests a month, no card. Team: €49 a month for 5,000 requests, batch, evidence packs and conversion. Details on /developers.