Skip to content

SPV API

The SPV client, session auth layer, bootstrap, certificate discovery, and value types.

anafpy.spv.client

Async client for the SPV web services (webserviced.anaf.ro/SPVWS2/rest).

Read-only by design: list inbox messages (listaMesaje), download documents (descarcare), request official reports (cerere) and wait for their asynchronous delivery. Declaration submission is out of scope.

Transport model (see docs/anaf-reference/spv/api.md §1.1): the certificate is involved only in the interactive session bootstrap (:class:~anafpy.spv.bootstrap.CurlBootstrapper — drive it via :meth:SpvClient.login); every request here is plain httpx riding the APM cookies. The cookie credential lives in :mod:anafpy.spv.auth — construct the client with a :class:~anafpy.spv.auth.SpvSessionProvider, mirroring how the OAuth clients take a TokenProvider. The :class:~anafpy.spv.auth.SpvAuth flow attaches the cookies, follows the APM's occasional /my.policy_nonce revalidation bounces transparently, and raises :class:~anafpy.exceptions.AnafAuthError on a redirect to bare /my.policy (the client never re-runs the bootstrap on its own: that fires the owner's 2FA, which must stay a deliberate act).

Unlike the OAuth clients' no-retry stance, the SPV reads (listaMesaje, descarcare) retry transient network failures with exponential backoff and jitter — every SPV operation is an idempotent GET, ANAF documents no rate limits, and the polling flows would otherwise surface every blip. cerere stays single-shot (it creates server-side work); dedupe of repeated requests is deliberately a caller concern — the MCP layer guards agent loops.

SpvClient

SpvClient(provider: SpvSessionProvider, *, http: AsyncClient | None = None, timeout: float = 60.0)

Bases: HttpClientBase

Talks to a taxpayer's SPV over an established APM cookie session.

Construct with a :class:~anafpy.spv.auth.SpvSessionProvider over the session store an earlier :meth:login filled. The client owns an httpx.AsyncClient (unless one is injected — it must then carry :class:~anafpy.spv.auth.SpvAuth itself and a non-empty base_url; an empty one raises :class:~anafpy.exceptions.AnafConfigError, since injected clients are never mutated) and should be used as an async context manager. Cookie rotations are saved back to the store.

login async

login() -> SpvSession

Establish a fresh APM session (delegates to the provider).

Interactive: the certificate middleware's PIN/2FA prompt fires. The new session is saved to the provider's store, where the next request picks it up.

Raises:

Type Description
AnafConfigError

the provider was built without a bootstrapper.

AnafAuthError

the handshake failed or timed out (retryable — the bootstrap is intermittently flaky; the prompt fires again).

list_messages async

list_messages(days: int, *, cif: str | None = None) -> MessageList

Inbox messages that arrived in the last days days (listaMesaje).

cif narrows to one CUI/CNP; by default ANAF returns every message the certificate has rights for. Besides the messages, the result carries the certificate's authorization inventory (:attr:~anafpy.spv.models.MessageList.authorized_cuis).

Raises:

Type Description
AnafConfigError

days is not positive (raised locally).

AnafAuthError

no session, or it expired (log in again).

AnafResponseError

ANAF reported a genuine error; a benign "no messages in the window" note yields empty messages with the wording in note instead.

download_document async

download_document(message_id: str) -> SpvDocument

Download one inbox document (descarcare) — PDF bytes.

Raises:

Type Description
AnafResponseError

ANAF answered with an eroare payload (e.g. no right to this message) or an unrecognised body.

request_report async

request_report(request: ReportRequest) -> ReportRequestResult

File a report request (cerere); the report arrives asynchronously.

The returned request_id will show up as an inbox message's request_id once the report is generated — poll with :meth:wait_for_report or match it in :meth:list_messages output.

This method deliberately does no transport retry — one call, one result-or-raise — and no dedupe: every call files a request with ANAF (a repeated cerere is harmless but yields a second inbox message). Callers that might repeat themselves (agent loops) own their dedupe — the MCP layer is where that guard lives.

Raises:

Type Description
AnafAuthError

no session, or it expired.

AnafResponseError

ANAF refused the request (no rights, invalid CUI, ...) — verbatim Romanian plus an English hint.

wait_for_report async

