Invoir

API documentation

One HTTP call checks an e-invoice against the official rules and returns a JSON report with every rule ID, message and location. Formats: XRechnung (UBL and CII), Peppol BIS Billing 3.0, plain EN 16931, and Factur-X / ZUGFeRD PDFs including PDF/A-3.

Quick start

  1. Get a free API key: enter your email, click the link we send, copy the key. 25 documents a month, no credit card.
  2. POST an invoice:
curl https://validate.invoir.dev/api/v1/validate \
  -H "Authorization: Bearer inv_YOUR_KEY" \
  -F [email protected]

Base URL: https://validate.invoir.dev/api/v1. Both endpoints take multipart/form-data with one field named file and answer JSON. Files up to 10 MB.

Authentication

Every request carries your key in the Authorization header:

Authorization: Bearer inv_AbCdEfGhIjKlMnOpQrStUvWxYz012345

Keys start with inv_. We store only a hash, so a key can't be shown again; regenerate it on your key page (the link is in your signup email, or request a new link). Regenerating revokes the old key at once. Keep keys out of client-side code and repositories.

Without a valid key the API answers 401 with missing_api_key or invalid_api_key.

POST /validate

Checks a PDF or XML invoice and returns the report.

Request

file
The invoice, as a multipart file field. A Factur-X / ZUGFeRD PDF, or XML in UBL 2.1 or UN/CEFACT CII syntax (XRechnung, Peppol BIS, EN 16931, Factur-X profiles). The format is detected from the content; the file name and declared content type are ignored.

Response: 200 with the report

{
  "status": "invalid",
  "fileType": "xml",
  "format": "EN 16931 XRechnung (UBL Invoice)",
  "specificationId": "urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0",
  "message": null,
  "errors": 1,
  "warnings": 0,
  "checks": [
    {
      "name": "EN 16931 XRechnung (UBL Invoice)",
      "engine": "KoSIT validator 1.6.3 · XRechnung 3.0.2 configuration 2026-08-31",
      "passed": false,
      "findings": [
        {
          "severity": "error",
          "rule": "BR-CO-15",
          "message": "Invoice total amount with VAT (BT-112) = Invoice total amount without VAT (BT-109) + Invoice total VAT amount (BT-110).",
          "location": "/ubl:Invoice/cac:LegalMonetaryTotal",
          "line": 142
        }
      ]
    }
  ],
  "embeddedXml": null,
  "durationMs": 340
}
status
valid (no errors; warnings allowed), invalid (at least one error), or unsupported (not an e-invoice we can check; see message). Note that an unsupported file is still a 200: the request worked, the file just isn't an invoice.
fileType
pdf, xml or unknown.
format
What was detected, for people: for example EN 16931 XRechnung (UBL Invoice), Peppol BIS Billing 3.0 (UBL Invoice) or PDF · Factur-X / ZUGFeRD EN 16931 (COMFORT). Null when nothing was recognised.
specificationId
The CustomizationID (UBL) or GuidelineSpecifiedDocumentContextParameter (CII) found in the XML, which decides the rule set.
errors, warnings
Totals over all checks.
checks[]
One entry per validation step, in the order they ran. For a PDF: the PDF/A-3 and metadata check, then the embedded XML's rules. engine names the engine and rule-set versions that produced it. passed is false when the step has errors.
checks[].findings[]
severity is error, warning or info; rule is the ID as published by the rule set (BR-CO-15, PEPPOL-EN16931-R003, BR-DE-15, …); message the rule's own text; location a simplified XPath into the XML, or a PDF object path for PDF/A findings; line the line in the XML when the engine reports one. Both may be null.
embeddedXml
For PDFs: the invoice XML that was extracted and checked, as a string. Null for XML uploads.
durationMs
Processing time on our side.

POST /extract

Returns the XML embedded in a Factur-X / ZUGFeRD PDF without validating it. Same request shape as /validate.

{
  "xml": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<rsm:CrossIndustryInvoice …",
  "format": "Factur-X / ZUGFeRD EN 16931 (COMFORT)",
  "specificationId": "urn:cen.eu:en16931:2017"
}

A PDF without an invoice attachment answers 422 no_embedded_xml. Each successful extraction counts as one document, like a validation.

Errors

Every error is JSON with a stable error code and a message for people:

{ "error": "quota_exceeded", "message": "The free plan includes 25 documents per month …" }
HTTPerrorWhen
400no_fileThe request is not multipart/form-data or has no file field.
400empty_fileThe file has zero bytes.
401missing_api_keyNo Authorization: Bearer header.
401invalid_api_keyUnknown key, or one that was regenerated.
404not_foundNo such endpoint.
405method_not_allowedUse POST.
413file_too_largeOver 10 MB.
422no_embedded_xml/extract on a PDF without an invoice attachment.
429quota_exceededThe monthly quota is used up; see the X-Quota-Reset header.
503busyAll validation slots were busy for 30 s. Retry after the seconds in Retry-After.
500internal_errorOur fault. Email [email protected] if it repeats.

Quota and limits

The free plan includes 25 documents per calendar month (UTC), counted per account. A document is one successful (200) call to /validate or /extract; error responses don't count. Every counted response carries:

X-Quota-Limit: 25
X-Quota-Used: 7
X-Quota-Reset: 2026-11-01

Beyond the quota the API answers 429 quota_exceeded until the reset date. Paid plans are coming soon; email [email protected] to hear first, or to tell us what volume you need.

