Documentation

E-invoice API

One POST call validates a UBL, CII, XRechnung or Factur-X invoice against the official EN 16931 rules and returns every failure in plain language. Another builds a Factur-X invoice from plain JSON and validates it before handing it back. This page is the whole reference.

Quickstart

Create a free API key (100 validations plus 20 generated invoices a month, no card), then validate your first invoice from the terminal:

Get your free API key

curl https://api.einvoicekit.com/v1/validate \
  -H "Authorization: Bearer eik_live_..." \
  -H "Content-Type: application/xml" \
  --data-binary @invoice.xml

Authentication

Your key travels in the Authorization header. Only POST /v1/validate also answers without one, on the free pool described in its section; generation always needs a key:

Authorization: Bearer eik_live_...

Keys start with eik_live_ and are shown once, at creation, on your dashboard. Treat them like passwords: environment variables, never a repository.

Packages

The same validation as a package, with no HTTP code to write: one function and one command, on npm and on PyPI. Without a key they run on the free pool described under POST /v1/validate; with EINVOICEKIT_API_KEY set, on your account.

Node.js 20+, from npm

npx @einvoicekit/einvoicekit invoice.pdf

npm install @einvoicekit/einvoicekit

Python 3.10+, from PyPI

pipx run einvoicekit invoice.pdf

pip install einvoicekit

The command prints every broken rule and exits 0 when every file is valid, 2 when any file could not be validated at all, and 1 when the rest holds at least one invalid invoice, so it drops into a CI pipeline as it is. The validate function returns the same verdict from code: in JavaScript this API's JSON as it arrives, in Python a typed result that keeps that JSON on .raw. Both packages are thin clients: the file is sent over HTTPS, processed in memory and dropped, never stored. No dependencies, MIT licence, source and full READMEs on GitHub.

POST /v1/validate

Validates one invoice document and returns the verdict with every broken rule. Both syntaxes (UBL and CII) and both profile families (XRechnung, Factur-X/ZUGFeRD) are detected automatically.

Request

Send the XML as the raw request body with Content-Type: application/xml, or as multipart/form-data with a single file field. A Factur-X / ZUGFeRD PDF is accepted as-is: the embedded XML is extracted before validation. Maximum size: 5 MB.

Response

Always JSON. valid is the verdict; errors lists every failed rule with its EN 16931 rule id, the official rule message (which names the business terms involved, BT-x), and the XPath of the offending element. warnings has the same shape and does not affect the verdict.

{
  "valid": false,
  "syntax": "cii",
  "profile": "urn:cen.eu:en16931:2017",
  "errors": [
    {
      "rule": "BR-CO-15",
      "message": "[BR-CO-15]-Invoice total amount with VAT (BT-112) = Invoice total amount without VAT (BT-109) + Invoice total VAT amount (BT-110).",
      "path": "/Q{urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100}CrossIndustryInvoice[1]"
    }
  ],
  "warnings": []
}

Without a key

Send the same request with no Authorization header and it runs on the anonymous pool the free tool pages use: 10 validations a day per IP address, shared between the validator page, the generator page and keyless API calls, reset at midnight UTC. Only a delivered verdict counts; a 400, a 422 or an error on our side gives the run back. The call after the last one is a 429 pool_exhausted naming the reset time and the free key, which gives 100 validations a month. A key that is present but wrong is a 401, never a fallback into the pool.

curl -s -X POST \
  --data-binary @invoice.xml \
  https://api.einvoicekit.com/v1/validate
{
  "error": "pool_exhausted",
  "message": "10 free validations a day per IP address without a key. A free key gives 100 a month: https://einvoicekit.com/get-started?from=api-pool",
  "resetsAt": "2026-09-04T00:00:00.000Z",
  "upgrade": "https://einvoicekit.com/get-started?from=api-pool"
}

Call it from your language

curl

curl https://api.einvoicekit.com/v1/validate \
  -H "Authorization: Bearer eik_live_..." \
  -H "Content-Type: application/xml" \
  --data-binary @invoice.xml

Python

# pip install requests
import requests

