Skip to content

e-Factura — Web Services API

Operations for the RO e-Factura system. Authentication is the shared OAuth2 flow (Bearer token). Invoice format is UBL 2.1 / CIUS-RO; validation is ANAF's server-side validare endpoint (§5) — anafpy deliberately has no local rule engine (see /DESIGN.md §4). This doc is the transport/API surface.

Status: draft, compiled from the official 5-page API PDF (current as linked from the e-Factura informații tehnice page on 2026-06-28) plus the official per-endpoint swagger presentations (vendored 2026-07-02 under _sources/efactura-swagger/) — the PDF covers URLs/params, the swaggers are the authority on response schemas. Re-confirm endpoint behaviour with a live test-environment call during implementation. First live TEST confirmation 2026-07-02: listaMesajePaginatieFactura returned HTTP 200 {"eroare":"Nu exista mesaje in intervalul selectat","titlu":"Lista Mesaje"} for an empty window — exactly the documented no-results shape below. Full roundtrip confirmed 2026-07-02 (TEST, minimal CIUS-RO invoice): upload (index_incarcare) → stareMesaj (in prelucrareok + id_descarcare) → descarcare (ZIP with signed invoice + semnatura XML) → paginated list with results — all matching the shapes below. The same session confirmed that validare and transformare answer HTTP 404 on test (see the caveat below).

Access modes & base URLs

Every endpoint has two access modes, differing only by host:

Mode Host How anafpy uses it
OAuth2 (Bearer token) https://api.anaf.ro/{prod\|test} This is what anafpy uses.
Certificate-at-call-time https://webserviceapl.anaf.ro/{prod\|test} Not used (direct mTLS per call).
Public (no auth) — validate/PDF only https://webservicesp.anaf.ro/prod Optional, for validare/transformare.

All paths below are shown for the OAuth2 mode, e.g. https://api.anaf.ro/prod/FCTEL/rest/.... Swap prodtest for the environments — with one caveat: the PDF documents validare, transformare, and validate/signature for producție only (no test URLs shown), and for the first two that is not an omission — live-confirmed 2026-07-02 that https://api.anaf.ro/test/FCTEL/rest/{validare,transformare}/FACT1 answer HTTP 404: they exist only on prod (validate/signature lives at the host root with no prod/test segment at all). Documents filed to TEST are validated by the upload pipeline itself (stareMesajok/nok).

Provenance: PDF pp. 1–5 (each operation lists both webserviceapl cert mode and api.anaf.ro oauth2 mode).

webservicesp.anaf.ro also hosts ANAF's broader family of public no-auth lookup services (VAT/taxpayer registry with statusRO_e_Factura, the RO e-Factura register query, financial statements) — documented separately in public/api.md.

1. Upload — POST /FCTEL/rest/upload

Submit an invoice/credit-note/message XML.

POST https://api.anaf.ro/prod/FCTEL/rest/upload?standard={std}&cif={cif}
     [&extern=DA][&autofactura=DA][&executare=DA]
