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+jsonand 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, quota_exceeded, rate_limited, invalid_model, unsupported_format or unparseable. |
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. On /validate, also returned when the invoice is invalid — check valid. On /generate and /convert the body is the resulting XML (application/xml). |
400 | empty_body | all POST | Empty request body. Send raw bytes (XML/PDF) — or, for /generate, the JSON model. |
400 | invalid_model | /generate | The JSON invoice model could not be parsed — see the InvoiceModel schema. |
400 | unsupported_format | /generate, /convert | Unknown ?format= value. Use cii or ubl. |
401 | unauthorized | all except /health | Missing or invalid API key. Send Authorization: Bearer <key>. /health is public. |
402 | quota_exceeded | all POST | Monthly quota exhausted. Upgrade or buy credits in the console — nothing is charged automatically. |
413 | — | all POST | Request body exceeds the 10 MB limit. |
422 | — (report) | /parse | No semantic invoice model could be extracted. Body is the report (without invoice). See below. |
422 | unparseable | /convert | The input could not be parsed as an e-invoice — reported as problem+json. |
429 | rate_limited | all POST | 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 — /parseanswers 422 Unprocessable Content when no semantic invoice model could be extracted. The response body contains the validation report ({ "report": … }, withoutinvoice); its findings explain what was detected, e.g. BH-XML-01when 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.xmlde, 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'sfindings array; valid is true iff no finding has severityerror, 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.