wait_for_report(request_id: str, *, cif: str | None = None, days: int = 7, timeout: float = 600.0, initial_wait: float = 15.0, max_wait: float = 120.0) -> SpvDocument

Poll the inbox until the report for request_id lands, then download.

Polls :meth:list_messages (a days-wide window, optionally narrowed by cif) with growing intervals — generous by design, ANAF documents no SLA. Raises :class:TimeoutError when the budget runs out; the request itself stays valid, so retrying later with the same request_id is always safe.

anafpy.spv.auth

SPV session lifecycle: hand out the APM cookie set, persisting rotations.

The SPV analog of :mod:anafpy.auth.provider. The per-request credential is not the certificate (that participates only in the interactive login bootstrap — see :mod:anafpy.spv.bootstrap) but the F5 APM cookie session it mints: a bearer credential, exactly like an OAuth access token. So the same two-piece shape applies:

  • :class:SpvSessionProvider mirrors TokenProvider — the :class:~anafpy.spv.session.SessionStore is the single source of truth, and the provider also owns the deliberate :meth:~SpvSessionProvider.login.
  • :class:SpvAuth mirrors AnafAuth — an httpx.Auth flow that attaches the cookies, follows the APM's mid-session /my.policy_nonce revalidation hops (re-yielding requests, the same mechanism AnafAuth uses for its 401 retry), and persists rotated cookies back through the provider.

The one deliberate asymmetry: AnafAuth refreshes on a 401, but :class:SpvAuth has no recovery leg. Re-establishing an SPV session means re-running the certificate bootstrap, which fires the owner's PIN/2FA — that must stay a deliberate act, so a bounce to the APM login wall raises :class:~anafpy.exceptions.AnafAuthError, full stop. Do not "complete" the symmetry by calling :meth:SpvSessionProvider.login from the auth flow.

SpvSessionProvider

SpvSessionProvider(*, store: SessionStore, bootstrapper: SessionBootstrapper | None = None)

Provides the current APM cookie set, persisting rotations and owning login.

The SessionStore is the single source of truth: the provider keeps no session state of its own. Every operation reads the freshest persisted set under the lock, so a login performed by another process sharing the store (the CLI, a second server) is picked up on the next call.

session property

session: SpvSession | None

The persisted session, if any (read-only snapshot).

cookies async

cookies() -> dict[str, str]

The stored session's cookie set.

Raises:

Type Description
AnafAuthError

no session is stored — log in first.

rotated async

rotated(cookies: dict[str, str], *, seen: dict[str, str]) -> None

Persist a cookie set the APM rotated mid-flight (save-if-changed).

seen is the set the request started from. The save happens only while the store still holds it: if another process replaced the session mid-flight (a fresh login), this rotation belongs to the dead session and must not clobber the new one (last-writer-wins would).

login async

login() -> SpvSession

Establish a fresh APM session via the configured bootstrapper.

Interactive: the certificate middleware's PIN/2FA prompt fires. The new session replaces whatever the store held.

Raises:

Type Description
AnafConfigError

the provider was built without a bootstrapper.

AnafAuthError

the handshake failed or timed out (retryable — the bootstrap is intermittently flaky; the prompt fires again).

SpvAuth

SpvAuth(provider: SpvSessionProvider)

Bases: Auth

httpx auth flow: attach the APM cookies, follow revalidation hops.

Every request gets the stored cookie set; a redirect to /my.policy_nonce is followed transparently (folding in rotated cookies), a bounce to the bare login wall raises :class:~anafpy.exceptions.AnafAuthError, and an off-host redirect is refused. Rotated cookies are saved back through the provider.

The client must send with follow_redirects=False so this flow sees the raw 302s instead of httpx silently following them onto the login wall.

anafpy.spv.bootstrap

SPV session bootstrap — the one step that needs the qualified certificate.

M0 established (see docs/anaf-reference/spv/api.md §1.1) that SPV's mTLS is an F5 APM login choreography, not a per-request transport: one redirect chain through /my.policy performs the client-certificate TLS renegotiation (firing the token PIN / cloud-HSM 2FA), and everything afterwards rides the resulting cookies. So the platform-specific seam is a :class:SessionBootstrapper that produces an :class:~.session.SpvSession — not a request transport.

