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
- Authentication
- POST /validate
- POST /extract
- Errors
- Quota and limits
- Examples
- What is checked
Quick start
- Get a free API key: enter your email, click the link we send, copy the key. 25 documents a month, no credit card.
- POST an invoice:
curl https://validate.invoir.dev/api/v1/validate \
-H "Authorization: Bearer inv_YOUR_KEY" \
-F file=@invoice.pdf
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
}
statusvalid(no errors; warnings allowed),invalid(at least one error), orunsupported(not an e-invoice we can check; seemessage). Note that an unsupported file is still a200: the request worked, the file just isn't an invoice.fileTypepdf,xmlorunknown.format- What was detected, for people: for example
EN 16931 XRechnung (UBL Invoice),Peppol BIS Billing 3.0 (UBL Invoice)orPDF · 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.
enginenames the engine and rule-set versions that produced it.passedis false when the step has errors. checks[].findings[]severityiserror,warningorinfo;ruleis the ID as published by the rule set (BR-CO-15,PEPPOL-EN16931-R003,BR-DE-15, …);messagethe rule's own text;locationa simplified XPath into the XML, or a PDF object path for PDF/A findings;linethe 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 …" }
| HTTP | error | When |
|---|---|---|
| 400 | no_file | The request is not multipart/form-data or has no file field. |
| 400 | empty_file | The file has zero bytes. |
| 401 | missing_api_key | No Authorization: Bearer header. |
| 401 | invalid_api_key | Unknown key, or one that was regenerated. |
| 404 | not_found | No such endpoint. |
| 405 | method_not_allowed | Use POST. |
| 413 | file_too_large | Over 10 MB. |
| 422 | no_embedded_xml | /extract on a PDF without an invoice attachment. |
| 429 | quota_exceeded | The monthly quota is used up; see the X-Quota-Reset header. |
| 503 | busy | All validation slots were busy for 30 s. Retry after the seconds in Retry-After. |
| 500 | internal_error | Our fault. Email hello@invoir.dev 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 hello@invoir.dev 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 file=@invoice.pdf
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
| Input | Engine | Rules |
|---|---|---|
| PDF (Factur-X, ZUGFeRD, ZUGFeRD-XRechnung) | Mustang + veraPDF | PDF/A-3 conformance and the Factur-X XMP metadata; then the embedded XML as below |
| XRechnung UBL or CII | KoSIT validator | The official KoSIT XRechnung configuration, including its accept/reject decision |
| Peppol BIS Billing 3.0 | KoSIT validator | UBL 2.1 schema, CEN EN 16931 rules and the OpenPeppol BIS Schematron with its country rules |
| Plain EN 16931 UBL or CII | KoSIT validator | The CEN rules |
| Factur-X / ZUGFeRD CII XML | Mustang | XSD 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 hello@invoir.dev to be told when it opens.