Content-Type: text/plain        # XML as the raw body (accepted: swagger declares */*)
Authorization: Bearer <token>
Param Req Values / meaning
standard UBL (invoice), CN (Credit Note), CII, or RASP (buyer→issuer message).
cif Numeric CIF that receives the error message if the seller can't be identified from the XML. You must have SPV rights for it.
extern DA only — buyer is outside Romania (no CUI/NIF).
autofactura DA only — self-billing (beneficiary issues on supplier's behalf).
executare DA only — filed by an enforcement body on the debtor's behalf.

Response (200, application/xml) — a <header> element with attributes dateResponse, ExecutionStatus (0 = accepted, 1 = rejected), and on success index_incarcare (the upload index used by stareMesaj); on rejection a nested Errors list with errorMessage attributes. 400 returns a JSON CustomErrorMessage (timestamp/status/error/message).

B2C variant: identical, at POST /FCTEL/rest/uploadb2c?.... Live on test + prod since 01.01.2025; mandatory for B2C filings since 31.03.2025 (informații-tehnice).

Provenance: PDF pp. 1–2; response schema from the upload swagger (upload.html, actualizat 04.02.2025).

2. Message status — GET /FCTEL/rest/stareMesaj

GET https://api.anaf.ro/prod/FCTEL/rest/stareMesaj?id_incarcare={index}

id_incarcare (✔) = the numeric upload index. Response field stare:

stare Meaning
ok Validated & processed. Download = original invoice + MF e-signature. Invoice reaches the buyer.
nok Errors found, not processed. Download = errors file + MF e-signature. Invoice does not reach the buyer.
XML cu erori nepreluat de sistem Rejected at upload; the error was already returned in the upload response.
in prelucrare Still processing — keep polling.

Response (200, application/xml) — a <header> element: stare, and on ok/nok an id_descarcare attribute (feeds descarcare). Query failures (unknown/invalid id_incarcare, missing SPV rights, daily limit reached) come back as <Errors errorMessage="…"/> without stare — they are errors about the query, not a state of the document.

anafpy: ok/nok are terminal; in prelucrare drives the upload_and_wait poll loop. nok is a typed business outcome (not an exception); an Errors-only response raises AnafResponseError.

Provenance: PDF p. 2; response schema + error catalog from the stareMesaj swagger (staremesaj.html).

3. Message lists

3a. GET /FCTEL/rest/listaMesajeFactura (by days)

GET .../listaMesajeFactura?zile={1..60}&cif={cif}[&filtru={E|T|P|R}]

3b. GET /FCTEL/rest/listaMesajePaginatieFactura (paginated, by time range)

GET .../listaMesajePaginatieFactura?startTime={ms}&endTime={ms}&cif={cif}&pagina={n}[&filtru=...]
startTime/endTime = unix-timestamp milliseconds (e.g. 1646037374000). Range rules (per the lista swagger's error catalog): endTime cannot be before startTime ("endTime nu poate fi inainte de startTime"), cannot be in the future ("nu poate fi o data din viitor"), and startTime cannot be older than 60 days from the request moment ("nu poate fi mai vechi de 60 de zile fata de momentul requestului") — messages are retained for 60 days.

Both ends are judged against ANAF's clock, and the rejection can quote it. The swagger's examples state the phrase bare, but the live endpoint appends its own request moment, in the same DD-MM-YYYY HH:MM:SS format and in Romania wall time (field-observed 2026-07-30, a caller one second ahead):

endTime = 30-07-2026 15:15:28 nu poate in viitor fata de momentul requestului = 30-07-2026 15:15:27

One second of clock disagreement is therefore enough to fail an entire listing, and a zile=60-equivalent window sits on both limits at once, so it can fail from either end. The sibling start-side fault (nu poate fi mai vechi de 60 de zile fata de momentul requestului) has not been observed carrying a value.

anafpy: list_messages's days path computes the window a margin inside each limit (5 min at start, 60 s at end) instead of exactly on it; an explicit start/end is sent untouched. When a rejection quotes momentul requestului, the window is rebuilt against that clock and the request retried once (_transport.base.request_moment_from_message, parsed as Romania wall time).

filtru (optional): E=ERORI FACTURA, T=FACTURA TRIMISĂ, P=FACTURA PRIMITĂ, R=MESAJ CUMPĂRĂTOR PRIMIT / MESAJ CUMPĂRĂTOR TRANSMIS (both directions).

Response fields (per message): data_creare, cif, id_solicitare (the upload index), detalii, tip (FACTURA TRIMISA | FACTURA PRIMITA | ERORI FACTURA | MESAJ CUMPARATOR …), and id (used by descarcare) — the six properties of the swagger Mesaj schema.

cif_emitent / cif_beneficiar are documented but never sent. The API PDF (pp. 3–4) lists them among "Parametrii raspuns" for both list endpoints (cif_emitent = seller, cif_beneficiar = buyer), but the swagger Mesaj schema omits them, and a production pull of 522 messages (2026-07-06) contained no such JSON keys on any message. Both online sources re-fetched 2026-07-07 and found byte-identical to the vendored copies — the conflict is ANAF's, and per this doc's source hierarchy the swagger wins. The CIFs ride only inside the free-text detalii, in (at least) three wordings (first two live-observed in production, third from the swagger examples):

  • Factura cu id_incarcare={id} emisa de cif_emitent={seller} pentru cif_beneficiar={buyer}
  • Factura cu id_incarcare={id} transmisa de cif={buyer} ca autofactura in numele cif={seller} (self-billing: the buyer issues on the supplier's behalf)
  • Erori de validare identificate la factura primita cu id_incarcare={id} (no CIFs)

Response envelope (paginated, 3b) — alongside mesaje[]: numar_inregistrari_in_pagina, numar_total_inregistrari_per_pagina (500), numar_total_inregistrari, numar_total_pagini, index_pagina_curenta, serial, cui, titlu. The non-paginated list (3a) caps at 500 messages and errors with "folositi endpoint-ul cu paginatie" beyond that.

Indexing lag (live-observed 2026-07-02, TEST): list entries are created asynchronously after processing — a freshly filed invoice whose stareMesaj is already ok (and whose ZIP downloads fine) may take anywhere from seconds to ~15 minutes to appear in the lists; data_creare is the indexing time, not the upload time. A self-billed filing (seller CIF == buyer CIF) produces two entries per invoice, FACTURA TRIMISA and FACTURA PRIMITA, sharing one id_solicitare.

200-with-eroare: both list endpoints return errors and the no-results note in the same eroare field on HTTP 200. Known no-results wordings: "Nu exista mesaje in intervalul selectat" (3b) / "Nu exista mesaje in ultimele {N} zile" (3a). Everything else (CIF … nu este un numar, Nu aveti drept in SPV pentru CIF=…, invalid startTime/endTime/pagina/filtru, page > total pages, daily call limit reached) is a genuine error.

anafpy: list_messages validates the window rules above client-side (AnafConfigError, raised eagerly before any request) — they are unconditional and documented, so failing locally with an English message beats spending a round-trip on a 200-with-eroare. MessageListItem keeps sender_cif/receiver_cif as optional aliases for the PDF-documented keys (wire wins if ANAF ever sends them); when absent it extracts them (best-effort) from the first two detalii wordings above, leaving None for wordings that carry no CIFs.

Provenance: PDF pp. 2–4; envelope + eroare catalog + Mesaj schema from the lista swagger (listamesaje.html); cif_emitent/cif_beneficiar absence live-confirmed in production 2026-07-06; the clock-quoting form of the endTime-in-the-future fault field-observed in production 2026-07-30.

4. Download — GET /FCTEL/rest/descarcare

GET https://api.anaf.ro/prod/FCTEL/rest/descarcare?id={id}
id (✔) = the id from a listaMesaje entry. Returns a ZIP with two XML files: the original invoice or the identified errors (as applicable), and the MF electronic signature.

anafpy: this is the DownloadedMessage — preserve both raw XML bytes (the signed original is the legally valid artifact).

Provenance: PDF p. 4.

4.1 Error payloads — HTTP 200 with a JSON note, undocumented

descarcare reports failures the way the listing endpoints do: HTTP 200 with a JSON body instead of the ZIP. The one wording confirmed in the field is the closed download window (observed 2026-07-30 on 3 messages of a client archive):

HTTP/1.1 200 OK
Content-Type: application/json

{"eroare":"Fisierul nu mai poate fi descarcat pentru ca a trecut perioada de 60 de zile
 in care este disponibil","titlu":"Descarcare mesaj"}

This is terminal — the file is gone from the SPV, and no retry will ever succeed. It is reachable without any caller error: listaMesaje and descarcare anchor their 60 days differently, so ANAF lists messages it then refuses to hand over. A first sync, or one catching up after a gap, with a 60-day lookback walks straight into that boundary band.

Other failures (unknown id, malformed request) arrive in the same 200-with-eroare shape; their wordings are not catalogued here — only the one above is live-observed.

anafpy: download raises AnafDownloadExpiredError(AnafResponseError) for this note, carrying message_id, so an archiver can skip the message permanently instead of substring-matching Romanian prose. The recognizer (is_download_expired_message, shared transport) reads the parsed JSON eroare field and matches the accent-stripped core "nu mai poate fi descarcat" only — every other non-ZIP body, including a body it cannot parse, stays a plain AnafResponseError. It under-matches deliberately: a false positive would lose an invoice for good.

Provenance: live-observed in production 2026-07-30 (3 messages, anaf-sync archive); not in ANAF's documentation or the swagger.

5. Validate XML — POST /FCTEL/rest/validare/{std}

POST https://api.anaf.ro/prod/FCTEL/rest/validare/{FACT1|FCN}      # oauth2
POST https://webservicesp.anaf.ro/prod/FCTEL/rest/validare/{FACT1|FCN}   # no auth
Content-Type: text/plain        # XML in the body
std (✔): FACT1 (invoice) or FCN (credit note). Server-side validation. Available without auth on webservicesp.anaf.ro. Prod-only — the test path answers HTTP 404 (live-confirmed 2026-07-02). anafpy therefore always calls the no-auth webservicesp.anaf.ro/prod variant, regardless of the client's environment (both response shapes live-confirmed there 2026-07-02: ok, and nok with Messages[] + trace_id).

Response (JSON): {"stare": "ok"|"nok", "Messages": [{"message": "..."}], "trace_id": "..."}Messages present on nok.

Provenance: PDF p. 4; response schema confirmed by the validare swagger (validare.html).

6. XML → PDF — POST /FCTEL/rest/transformare/{std}[/{novld}]

POST https://api.anaf.ro/prod/FCTEL/rest/transformare/{FACT1|FCN}[/DA]
Content-Type: text/plain        # XML in the body
std (✔): FACT1 | FCN. Optional novld=DA skips validation (⚠️ ANAF does not guarantee correctness of the PDF for unvalidated XML). Also available no-auth on webservicesp.anaf.ro. Prod-only — the test path answers HTTP 404 (live-confirmed 2026-07-02). anafpy therefore always calls the no-auth webservicesp.anaf.ro/prod variant, regardless of the client's environment (live-confirmed 2026-07-02: returns %PDF bytes for a valid invoice).

Provenance: PDF p. 5.

6.1 The firewall in front of the body — undocumented, live-observed

webservicesp.anaf.ro is fronted by an F5 WAF that scans the posted XML. When the document matches one of its attack signatures the answer is the block page

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8

<html><head><title>Request Rejected</title></head><body>The requested URL was rejected.
Please consult with your administrator.<br><br>Your support ID is: 9826645832961917351…

HTTP 200, so it is neither a PDF nor the JSON error shape. Legitimate, ANAF-accepted invoices trip it (observed 2026-07-30 on 4 of ~525 archived inbound messages, from 3 issuers, deterministically):

  • path traversal — a relative path in xsi:schemaLocation (… ../../UBL-2.1(1)/xsd/maindoc/UBL-Invoice-2.1.xsd), which some issuing software emits on every document (3 of the 4);
  • shell command;CP inside an address (Loc.Periam Nr. 868;CP 307315) matches the ;cp signature (;rm likewise; ;CP307315 without the space passes).

Two behaviours matter and are the basis of anafpy's handling (all live-confirmed 2026-07-30 with the four real invoices):

Path Same blocked document
transformare/{std}/DA (skip validation) block page
transformare/{std} (validating) renders — the /DA URL carries the stricter policy
validare/{std} passes
api.anaf.ro (OAuth host, e.g. upload) passes — the policy is this host's, so filing is unaffected
  • Dropping xsi:schemaLocation clears the traversal class: the attribute is advisory (ANAF renders against its own schemas) and the PDF is byte-identical with it, without it, and with an http:// URL there.
  • The content-signature class (;CP) cannot be defused without altering what the PDF displays — XML character references do not help (&#32; for the space is still blocked, so the WAF decodes entities before matching) — but the validating path renders it unchanged.

anafpy: raise_for_waf_rejection (shared transport) turns the block page into AnafWafRejectionError on every client; PublicClient strips xsi:schemaLocation from both document services and retries render_invoice_pdf(validate=False) once on the validating path. Nothing about this is in ANAF's documentation.

7. Validate signature — POST /api/validate/signature

POST https://api.anaf.ro/api/validate/signature                  # oauth2
POST https://webservicesp.anaf.ro/api/validate/signature         # no auth
multipart/form-data:
  file      = invoice XML
  signature = signature XML
Both files come from the descarcare ZIP. Response (200, JSON): {"msg": "…"} for both outcomes — valid and invalid are distinguished only by the wording ("…au fost validate cu succes…" vs "…NU au putut fi validate cu succes…"); a technical error is HTTP 400 with the same {msg} shape. Note the path sits at the host root — no FCTEL/rest prefix and no test/prod segment.

Provenance: PDF p. 5; response shape from the signature swagger (validaresemnatura.html, 03.09.2024).

8. Rate limits (per method)

Global (api.anaf.ro): 1000 requests/minute (see the OAuth doc §8). On top of that, per-method daily limits:

Method Limit
upload max 1000 RASP (buyer-message) files/day/CUI; no limit for invoice files.
stareMesaj max 100 queries per message/day; no limit on total queries/day/CUI.
listaMesajeFactura (3a) max 1500 queries/day/CUI.
listaMesajePaginatieFactura (3b) max 100 000 queries/day/CUI.
descarcare max 10 downloads per message/day; no limit on total downloads/day/CUI.

Limits can change; repeated over-limit calls can get the user (and, in serious cases, the application) blocked. (An older lista swagger example mentions a 1000/day list limit — the limits file is newer and is the authority.)

Provenance: limiteApeluriAPI.txt (retrieved 2026-07-02).

anafpy endpoint map

anafpy method HTTP
upload(xml, standard, cif, *, extern, autofactura, executare) POST /FCTEL/rest/upload
upload(..., b2c=True) POST /FCTEL/rest/uploadb2c
get_status(index) GET /FCTEL/rest/stareMesaj
list_messages(cif, *, days \| start+end, filter=None)AsyncIterator[MessageListItem] GET /FCTEL/rest/listaMesajePaginatieFactura (paged internally)
download(id)DownloadedMessage GET /FCTEL/rest/descarcare
PublicClient.validate_invoice(xml, standard) POST webservicesp.anaf.ro/prod/FCTEL/rest/validare/{std} (no auth, prod-only)
PublicClient.render_invoice_pdf(xml, standard, validate=True) POST webservicesp.anaf.ro/prod/FCTEL/rest/transformare/{std} (no auth, prod-only)
validate_signature(file, signature) POST /api/validate/signature

Implementation notes

  • Bodies are sent as Content-Type: text/plain with the XML as the raw body. The PDF mandates text/plain for validare/transformare; for upload it says nothing and the swagger declares the request body */*, so text/plain is a safe uniform choice, not a requirement.
  • Listing: list_messages is a single async iterator that pages listaMesajePaginatieFactura under the hood (days is converted to a [now-days, now] millisecond window; the non-paginated listaMesajeFactura (3a) is not used). It stops on the first empty page and honours the numar_total_pagini envelope field (confirmed by the lista swagger). ANAF returns the same eroare field for both "no messages in interval" and genuine errors — the former yields an empty iterator, the latter raises AnafResponseError (matched by wording; see is_empty_result_message; the official wordings are catalogued in §3).
  • download returns a ZIP (binary) — handle as bytes, unzip to the two XML members. A non-ZIP 200 body is an error note (§4.1): the recognised "past the 60-day window" wording raises AnafDownloadExpiredError (terminal — stop retrying that id), every other body a plain AnafResponseError.
  • validare/transformare are stateless, public no-auth, prod-only (see §5/§6), so they live on PublicClient (validate_invoice/render_invoice_pdf) — no Bearer header, no environment, no OAuth credentials needed.
  • The WAF (§6.1) is handled in two places: the shared transport raises AnafWafRejectionError for the block page (it is an HTTP 200, so nothing else would notice), and PublicClient strips xsi:schemaLocation before posting and retries a blocked render_invoice_pdf(validate=False) on the validating path.