with open("invoice.xml", "rb") as f:
    response = requests.post(
        "https://api.einvoicekit.com/v1/validate",
        headers={
            "Authorization": "Bearer eik_live_...",
            "Content-Type": "application/xml",
        },
        data=f.read(),
    )

result = response.json()
print(result["valid"], result["errors"])

Node.js

// Node.js 18+ (built-in fetch), run as an ES module
import { readFile } from "node:fs/promises";

const response = await fetch("https://api.einvoicekit.com/v1/validate", {
  method: "POST",
  headers: {
    Authorization: "Bearer eik_live_...",
    "Content-Type": "application/xml",
  },
  body: await readFile("invoice.xml"),
});

const result = await response.json();
console.log(result.valid, result.errors);

PHP

<?php
$ch = curl_init("https://api.einvoicekit.com/v1/validate");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer eik_live_...",
        "Content-Type: application/xml",
    ],
    CURLOPT_POSTFIELDS => file_get_contents("invoice.xml"),
]);

$result = json_decode(curl_exec($ch), true);
echo $result["valid"] ? "valid" : "invalid", PHP_EOL;

C#

// .NET 6+ (top-level statements)
using System.Net.Http.Headers;
using System.Text.Json;

using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", "eik_live_...");

var content = new ByteArrayContent(File.ReadAllBytes("invoice.xml"));
content.Headers.ContentType = new MediaTypeHeaderValue("application/xml");

var response = await http.PostAsync("https://api.einvoicekit.com/v1/validate", content);
using var result = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Console.WriteLine(result.RootElement.GetProperty("valid"));

Java

// Java 11+
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Path;

public class ValidateInvoice {
    public static void main(String[] args) throws Exception {
        var client = HttpClient.newHttpClient();
        var request = HttpRequest.newBuilder()
            .uri(URI.create("https://api.einvoicekit.com/v1/validate"))
            .header("Authorization", "Bearer eik_live_...")
            .header("Content-Type", "application/xml")
            .POST(HttpRequest.BodyPublishers.ofFile(Path.of("invoice.xml")))
            .build();

        var response = client.send(request, HttpResponse.BodyHandlers.ofString());
        System.out.println(response.body());
    }
}

Go

package main

import (
	"bytes"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
)

func main() {
	xml, err := os.ReadFile("invoice.xml")
	if err != nil {
		log.Fatal(err)
	}
	req, err := http.NewRequest(http.MethodPost, "https://api.einvoicekit.com/v1/validate", bytes.NewReader(xml))
	if err != nil {
		log.Fatal(err)
	}
	req.Header.Set("Authorization", "Bearer eik_live_...")
	req.Header.Set("Content-Type", "application/xml")

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(body))
}

Rust

// cargo add reqwest --features blocking,json
// cargo add serde_json
use std::fs;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let xml = fs::read("invoice.xml")?;
    let result: serde_json::Value = reqwest::blocking::Client::new()
        .post("https://api.einvoicekit.com/v1/validate")
        .header("Authorization", "Bearer eik_live_...")
        .header("Content-Type", "application/xml")
        .body(xml)
        .send()?
        .json()?;

    println!("{} {}", result["valid"], result["errors"]);
    Ok(())
}

Swift

// Swift 5.7+, macOS 12+ — run as main.swift or 'swift validate.swift'
// (on Linux, add: import FoundationNetworking)
import Foundation

var request = URLRequest(url: URL(string: "https://api.einvoicekit.com/v1/validate")!)
request.httpMethod = "POST"
request.setValue("Bearer eik_live_...", forHTTPHeaderField: "Authorization")
request.setValue("application/xml", forHTTPHeaderField: "Content-Type")
request.httpBody = try Data(contentsOf: URL(fileURLWithPath: "invoice.xml"))

let (data, _) = try await URLSession.shared.data(for: request)
let result = try JSONSerialization.jsonObject(with: data) as! [String: Any]
print(result["valid"] ?? "?", result["errors"] ?? [])

French rules (BR-FR)

France adds its own business rules on top of EN 16931: the BR-FR rule set that accredited platforms apply under the 2026 e-invoicing mandate, published by FNFE-MPE. Opt in per request with the target parameter:

curl -s -X POST \
  -H "Authorization: Bearer eik_live_..." \
  --data-binary @invoice.xml \
  "https://api.einvoicekit.com/v1/validate?target=france"

The parameter is opt-in because French rules cannot be auto-detected: a French EN 16931 invoice is structurally identical to any other. A call with target=france costs the same one document as any validation.

Accepted documents: EN 16931 invoices in UBL (Invoice and CreditNote) or CII, and the Factur-X profiles BASIC WL, EN 16931 and EXTENDED. Any other profile returns 422 instead of silently skipping the French stage, so a green verdict always means the French rules actually ran.

The response echoes target and lists failed French rules alongside every other finding. Rule messages arrive in French, exactly as FNFE-MPE publishes them:

{
  "valid": false,
  "syntax": "cii",
  "profile": "urn:cen.eu:en16931:2017",
  "target": "france",
  "errors": [
    {
      "rule": "BR-FR-05_BT-22_PMT",
      "message": "BR-FR-05/BT-22 : La mention relative aux frais de recouvrement (code PMT) est absente. Elle est obligatoire dans les notes (BG-1).",
      "path": "/*:CrossIndustryInvoice[namespace-uri()='urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100'][1]/*:ExchangedDocument[namespace-uri()='urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100'][1]"
    }
  ],
  "warnings": []
}

POST /v1/generate

Builds a Factur-X invoice from plain JSON. We compute every total ourselves, run the finished document through the same official rule set /v1/validate uses, and refuse the call rather than hand you a file a receiving platform would reject.

This release generates Factur-X, in the en16931 profile or in extended, as CII XML or as a PDF with that XML inside it. So format takes facturx, output takes xml or pdf, profile takes en16931 (the default) or extended, and any other value is a 400 naming it. language accepts fr, en or de and picks the labels printed on the PDF; it never touches your own text, so party names, line descriptions, payment terms and notes print exactly as you sent them. logo is drawn on the PDF and ignored for xml.

Request

POST the invoice as JSON. The schema is closed: an unknown key is a 400 with a JSON pointer at it rather than a silently dropped field, and a field EN 16931 defines but this release does not accept is a 400 that names it rather than a pretence you never sent it.

curl https://api.einvoicekit.com/v1/generate \
  -H "Authorization: Bearer eik_live_..." \
  -H "Content-Type: application/json" \
  --data-binary @invoice.json
{
  "format": "facturx",
  "profile": "en16931",
  "output": "xml",
  "language": "fr",
  "invoice": {
    "number": "F-2026-0042",
    "issueDate": "2026-09-15",
    "typeCode": "380",
    "currency": "EUR",
    "dueDate": "2026-10-15",
    "paymentTerms": "Paiement a 30 jours.",
    "buyerReference": "04011000-1234512345-06",
    "purchaseOrderReference": "PO-889",
    "notes": [{ "text": "Penalites de retard: 3x le taux legal." }],
    "seller": {
      "name": "Atelier Dupont",
      "vatId": "FR12345678901",
      "legalRegistrationId": "12345678900012",
      "legalInformation": "SARL au capital de 10 000 EUR - RCS Paris B 123 456 789",
      "electronicAddress": { "value": "12345678900012", "scheme": "0009" },
      "address": {
        "line1": "3 rue des Lilas",
        "city": "Paris",
        "postCode": "75011",
        "country": "FR"
      },
      "contact": {
        "name": "Marie Dupont",
        "phone": "+33 1 23 45 67 89",
        "email": "compta@atelier-dupont.fr"
      }
    },
    "buyer": {
      "name": "Beispiel GmbH",
      "vatId": "DE123456789",
      "address": {
        "line1": "Hauptstrasse 12",
        "city": "Berlin",
        "postCode": "10115",
        "country": "DE"
      }
    },
    "delivery": { "date": "2026-09-10", "country": "FR" },
    "payment": {
      "meansCode": "58",
      "iban": "FR7630006000011234567890189",
      "bic": "AGRIFRPP",
      "remittanceInformation": "F-2026-0042"
    },
    "allowances": [
      {
        "amount": "100.00",
        "reason": "Remise commerciale",
        "vat": { "category": "S", "rate": "20" }
      }
    ],
    "charges": [
      {
        "percentage": "1.50",
        "baseAmount": "7800.00",
        "reason": "Frais de dossier",
        "vat": { "category": "S", "rate": "20" }
      }
    ],
    "lines": [
      {
        "id": "1",
        "name": "Prestation de developpement",
        "quantity": "12",
        "unit": "HUR",
        "unitPrice": "650.00",
        "vat": { "category": "S", "rate": "20" }
      }
    ]
  }
}

