eFakturuj / API Docs
eFakturuj Guides

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

StatusMeaning
400Malformed request — bad JSON, wrong content-type.
401Missing or invalid bearer token / API key.
402Plan quota exceeded on a non-overage plan — needs upgrade.
403Authenticated, but role/scope forbids this action.
404Resource doesn't exist or belongs to another org (we return 404 to avoid leaking foreign ids).
409State conflict — e.g. editing or deleting a sent invoice.
422Validation failed — Pydantic body errors or domain validation (UBL XML didn't pass XSD/Schematron).
429Rate-limited — back off using the Retry-After header.
5xxServer 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.errorStatusNotes
invoice_not_found404Returned for both "doesn't exist" and "belongs to another org".
(validation)422detail = {"message": "Invoice validation failed", "errors": [...]} — XSD + Schematron failures from the UBL pipeline.
usage_limit_exceeded402Includes current_plan, limit, current_usage, upgrade_url. Free plans only — paid plans bill overage at month-end.
rate_limit_exceeded429Includes retry_after, limit, current_plan, upgrade_url. Also sets the HTTP Retry-After header.
peppol_delivery_failed502AS4 transport to the recipient's Access Point failed. Retryable.
fs_submission_failed502Slovak Tax Authority (Finančná správa) C5 copy upload failed. Retryable.
invalid_status409Tried 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"],
    )