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:
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.