Amounts, quantities, rates and percentages are strings: "650.00", "12", "5.5". That is the canonical form, because a JSON number is an IEEE double and money is not. Bare numbers are still tolerated at the boundary, read through their shortest decimal form, so 650.00 arrives as 650; a value that then exceeds its field budget is a 400. Amounts carry at most 2 decimals, quantities and unit prices up to 6, rates and percentages up to 2.

You do not send totals and you cannot get them wrong: we derive every one of them, rounding half away from zero, once per line and once per VAT rate. Send a totals object anyway and we compare it with ours, then refuse with 422 totals_mismatch showing both figures. Your books, your number, our warning: we never silently overwrite it.

Response

JSON by default: the base64 CII document, the totals we computed, and the verdict of each pipeline stage. pdfa3 reads skipped because output xml renders no PDF, so there was nothing to check; ask for a PDF and it carries a real verdict instead. Findings the rule set raises as warnings rather than errors come back in a warnings array and do not change the verdict. A rounding key appears inside totals only when your roundingAmount is not zero.

{
  "format": "facturx",
  "profile": "en16931",
  "xml": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4...",
  "totals": {
    "lineNet": "7800.00",
    "allowances": "100.00",
    "charges": "117.00",
    "taxBasis": "7817.00",
    "vat": "1563.40",
    "grossTotal": "9380.40",
    "prepaid": "0.00",
    "amountDue": "9380.40"
  },
  "validation": {
    "valid": true,
    "stages": { "xsd": "pass", "schematron": "pass", "pdfa3": "skipped" }
  }
}

Getting the XML itself

Send Accept: application/xml and the answer is the CII document itself, no JSON envelope and no base64 to pipe through jq and base64 -d. Accept: application/pdf does the same for the PDF. The header and the body have to agree: asking for one representation while output names the other is a 400 output_conflict, because guessing which of the two you meant is how a file ends up in the wrong pipeline.

curl https://api.einvoicekit.com/v1/generate \
  -H "Authorization: Bearer eik_live_..." \
  -H "Content-Type: application/json" \
  -H "Accept: application/xml" \
  -H "Idempotency-Key: F-2026-0042" \
  --data-binary @invoice.json \
  -o invoice.xml

The PDF output

Ask for output pdf and you get a Factur-X hybrid: a PDF a human reads with the same invoice, as CII XML, attached inside it under the name factur-x.xml that the specification fixes. It is a PDF/A-3B, the archival format the standard requires, and every file is checked for conformance before it is billed: the pdfa3 stage in the response carries that verdict. If it ever failed, the call would be a 500 on us and cost you nothing.

The request body is the one you already have. Nothing but output changes, and the XML inside the PDF is byte for byte the XML the same request returns with output xml, so nothing forces you to choose one integration or the other.

curl https://api.einvoicekit.com/v1/generate \
  -H "Authorization: Bearer eik_live_..." \
  -H "Content-Type: application/json" \
  -H "Accept: application/pdf" \
  -H "Idempotency-Key: F-2026-0042" \
  --data-binary @invoice.json \
  -o invoice.pdf
{
  "format": "facturx",
  "profile": "en16931",
  "xml": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4...",
  "pdf": "JVBERi0xLjcKJYGBgYEKCjEgMCBvYmoKPDwKL1R5cGUgL1BhZ2VzCi...",
  "totals": {
    "lineNet": "7800.00",
    "allowances": "100.00",
    "charges": "117.00",
    "taxBasis": "7817.00",
    "vat": "1563.40",
    "grossTotal": "9380.40",
    "prepaid": "0.00",
    "amountDue": "9380.40"
  },
  "validation": {
    "valid": true,
    "stages": { "xsd": "pass", "schematron": "pass", "pdfa3": "pass" }
  }
}

