Errors
Every non-2xx response from the eFakturuj API uses a predictable shape
so your client can branch on a stable error code instead of parsing
prose. This page lists the codes, the status they ship with, and the
patterns we expect callers to apply for the three "you have to react"
classes — 422 validation, 429 rate limit, and 402 plan limit.
Envelope shape
For domain errors raised by the backend (app/core/exceptions.py) the
body is FastAPI's HTTPException envelope:
{
"detail": {
"error": "rate_limit_exceeded",
"retry_after": 12,
"limit": 60,
"current_plan": "starter",
"upgrade_url": "https://efakturuj.sk/pricing"
}
}
detail.error is the stable, machine-readable code — branch on this.
The other fields are error-specific: rate-limit and usage-limit
responses include plan + upgrade context, Peppol/FS errors include a
human message, etc.
For Pydantic request validation (malformed body, missing required field) FastAPI emits its own shape — see Reading 422 errors below.
Status code semantics
| Status | Meaning |
|---|---|
400 | Malformed request — bad JSON, wrong content-type. |
401 | Missing or invalid bearer token / API key. |
402 | Plan quota exceeded on a non-overage plan — needs upgrade. |
403 | Authenticated, but role/scope forbids this action. |
404 | Resource doesn't exist or belongs to another org (we return 404 to avoid leaking foreign ids). |
409 | State conflict — e.g. editing or deleting a sent invoice. |
422 | Validation failed — Pydantic body errors or domain validation (UBL XML didn't pass XSD/Schematron). |
429 | Rate-limited — back off using the Retry-After header. |
5xx | Server error. 502 specifically means downstream Peppol/FS transport failed; safe to retry the send. |
Error codes
Canonical list, sourced from backend/app/core/exceptions.py:
detail.error | Status | Notes |
|---|---|---|
invoice_not_found | 404 | Returned for both "doesn't exist" and "belongs to another org". |
| (validation) | 422 | detail = {"message": "Invoice validation failed", "errors": [...]} — XSD + Schematron failures from the UBL pipeline. |
usage_limit_exceeded | 402 | Includes current_plan, limit, current_usage, upgrade_url. Free plans only — paid plans bill overage at month-end. |
rate_limit_exceeded | 429 | Includes retry_after, limit, current_plan, upgrade_url. Also sets the HTTP Retry-After header. |
peppol_delivery_failed | 502 | AS4 transport to the recipient's Access Point failed. Retryable. |
fs_submission_failed | 502 | Slovak Tax Authority (Finančná správa) C5 copy upload failed. Retryable. |
invalid_status | 409 | Tried to mutate an invoice that's no longer editable (already sent, voided, etc.). |
A handful of routes also return 401 with invalid_credentials and 403
with role-specific codes — those are surfaced inline in the OpenAPI
spec rather than as dedicated exception classes.
Reading 422 validation errors
Two flavours ship with status 422:
Pydantic body errors — wrong type, missing field. Standard FastAPI shape:
{
"detail": [
{
"loc": ["body", "lines", 0, "unit_price"],
"msg": "Input should be a valid decimal",
"type": "decimal_parsing",
"input": "abc"
}
]
}
Domain validation — UBL didn't pass XSD or Schematron. From
ValidationError in exceptions.py:
{
"detail": {
"message": "Invoice validation failed",
"errors": [
{ "rule": "BR-CO-15", "message": "Invoice total amount with VAT must equal the sum of line amounts plus VAT.", "severity": "fatal" }
]
}
}
Surface field-level errors back to the user — Pydantic's loc array
maps cleanly onto your form's field names; for domain errors, render
message and group by severity.
Handling 429 (rate-limited)
Read Retry-After (seconds), back off, retry. Do not read
detail.retry_after only — the HTTP header is the source of truth and
proxies sometimes strip the body.
curl --retry 3 --retry-delay 0 --retry-all-errors \
-H 'Authorization: Bearer ...' https://api.efakturuj.sk/api/v1/invoices
import time, httpx
def request_with_backoff(client, method, url, **kw):
for _ in range(5):
r = client.request(method, url, **kw)
if r.status_code != 429:
return r
time.sleep(int(r.headers.get("Retry-After", "1")))
r.raise_for_status()
Caveat: the rate-limit middleware response uses the field name plan
(not current_plan) — branch on the HTTP status and the
Retry-After header, not on body field names.
Handling 402 (plan limit)
Stop retrying. Surface detail.upgrade_url so the caller knows where
to go, and short-circuit any queued requests for the same org until
they upgrade. 402 is a business decision — silent retries waste
quota-check round-trips and can mask the prompt the user actually
needs to see.
if r.status_code == 402:
body = r.json()["detail"]
raise PlanLimitError(
plan=body["current_plan"],
limit=body["limit"],
used=body["current_usage"],
upgrade_url=body["upgrade_url"],
)