:class:CurlBootstrapper drives the OS-shipped curl in a subprocess:

  • macOS — /usr/bin/curl with the SecureTransport backend, which takes the identity by Keychain name and, unlike Apple's own NSURLSession (which hangs, verified 2026-07-12), survives the mid-connection renegotiation.
  • Windows — System32\curl.exe (Schannel build) with the CurrentUser\MY\<thumbprint> cert-store syntax.

Python-level alternatives were evaluated and rejected: CPython's ssl only loads private keys from files, so no httpx/requests-based stack can sign with a non-exportable platform-store key.

The bootstrap is interactive (the 2FA prompt fires every time — the middleware caches authorizations only for minutes) and intermittently flaky, so it is bounded by a timeout and surfaces actionable errors instead of hanging.

The platform machinery (curl resolution, cert selectors, TLS-backend pin, subprocess runner, failure taxonomy) lives in :mod:anafpy._transport.curl, shared with the declaration portal bootstrap (:class:anafpy.declaratii.upload.PortalCurlBootstrapper) — only the SPV choreography (the one-probe redirect chain) and its success judgment (the SPV JSON) live here.

SessionBootstrapper

Bases: Protocol

Performs the certificate handshake and returns the APM cookie session.

CurlBootstrapper

CurlBootstrapper(identity: str, *, timeout: float = 240.0, curl_path: str | None = None, platform: str | None = None)

Bases: CurlBootstrapperBase

Establishes an SPV session via the platform curl and its native key store.

The choreography is a single probe: one --location chain through /my.policy (the renegotiation fires the 2FA) that must land on the SPV listaMesaje JSON. Constructor arguments are the shared base's (:class:~anafpy._transport.curl.CurlBootstrapperBase): identity — the Keychain identity name on macOS (see :func:~anafpy.spv.certs.list_keychain_identities), the SHA-1 thumbprint in CurrentUser\MY on Windows — plus timeout, curl_path, and platform.

command

command(jar_path: str) -> list[str]

The curl argv for the bootstrap redirect chain.

bootstrap async

bootstrap() -> SpvSession

Run the handshake and return the authenticated session.

Success is judged by the payload, not curl's exit code: ANAF's F5 closes the final connection without a TLS close_notify, so a fully successful bootstrap still exits 56 (SSLRead() error -9806, live-observed 2026-07-12). If the body is the SPV JSON and the jar holds the APM session cookie, the handshake worked.

Raises:

Type Description
AnafAuthError

the handshake timed out (usually an unanswered or stalled 2FA), curl failed, or ANAF answered with something other than the SPV JSON (certificate without SPV rights, APM hangup).

parse_netscape_cookies

parse_netscape_cookies(text: str) -> dict[str, str]

Cookie name -> value from a curl-written Netscape cookie jar.

Hand-rolled because stdlib MozillaCookieJar silently skips the #HttpOnly_ lines curl emits for HttpOnly cookies.

anafpy.spv.session

SPV session state and its persistence.

An SPV "session" is the F5 BIG-IP APM cookie set (MRHSession & friends) minted by the one certificate-authenticated handshake at /my.policy — see docs/anaf-reference/spv/api.md §1.1. After that handshake every request rides the cookies, so the cookie set is a bearer credential to the taxpayer's SPV and gets the same custody treatment as the OAuth tokens: a :class:SessionStore protocol with an atomic, 0o600 JSON file backend.

The APM rotates MRHSession mid-session (revalidation hops), so the client re-saves the session whenever the cookie values change.

SpvSession

Bases: BaseModel

The APM cookie set plus when it was established.

cookies maps cookie name to value for the webserviced.anaf.ro domain (the only host SPV lives on — domain/path carry no information worth persisting). established_at is when the certificate handshake ran; the APM idle timeout is not published, so staleness is discovered by the first request bouncing to /my.policy, not predicted from this timestamp.

is_authenticated_shape property

is_authenticated_shape: bool

Whether the cookie set contains the APM session cookie at all.

SessionStore

Bases: Protocol

Where SPV sessions are loaded from and saved to.

MemorySessionStore

MemorySessionStore(session: SpvSession | None = None)

In-process session store (tests, ephemeral runtimes).

FileSessionStore