Text the invoice cannot draw

A PDF has to draw every character with an embedded font, and a font does not cover all of Unicode. Rather than print a blank rectangle onto an invoice you are about to send, we refuse: a 422 whose issues carry the rule unrenderable_text, a pointer at the exact field, and the characters at fault. The workaround is in the message, because output xml has no font constraint at all and takes any Unicode you like. Only the PDF path is ever checked this way.

{
  "error": "invalid_invoice",
  "message": "this invoice does not satisfy the rules it has to satisfy; see issues.",
  "issues": [
    {
      "rule": "unrenderable_text",
      "field": "/invoice/lines/0/name",
      "message": "the invoice font cannot draw \"😀\". Use output \"xml\", which has no font constraint."
    }
  ]
}

Every field

Field names are plain English, and the EN 16931 business term sits beside each one so the standard stays discoverable without being imposed on you. This table is generated from the very rules the endpoint validates against, so it cannot drift from them.

Always means the call is refused without it. Conditional means a business rule can make it mandatory: VAT category E needs an exemption reason, a payment group needs a means code, a credit transfer needs an IBAN, category K needs a delivery date or period and a delivery country, and every invoice needs a due date or payment terms. Optional means nothing ever asks for it. A row for an array entry (a path ending in []) leaves the column blank: what is required or not is the array itself, one row above it.

unit (BT-130) is required on every line and has no default. BR-23 makes the unit code mandatory and fatal, so a line without one would produce a document our own rule set rejects. Use UN/ECE Recommendation 20 codes with the Rec 21 extension: HUR for an hour, DAY, KGM, MTR, or C62 for a plain countable unit.

Exemption reasons sit on the vat object of each line, allowance and charge, but they are not line terms: BT-120 and BT-121 belong to the document VAT breakdown (BG-23), and we group them there for you. For categories AE, K, G and O we fill in the standard wording when you send none. Category E has no standard wording, so a reason is required there and its absence is a 422 pointing at the exact line.

The request

Field BT / BG Required Type and limits
format optional one of facturx
profile optional one of en16931, extended
output optional one of xml, pdf
language optional one of fr, en, de
invoice always object
totals optional object
logo optional string, max 2,000,000 characters

Invoice header

Field BT / BG Required Type and limits
number BT-1 always string, max 100 characters
issueDate BT-2 always string, max 10 characters
typeCode BT-3 always one of 380, 381, 384, 389
currency BT-5 always string, max 3 characters
dueDate BT-9 conditional string, max 10 characters
paymentTerms BT-20 conditional string, max 2,000 characters
buyerReference BT-10 optional string, max 200 characters
purchaseOrderReference BT-13 optional string, max 200 characters
notes BG-1 optional array
notes[] BG-1 object
notes[]/text BT-22 always string, max 1,000 characters
precedingInvoices optional array
precedingInvoices[] object
precedingInvoices[]/number BT-25 always string, max 100 characters
precedingInvoices[]/issueDate BT-26 optional string, max 10 characters
prepaidAmount BT-113 optional decimal, max 2 decimals
roundingAmount BT-114 optional decimal, max 2 decimals

Seller (BG-4)

Field BT / BG Required Type and limits
/invoice/seller always object
name BT-27 always string, max 200 characters
vatId BT-31 conditional string, max 50 characters
taxRegistrationId BT-32 conditional string, max 50 characters
legalRegistrationId BT-30 conditional string, max 50 characters
legalInformation BT-33 optional string, max 1,000 characters
electronicAddress optional object
electronicAddress/value BT-34 optional string, max 200 characters
electronicAddress/scheme BT-34-1 optional string, max 10 characters
address always object
address/line1 BT-35 optional string, max 200 characters
address/line2 BT-36 optional string, max 200 characters
address/city BT-37 optional string, max 100 characters
address/postCode BT-38 optional string, max 20 characters
address/country BT-40 always string, max 2 characters
contact optional object
contact/name BT-41 optional string, max 200 characters
contact/phone BT-42 optional string, max 50 characters
contact/email BT-43 optional string, max 200 characters

