The API distinguishes two things that are easy to confuse: request errors
(your call could not be processed — reported as RFC 9457 problem+json with a 4xx status)
and validation findings (the invoice has problems — reported inside the report,
with HTTP 200). An invalid invoice is a successful validation.
Whenever a request fails, the response has Content-Type: application/problem+json
and this body structure:
HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json
{
"type": "https://billhorse.com/errors/unauthorized",
"title": "unauthorized",
"status": 401,
"detail": "API key missing or invalid. Send header: Authorization: Bearer <key>"
}
| Field | Type | Meaning |
|---|---|---|
type | string (URI) | Stable identifier for the error kind, https://billhorse.com/errors/<title>. Match on this (or title), not on detail. |
title | string | Machine-readable error code: unauthorized, empty_body or rate_limited. |
status | integer | The HTTP status code, repeated in the body. |
detail | string | Human-readable explanation (English). May change; don't parse it. |
| Status | Error code | Endpoints | When |
|---|---|---|---|
200 | — | all | Success. Also returned when the invoice is invalid — check valid in the report. |
400 | empty_body | /validate, /parse | Empty request body. Send the invoice as raw bytes (XML or PDF) — no multipart, no base64. |
401 | unauthorized | /validate, /parse | Missing or invalid API key. Send Authorization: Bearer <key>. /health is public. |
413 | — | /validate, /parse | Request body exceeds the 10 MB limit. |
422 | — | /parse only | The input could not be parsed as an e-invoice (no UBL/CII/ZUGFeRD structure found). See below. |
429 | rate_limited | /validate, /parse | Rate limit exceeded (default 120 requests/minute per key). The Retry-After header tells you how many seconds to wait. |
Unlike /validate — which always answers 200 with a report — /parse
answers 422 Unprocessable Content when no semantic invoice model could be extracted.
The response body contains the validation report ({ "report": … }, without
invoice); its findings explain what was detected, e.g. BH-XML-01
when the input is not well-formed XML:
HTTP/1.1 422 Unprocessable Content
{
"report": {
"valid": false,
"findings": [
{ "id": "BH-XML-01", "severity": "error",
"message": "The file is not well-formed XML." }
],
…
}
}
Finding messages come in English by default (the contract language of the
API). Add ?lang=de or ?lang=fr to /validate and
/parse to get German or French messages for your end users:
curl -s -X POST "https://api.billhorse.com/v1/validate?lang=de" \
-H "Authorization: Bearer $BILLHORSE_API_KEY" \
--data-binary @rechnung.xml
de, en, fr. Region subtags work too (de-DE, fr-CH); anything else falls back to English.message is localized. id, detail, expected, actual and found are language-neutral, so your error handling never depends on the language.problem+json request errors are not localized — their detail is always English.Validation problems never surface as HTTP errors. They are entries in the report's
findings array; valid is true iff no finding has severity
error, and counts aggregates findings per severity:
{
"valid": false,
"findings": [
{ "id": "BR-DE-15", "severity": "error",
"message": "XRechnung: buyer reference (BT-10, …) is mandatory." },
{ "id": "BR-CO-16", "severity": "error",
"message": "Amount due (BT-115) does not match …",
"expected": "178.50", "actual": "170.00" }
],
"counts": { "error": 2, "warning": 0, "info": 0 }
}
| Field | Presence | Meaning |
|---|---|---|
id | always | Rule id — stable, language-neutral. Every id has a human explanation page: billhorse.com/en/rules/<id>/ (also in DE and FR). |
severity | always | error (invoice is invalid), warning (should be fixed) or info (notice, e.g. a migration hint). |
message | always | Human-readable explanation, localizable via ?lang=. |
detail | optional | Technical detail (e.g. the raw XML parser error). Not translated. |
expected / actual | optional | Computed vs. stated value — set on arithmetic rules (e.g. BR-CO-16). |
found | optional | The offending value (e.g. a malformed IBAN or Leitweg-ID). |
Optional fields are omitted entirely when not set (never null).
| Prefix | Source | Examples |
|---|---|---|
BR-*, BR-CO-*, BR-S/Z/E/G/IC-* | EN 16931 business rules (field presence, arithmetic, VAT categories) | BR-01, BR-CO-16, BR-S-10 |
BR-DE-* | German XRechnung rules (applied when profile.xrechnung is true) | BR-DE-15 (Leitweg-ID) |
PEPPOL-EN16931-* | Peppol BIS Billing 3.0 rules (applied when profile.peppol is true) | PEPPOL-EN16931-R010 |
BH-* | Billhorse checks: file/structure, profiles, plausibility, French rules | BH-XML-01, BH-PDF-01, BH-IBAN-01, BH-FR-01 |
The full catalog with fixes lives in the rule index — in EN, DE and FR.