FileSessionStore(path: str | PathLike[str] = DEFAULT_SESSION_PATH)

JSON-file session store, written atomically with 0o600 permissions.

load

load() -> SpvSession | None

The stored session, or None when no store file exists.

Raises:

Type Description
AnafConfigError

the file exists but cannot be read or parsed — stay in the AnafError hierarchy instead of leaking a pydantic/OS error.

clear

clear() -> None

Delete the store file; a no-op when none exists.

Raises:

Type Description
AnafConfigError

the file exists but cannot be removed.

anafpy.spv.certs

Qualified-certificate discovery in the platform key stores.

Both platforms enumerate identities the OS considers usable for TLS client auth — certificate + private key present, clientAuth EKU, not expired — including token/cloud-HSM ones surfaced through their middleware (SafeNet, certSIGN vToken, ...):

  • macOS — security find-identity -v -p ssl-client (file-based Keychain identities) merged with sc_auth identities (smartcard/CryptoTokenKit identities — USB tokens and cloud vTokens; find-identity cannot see these at all, live-verified 2026-07-12 with a certSIGN vToken);
  • Windows — a PowerShell one-liner over Cert:\CurrentUser\My.

The two macOS tools hash differently (find-identity prints the certificate's SHA-1; sc_auth prints its own identity hash), so treat sha1_thumbprint as an opaque selector that is only compared against the same discovery output — which is all :func:identity_by_thumbprint does.

:attr:StoreIdentity.bootstrap_identity is what :class:~anafpy.spv.bootstrap.CurlBootstrapper needs: the Keychain name on macOS (curl's SecureTransport selects by name), the SHA-1 thumbprint on Windows (Schannel's cert-store syntax). The thumbprint is the stable identifier worth persisting — names can collide, e.g. after a renewal, which :func:identity_by_thumbprint refuses on macOS where curl would pick blindly.

The user's chosen certificate is persisted as a small JSON file (:func:save_selected_identity / :func:load_selected_identity) — a thumbprint is an identifier, not a secret, so a plain config file is fine.

StoreIdentity

Bases: BaseModel

One usable TLS-client identity in a platform key store.

bootstrap_identity property

bootstrap_identity: str

The --cert selector for :class:~anafpy.spv.bootstrap.CurlBootstrapper: the Keychain name on macOS, the thumbprint on Windows.

SelectedIdentity

Bases: BaseModel

The persisted certificate choice (spv_select_certificate / CLI).

discover_identities

discover_identities() -> list[StoreIdentity]

Usable TLS-client identities in this platform's key store.

Raises:

Type Description
AnafConfigError

unsupported platform, or the platform tool failed.

list_keychain_identities

list_keychain_identities() -> list[StoreIdentity]

Valid TLS-client identities on macOS: Keychain and smartcard/CTK.

security find-identity only sees file-based keychains; the token-backed identities that actually hold qualified certificates come from sc_auth.

Raises:

Type Description
AnafConfigError

the security tool failed or is unavailable (sc_auth failures are tolerated — no smartcard stack is fine).

parse_find_identity_output

parse_find_identity_output(stdout: str) -> list[StoreIdentity]

Parse security find-identity -v output (separated for testability).

parse_sc_auth_output

parse_sc_auth_output(stdout: str) -> list[StoreIdentity]

Parse sc_auth identities output (separated for testability).

The output mixes headers (SmartCard: ..., Unpaired identities:) with <40-hex-hash>\t<name> identity lines; only the latter match.

list_windows_identities

list_windows_identities() -> list[StoreIdentity]

Valid client-auth identities in Cert:\CurrentUser\My.

Raises:

Type Description
AnafConfigError

PowerShell failed or emitted an unrecognisable shape.

parse_windows_identities

parse_windows_identities(json_text: str) -> list[StoreIdentity]

Parse the discovery script's JSON output (separated for testability).

identity_by_thumbprint

identity_by_thumbprint(thumbprint: str) -> StoreIdentity

Resolve a thumbprint to its identity in this platform's store.

Raises:

Type Description
AnafConfigError

no identity with that thumbprint; or (macOS only) its name is shared by another identity — curl selects by name there, so an ambiguous name could silently pick the wrong certificate.

load_selected_identity

load_selected_identity(path: str | PathLike[str] = DEFAULT_IDENTITY_PATH) -> SelectedIdentity | None

The persisted certificate selection, or None when none was made.

Raises:

Type Description
AnafConfigError

the file exists but cannot be read or parsed.

save_selected_identity

save_selected_identity(identity: StoreIdentity | SelectedIdentity, path: str | PathLike[str] = DEFAULT_IDENTITY_PATH) -> SelectedIdentity

Persist the certificate choice; returns what was saved.

anafpy.spv.models

Value types for the SPV web services (webserviced.anaf.ro/SPVWS2/rest).

Everything here mirrors the vendored ClientSPV documentation (docs/anaf-reference/spv/api.md): the inbox message shapes (§2), the cerere report nomenclature with its per-type parameter requirements (§4), and the fixed motiv list for income certificates (§4.2). Per the repo's hybrid error model, these are values; the Romanian eroare texts become :class:~anafpy.exceptions.AnafResponseError in the client, decorated with the English hints from :func:english_error_hint.

tip is an open string on the wire (live-observed values include a trailing space: "DECLARATIE "), so :class:SpvMessage keeps it verbatim and offers :attr:SpvMessage.kind for trimmed comparisons.

SpvMessage

Bases: BaseModel

One inbox message from listaMesaje.

kind property

kind: str

type_ normalised for comparison (trimmed — live-observed values carry trailing spaces).

SpvEnvelope

Bases: BaseModel

The identity stamp ANAF puts on every authenticated SPV response.

cnp and certificate_serial identify the certificate the session was established with; title is the response's titlu. All optional: the benign no-results listaMesaje shape carries no identity fields at all.

MessageList

Bases: SpvEnvelope

Outcome of listaMesaje.

authorized_cuis (the wire's comma-separated cui) is the certificate's authorization inventory — every CUI/CNP it may query; listaMesaje is the only endpoint that returns it. A benign "no messages in the window" answer yields empty messages with the note kept in note.

SpvDocument

Bases: BaseModel

A document downloaded via descarcare — usually a PDF.

ReportRequestResult

Bases: SpvEnvelope

Outcome of cerere — the request was accepted, not answered.

The report itself arrives asynchronously as an inbox message whose request_id equals this request_id; download it via descarcare. (cerere echoes the envelope's cnp/serial but, unlike listaMesaje, no cui authorization list.)

ReportType

Bases: DescribedStrEnum

tip values accepted by cerere (vendored README, verbatim casing).

Members are named after ANAF's own report names, per the repo convention for ANAF-code enums, and are declared (value, description) — the stdlib enum-with-attributes pattern. :attr:description is a one-line English rendering of the README's per-type explanation (api.md §4.1) so a caller choosing a report — the MCP model in particular — sees what each one is, not just the bare declaration code; timing and natural-/legal- person scope are folded in where the README states them, and parameter names are avoided (the library and the MCP tool spell them differently). A member declared without a description fails at import time. CAF is deliberately absent — the README states it is not yet requestable via the web service.

ReportRequest

Bases: BaseModel

A validated cerere — invalid parameter combinations fail here, before any wire call.

Field names are English; :meth:wire_params produces ANAF's Romanian query names per :data:REPORT_PARAMETER_WIRE_NAMES (an, luna, motiv, numar_inregistrare, cui_pui, lunai/lunas).

wire_params

wire_params() -> dict[str, str]

The cerere query parameters, in ANAF's wire names.

required_parameters

required_parameters(type_: ReportType) -> tuple[str, ...]

The parameters a :class:ReportRequest must carry for type_ (model-field names, not wire names).

Groups per the vendored README's example calls (api.md §4.1 — the groupings are inferred from the examples; ANAF's own validation stays the authority on any discrepancy). The match is exhaustive over :class:ReportType, so mypy --strict flags an unclassified new member as a missing return.

optional_parameters

optional_parameters(type_: ReportType) -> tuple[str, ...]

The parameters type_ accepts but does not require (model-field names) — today only Fisa Rol's optional branch_cui.

english_error_hint

english_error_hint(eroare: str) -> str | None

Best-effort English hint for a Romanian SPV eroare text, or None.

The verbatim Romanian text stays authoritative and is always surfaced alongside; this only orients non-Romanian-speaking callers.