Buyer

Field BT / BG Required Type and limits
/invoice/buyer always object
name BT-44 always string, max 200 characters
vatId BT-48 conditional string, max 50 characters
taxRegistrationId optional string, max 50 characters
legalRegistrationId BT-47 conditional string, max 50 characters
legalInformation optional string, max 1,000 characters
electronicAddress optional object
electronicAddress/value BT-49 optional string, max 200 characters
electronicAddress/scheme BT-49-1 optional string, max 10 characters
address always object
address/line1 BT-50 optional string, max 200 characters
address/line2 BT-51 optional string, max 200 characters
address/city BT-52 optional string, max 100 characters
address/postCode BT-53 optional string, max 20 characters
address/country BT-55 always string, max 2 characters
contact optional object
contact/name BT-56 optional string, max 200 characters
contact/phone BT-57 optional string, max 50 characters
contact/email BT-58 optional string, max 200 characters

Delivery

Field BT / BG Required Type and limits
/invoice/delivery conditional object
date BT-72 conditional string, max 10 characters
periodStart BT-73 conditional string, max 10 characters
periodEnd BT-74 conditional string, max 10 characters
country BT-80 conditional string, max 2 characters

Payment instructions (BG-16)

Field BT / BG Required Type and limits
/invoice/payment optional object
meansCode BT-81 conditional string, max 10 characters
iban BT-84 conditional string, max 34 characters
bic BT-86 optional string, max 11 characters
accountName BT-85 optional string, max 200 characters
remittanceInformation BT-83 optional string, max 200 characters
mandateReference BT-89 optional string, max 70 characters
creditorId BT-90 optional string, max 35 characters

Invoice lines (BG-25)

Field BT / BG Required Type and limits
/invoice/lines always array
/invoice/lines[] object
id BT-126 always string, max 50 characters
name BT-153 always string, max 200 characters
description BT-154 optional string, max 1,000 characters
quantity BT-129 always decimal, max 6 decimals
unit BT-130 always string, max 10 characters
unitPrice BT-146 always decimal, max 6 decimals
priceBaseQuantity BT-149 optional decimal, max 6 decimals
vat always object
vat/category BT-151 always string, max 2 characters
vat/rate BT-152 conditional decimal, max 2 decimals
vat/exemptionReason BT-120 conditional string, max 200 characters
vat/exemptionReasonCode BT-121 optional string, max 30 characters

Document allowances (BG-20)

Field BT / BG Required Type and limits
/invoice/allowances optional array
/invoice/allowances[] object
amount BT-92 conditional decimal, max 2 decimals
percentage BT-94 conditional decimal, max 2 decimals
baseAmount BT-93 conditional decimal, max 2 decimals
reason BT-97 conditional string, max 200 characters
reasonCode BT-98 conditional string, max 10 characters
vat always object
vat/category BT-95 always string, max 2 characters
vat/rate BT-96 conditional decimal, max 2 decimals
vat/exemptionReason BT-120 conditional string, max 200 characters
vat/exemptionReasonCode BT-121 optional string, max 30 characters

Document charges (BG-21)

Field BT / BG Required Type and limits
/invoice/charges optional array
/invoice/charges[] object
amount BT-99 conditional decimal, max 2 decimals
percentage BT-101 conditional decimal, max 2 decimals
baseAmount BT-100 conditional decimal, max 2 decimals
reason BT-104 conditional string, max 200 characters
reasonCode BT-105 conditional string, max 10 characters
vat always object
vat/category BT-102 always string, max 2 characters
vat/rate BT-103 conditional decimal, max 2 decimals
vat/exemptionReason BT-120 conditional string, max 200 characters
vat/exemptionReasonCode BT-121 optional string, max 30 characters

Line allowances (BG-27)