Other limits: files up to 10 MB; a few validations run at the same time and the rest wait up to 30 seconds, after which you get 503 busy with a Retry-After header. A validation typically takes between 50 ms (XML) and 2 s (large PDF). Set a client timeout of 60 seconds.

Examples

Each example posts invoice.pdf, reads the key from the INVOIR_API_KEY environment variable and prints the findings. Replace the file name with any PDF or XML invoice.

curl

curl https://validate.invoir.dev/api/v1/validate \
  -H "Authorization: Bearer $INVOIR_API_KEY" \
  -F [email protected]

Node.js (18 or newer, no dependencies)

import { readFile } from 'node:fs/promises';

const body = new FormData();
body.append('file', new Blob([await readFile('invoice.pdf')]), 'invoice.pdf');

const res = await fetch('https://validate.invoir.dev/api/v1/validate', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.INVOIR_API_KEY}` },
  body,
});
const report = await res.json();
if (!res.ok) throw new Error(`${report.error}: ${report.message}`);

console.log(report.status, report.format, `${report.errors} errors`);
for (const check of report.checks) {
  for (const f of check.findings) console.log(f.severity, f.rule, f.message, f.location ?? '');
}

Python (requests)

import os
import requests

with open("invoice.pdf", "rb") as f:
    res = requests.post(
        "https://validate.invoir.dev/api/v1/validate",
        headers={"Authorization": f"Bearer {os.environ['INVOIR_API_KEY']}"},
        files={"file": ("invoice.pdf", f)},
        timeout=60,
    )
report = res.json()
if not res.ok:
    raise SystemExit(f"{report['error']}: {report['message']}")

print(report["status"], report["format"], report["errors"], "errors")
for check in report["checks"]:
    for finding in check["findings"]:
        print(finding["severity"], finding["rule"], finding["message"], finding.get("location") or "")

PHP (curl extension)

<?php
$ch = curl_init('https://validate.invoir.dev/api/v1/validate');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 60,
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . getenv('INVOIR_API_KEY')],
    CURLOPT_POSTFIELDS     => ['file' => new CURLFile('invoice.pdf', 'application/pdf', 'invoice.pdf')],
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

$report = json_decode($body, true);
if ($status >= 400) {
    throw new RuntimeException("{$report['error']}: {$report['message']}");
}
echo "{$report['status']} {$report['format']} {$report['errors']} errors\n";
foreach ($report['checks'] as $check) {
    foreach ($check['findings'] as $f) {
        echo "{$f['severity']} {$f['rule']} {$f['message']} " . ($f['location'] ?? '') . "\n";
    }
}

Go (standard library)

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"mime/multipart"
	"net/http"
	"os"
	"time"
)

type report struct {
	Status  string `json:"status"`
	Format  string `json:"format"`
	Errors  int    `json:"errors"`
	Error   string `json:"error"`
	Message string `json:"message"`
	Checks  []struct {
		Findings []struct {
			Severity, Rule, Message, Location string
		} `json:"findings"`
	} `json:"checks"`
}

func main() {
	var body bytes.Buffer
	w := multipart.NewWriter(&body)
	part, _ := w.CreateFormFile("file", "invoice.pdf")
	f, err := os.Open("invoice.pdf")
	if err != nil {
		panic(err)
	}
	io.Copy(part, f)
	f.Close()
	w.Close()

	req, _ := http.NewRequest("POST", "https://validate.invoir.dev/api/v1/validate", &body)
	req.Header.Set("Authorization", "Bearer "+os.Getenv("INVOIR_API_KEY"))
	req.Header.Set("Content-Type", w.FormDataContentType())
	client := &http.Client{Timeout: 60 * time.Second}
	res, err := client.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var r report
	json.NewDecoder(res.Body).Decode(&r)
	if res.StatusCode >= 400 {
		panic(r.Error + ": " + r.Message)
	}
	fmt.Println(r.Status, r.Format, r.Errors, "errors")
	for _, c := range r.Checks {
		for _, f := range c.Findings {
			fmt.Println(f.Severity, f.Rule, f.Message, f.Location)
		}
	}
}

What is checked

InputEngineRules
PDF (Factur-X, ZUGFeRD, ZUGFeRD-XRechnung)Mustang + veraPDFPDF/A-3 conformance and the Factur-X XMP metadata; then the embedded XML as below
XRechnung UBL or CIIKoSIT validatorThe official KoSIT XRechnung configuration, including its accept/reject decision
Peppol BIS Billing 3.0KoSIT validatorUBL 2.1 schema, CEN EN 16931 rules and the OpenPeppol BIS Schematron with its country rules
Plain EN 16931 UBL or CIIKoSIT validatorThe CEN rules
Factur-X / ZUGFeRD CII XMLMustangXSD and Schematron of the declared profile (MINIMUM, BASIC WL, BASIC, EN 16931, EXTENDED, XRECHNUNG)

No rule is hand-written: everything comes from the published rule sets, and every report names the engine and rule-set versions in checks[].engine. The versions currently in use are listed on the validator page. When a rule set is updated (KoSIT publishes XRechnung releases twice a year, OpenPeppol three times), the API follows within days; the API shape stays the same.

Next from Invoir: generation. POST /v1/invoices turns JSON into a branded PDF/A-3 with the compliant XML embedded, validated with these same rules before it is returned. Email [email protected] to be told when it opens.