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:listaMesajePaginatieFacturareturned 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 prelucrare→ok+id_descarcare) →descarcare(ZIP with signed invoice +semnaturaXML) → paginated list with results — all matching the shapes below. The same session confirmed thatvalidareandtransformareanswer HTTP 404 ontest(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 prod↔test 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 (stareMesaj → ok/nok).
Provenance: PDF pp. 1–5 (each operation lists both
webserviceaplcert mode andapi.anaf.rooauth2 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/nokare terminal;in prelucraredrives theupload_and_waitpoll loop.nokis a typed business outcome (not an exception); anErrors-only response raisesAnafResponseError.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'sdayspath computes the window a margin inside each limit (5 min atstart, 60 s atend) instead of exactly on it; an explicitstart/endis sent untouched. When a rejection quotesmomentul 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_messagesvalidates 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.MessageListItemkeepssender_cif/receiver_cifas optional aliases for the PDF-documented keys (wire wins if ANAF ever sends them); when absent it extracts them (best-effort) from the first twodetaliiwordings above, leavingNonefor wordings that carry no CIFs.Provenance: PDF pp. 2–4; envelope +
eroarecatalog +Mesajschema from the lista swagger (listamesaje.html);cif_emitent/cif_beneficiarabsence live-confirmed in production 2026-07-06; the clock-quoting form of theendTime-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 theDownloadedMessage— 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:downloadraisesAnafDownloadExpiredError(AnafResponseError)for this note, carryingmessage_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 JSONeroarefield 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 plainAnafResponseError. 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 —
;CPinside an address (Loc.Periam Nr. 868;CP 307315) matches the;cpsignature (;rmlikewise;;CP307315without 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:schemaLocationclears 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 anhttp://URL there. - The content-signature class (
;CP) cannot be defused without altering what the PDF displays — XML character references do not help ( 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
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/plainwith the XML as the raw body. The PDF mandatestext/plainforvalidare/transformare; foruploadit says nothing and the swagger declares the request body*/*, sotext/plainis a safe uniform choice, not a requirement. - Listing:
list_messagesis a single async iterator that pageslistaMesajePaginatieFacturaunder the hood (daysis converted to a[now-days, now]millisecond window; the non-paginatedlistaMesajeFactura(3a) is not used). It stops on the first empty page and honours thenumar_total_paginienvelope field (confirmed by the lista swagger). ANAF returns the sameeroarefield for both "no messages in interval" and genuine errors — the former yields an empty iterator, the latter raisesAnafResponseError(matched by wording; seeis_empty_result_message; the official wordings are catalogued in §3). downloadreturns 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 raisesAnafDownloadExpiredError(terminal — stop retrying that id), every other body a plainAnafResponseError.validare/transformareare stateless, public no-auth, prod-only (see §5/§6), so they live onPublicClient(validate_invoice/render_invoice_pdf) — no Bearer header, noenvironment, no OAuth credentials needed.- The WAF (§6.1) is handled in two places: the shared transport raises
AnafWafRejectionErrorfor the block page (it is an HTTP 200, so nothing else would notice), andPublicClientstripsxsi:schemaLocationbefore posting and retries a blockedrender_invoice_pdf(validate=False)on the validating path.