Field BT / BG Required Type and limits
/invoice/lines[]/allowances optional array
/invoice/lines[]/allowances[] object
amount BT-136 conditional decimal, max 2 decimals
percentage BT-138 conditional decimal, max 2 decimals
baseAmount BT-137 conditional decimal, max 2 decimals
reason BT-139 conditional string, max 200 characters
reasonCode BT-140 conditional string, max 10 characters

Line charges (BG-28)

Field BT / BG Required Type and limits
/invoice/lines[]/charges optional array
/invoice/lines[]/charges[] object
amount BT-141 conditional decimal, max 2 decimals
percentage BT-143 conditional decimal, max 2 decimals
baseAmount BT-142 conditional decimal, max 2 decimals
reason BT-144 conditional string, max 200 characters
reasonCode BT-145 conditional string, max 10 characters

Totals, if you send them for checking

Field BT / BG Required Type and limits
lineNet BT-106 optional decimal, max 2 decimals
allowances BT-107 optional decimal, max 2 decimals
charges BT-108 optional decimal, max 2 decimals
taxBasis BT-109 optional decimal, max 2 decimals
vat BT-110 optional decimal, max 2 decimals
grossTotal BT-112 optional decimal, max 2 decimals
prepaid BT-113 optional decimal, max 2 decimals
amountDue BT-115 optional decimal, max 2 decimals

Limits

Request body 2 MiB, 413 beyond it. 500 lines per invoice, 400 beyond that, which lays out to nine PDF pages. Every string field has the maximum length shown in the table above. A logo must be a PNG or JPEG data URI of at most 2000 by 2000 pixels, recognized by its magic bytes rather than by the MIME type it declares.

What it costs

You are charged for the call, not for what comes out of it. Asking for a PDF costs exactly what asking for the XML costs: a Factur-X PDF is one invoice that carries its own XML, which is what the standard is, not two deliverables. Free accounts get 20 generated invoices a month in an allowance of their own, separate from their 100 validations, so a month of validating can never eat your generations or the other way round. Pro pools instead: 1,000 credits a month, where a validation spends one credit and a generated invoice spends two, mixed however you like. That is 1,000 invoices validated, or 500 generated, or anything in between.

Refused calls are not billed, and neither is anything that turns out to be our fault. The call is counted only once the document has passed the XSD, the full rule set and, for a PDF, the PDF/A check, and is on its way back to you. A call that cannot afford its whole cost is refused before any of that work is done and leaves your allowance untouched: on Pro with a single credit left, a validation still goes through and a generation is a <code>429</code>, rather than a generation half-billed against credits it cannot cover.

Retrying safely

Send an Idempotency-Key header, 1 to 255 characters, scoped to your API key and kept for 24 hours. Its promise is narrow and exact: the same key with the same body is never billed twice. Response bodies are not stored, so a retry re-runs the whole pipeline rather than replaying a saved answer. You get the same document back all the same: the XML is built deterministically from your request, so the same body always produces the same bytes.

The same key with a different body is a 409 idempotency_key_reuse, not a quiet substitution: a key is a promise about one request. A 422 releases the key, so the corrected invoice can go out under it.

A replay is free but not costless to serve, so it is capped: five replays of an already billed document, then 429 replay_limit_exceeded. That covers the shapes a real retry arrives in, a dropped connection, a client timeout, an at-least-once queue, and stops the loop that would otherwise turn one paid document into a day of free compute.

Errors

Real status codes, always: never a 200 with the failure hidden inside it. Every refusal is JSON carrying a snake_case error you can branch on and a human message you should not.

Status error Meaning
400 unknown_parameter This endpoint takes no query parameters. The offending one is named.
400 invalid_json The body is not valid JSON.
400 invalid_request The body is not shaped the way this endpoint expects: an unknown key, a field this release does not accept, a value outside an enum, too many decimals, an over-long string, more than 500 lines, or an allowance that gives both an amount and a percentage.
400 invalid_idempotency_key Idempotency-Key is present but not 1 to 255 characters.
400 output_conflict Accept and output name different representations. Ask for the one the body says it is producing, or drop the header.
401 invalid_api_key Missing or unknown API key.
405 method_not_allowed Only POST exists.
409 idempotency_key_reuse This Idempotency-Key was already used with a different body.
413 payload_too_large The request body is over 2 MiB.
422 invalid_invoice The invoice parsed but breaks a rule it has to satisfy, either one of ours before the pipeline or an EN 16931 rule the official rule set caught. issues says which.
422 totals_mismatch The totals you sent disagree with the ones we computed. Both are shown.
429 quota_exceeded Monthly allowance exhausted. The response carries an upgrade field with the URL of the door that fits your account.
429 replay_limit_exceeded This Idempotency-Key has replayed its billed document five times already.
500 generation_failed Ours, not yours: our own XML failed our own XSD, the validator was unreachable, or an arithmetic rule flagged arithmetic we computed ourselves. Nothing is billed and we are alerted.
504 generation_timeout The validation stage did not answer inside 20 seconds. Nothing is billed, we are alerted, and a retry normally succeeds.

Reading issues

Refusals about your data carry an issues array. rule names the rule that failed: one of our own codes for a schema problem, or the EN 16931 rule id when the official rule set caught it. field is a JSON pointer into the body you sent; message is the rule text. Competitors hand back an XPath into XML you never wrote; we own both ends of that mapping, so you get the pointer instead. When a finding maps to no single field, field is null with the real rule id beside it, never a pointer we invented.

{
  "error": "invalid_invoice",
  "message": "this invoice does not satisfy the rules it has to satisfy; see issues.",
  "issues": [
    {
      "rule": "BR-CL-23",
      "field": "/invoice/lines/0/unit",
      "message": "[BR-CL-23]-Unit code MUST be coded according to the UN/ECE Recommendation 20 with Rec 21 extension"
    }
  ]
}

Codes you can see in rule on a 400: unknown_field, unsupported_field, unsupported_value, too_many_decimals, too_long, invalid_value, too_many_lines, invalid_amount_shape. On a 422: missing_field, missing_exemption_reason, exemption_reason_not_allowed, rate_not_allowed, invalid_vat_rate, invalid_vat_category_mix, missing_reason, missing_seller_identification, missing_buyer_identification, missing_due_date_or_payment_terms, negative_unit_price, invalid_price_base_quantity, invalid_logo, unrenderable_text, conflicting_exemption_reasons, and the EN 16931 rule ids themselves.

The first call after an idle period

The validation engine sleeps after ten idle minutes. The first generate after that can spend the whole 20 second budget waiting for it to wake up and come back 504 generation_timeout. Retry and it works: warm, the median XML call is around 230 ms and the median PDF around 750 ms. A 500 line invoice is the slow end, several seconds as a PDF. Nothing is billed for the failed one and we are alerted on every one of them. We would rather write this down than let you find it in production.

A known warning

On profile extended, a document carrying no delivery data comes back with the warning PEPPOL-EN16931-R008, "Document MUST not contain empty elements". The CII XSD makes the delivery element mandatory (minOccurs 1) while the rule set wants no empty element, so the schema and the rule set disagree and the schema wins. It is a warning, the verdict stays valid, and there is nothing to fix on your side.

Errors & quotas

Every non-200 answer is JSON with an error field. The statuses that exist:

Status Meaning
400 Empty body, a multipart request without a file field, an unknown target value, or a document no parser recognizes.
401 Missing or unknown API key.
405 Only POST exists.
413 Document over 5 MB on /v1/validate. /v1/generate caps the request body at 2 MiB instead.
422 The document parsed but is not a supported e-invoice syntax.
429 Monthly quota exhausted. The response carries an upgrade field with the URL of the door that fits your account: pricing for a free account, the volume form for a pro one.
502 The validation engine is briefly unavailable. Retry with backoff.

Allowances are monthly, per account, across all its keys. A free account gets 100 validations plus a separate 20 generated invoices; a pro account gets one pooled 1,000 credits covering both, where a validation spends one and a generated invoice two. The window is anchored to your subscription date (or signup date on free), not the calendar month. Only requests we accepted and served are counted: a refused call, and anything that turns out to be our fault, is never billed.

What's coming

The extraction endpoint, which parses any e-invoice into normalized JSON, is still in development. Its intended shape is on the API section of the homepage. Everything else on this page you can call today.