ANAF SPV E-Invoicing from Java/Spring Boot: OAuth2, JWT Access Tokens, and a Refresh Window That Never Rests
Integrating with ANAF's SPV API for Romanian e-invoicing has a personality: sparse documentation, error messages in Romanian, a login flow that needs a human with a digital certificate, and JWT access tokens that expire after roughly ten minutes. The OAuth2 machinery itself is textbook — authorization code flow with client authentication — but every quirk around it decides whether you ship in a day or debug for a week. Below is the flow exactly as it runs in production Spring Boot code, including an honest look at what the refresh logic actually does with ANAF's short-lived tokens.
The flow ANAF actually runs
ANAF SPV (Sistemul Privat Virtual) e-Factura is the mandatory channel for B2B e-invoices in Romania, and machine access is not a plain API key: it is OAuth2 authorization code, after which every REST call carries Authorization: Bearer <jwt>. Two details make it special. First, token_content_type=jwt must appear on the authorize request and on the token exchange — omit it and ANAF returns a legacy token format. Second, the "user" authentication happens in a browser, where a person logs in with their digital certificate; your backend only ever sees the one-time code, exchanged over HTTPS.
The full dance: your app redirects the operator to ANAF's login page → the operator authenticates with the certificate → ANAF redirects to your callback with ?code=... → your backend exchanges the code at the token endpoint and receives access_token, refresh_token, expires_in → every subsequent SPV call uses the JWT.
Configuration that drives everything
Real endpoints, straight from application.yml:
app:
anaf:
api-key: ${ANAF_API_KEY:}
api-url: ${ANAF_API_URL:https://api.anaf.ro/prod/FCTEL/rest}
oauth:
client-id: ${ANAF_CLIENT_ID:}
client-secret: ${ANAF_CLIENT_SECRET:}
authorization-url: ${ANAF_OAUTH_AUTHORIZATION_URL:https://logincert.anaf.ro/anaf-oauth2/v1/authorize}
token-url: ${ANAF_OAUTH_TOKEN_URL:https://logincert.anaf.ro/anaf-oauth2/v1/token}
callback-url: ${ANAF_OAUTH_CALLBACK_URL:https://api.fiscallink.io/v1/auth/anaf/callback}
These land in AnafTokenManager as @Value fields, and every lookup applies the same precedence rule: database settings first, environment variables second — firstNonBlank(s.getAnafClientId(), envClientId). Operators can reconnect a new ANAF account without a redeploy. Note the two hosts: logincert.anaf.ro is the OAuth server, api.anaf.ro serves the SPV data endpoints and the test hello endpoint.
Step 1 — build the authorize URL
AnafAuthController exposes GET /v1/auth/anaf/authorize: hit it from a browser (Accept: text/html) and it 302-redirects you to ANAF; call it from an API client and it returns the URL as JSON. The URL is assembled in AnafTokenManager.getAuthorizationUrl(state):
StringBuilder url = new StringBuilder(authorizationUrl)
.append("?response_type=code")
.append("&client_id=").append(enc(clientId))
.append("&redirect_uri=").append(enc(callbackUrl))
.append("&token_content_type=jwt");
if (state != null && !state.isBlank()) url.append("&state=").append(enc(state));
return url.toString();
token_content_type=jwt is already in the query string, and callbackUrl is a single configuration value used everywhere — it must match byte-for-byte the redirect URI registered for your client in ANAF SPV. Scheme, host, path: one character off and the login dies with a redirect URI mismatch. The controller also generates CSRF state (UUID.randomUUID().toString()) and logs it — but see the pitfalls: this callback accepts state without ever verifying it, so add that check in yours.
Step 2 — exchange the code
ANAF redirects the operator's browser to GET /v1/auth/anaf/callback?code=...&state=.... The controller hands the code to exchangeCodeForToken(code), which posts to the token endpoint using HTTP Basic client authentication and a form-urlencoded body:
String formBody = "grant_type=authorization_code"
+ "&code=" + enc(code)
+ "&redirect_uri=" + enc(callbackUrl)
+ "&token_content_type=jwt";
String response = restClient.post()
.uri(tokenUrl)
.header(HttpHeaders.AUTHORIZATION, basicAuth(clientId, clientSecret))
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(formBody)
.retrieve()
.body(String.class);
parseAndStore(s, response);
basicAuth() is just base64 of clientId + ":" + clientSecret — client credentials travel in the Authorization: Basic header, not in the body, a classic gotcha if you are used to other providers. redirect_uri here is the identical string sent on authorize, and token_content_type=jwt is mandatory again. The code is single-use: exchange it immediately and never log it.
Step 3 — store what ANAF returns
parseAndStore reads the token JSON and persists it into the AppSettings row (id "default"):
JsonNode json = objectMapper.readTree(jsonResponse);
String accessToken = json.path("access_token").asText(null);
String refreshToken = json.path("refresh_token").asText(null);
long expiresIn = json.path("expires_in").asLong(3600);
// ...
s.setAnafApiKey(accessToken);
if (refreshToken != null && !refreshToken.isBlank()) s.setAnafRefreshToken(refreshToken);
s.setAnafTokenExpiresAt(Instant.now().plusSeconds(expiresIn));
return appSettingsRepository.save(s);
Two bookkeeping fields drive everything later: the refresh token and the expiry instant. If expires_in is missing the fallback is 3600 seconds; when a token is pasted manually via POST /v1/auth/anaf/token (storeToken), the fallback is 600 seconds — the same order of magnitude as ANAF's real JWT lifetime, which is the number that matters next.
Step 4 — getValidAccessToken, or the refresh window that never rests
Every SPV call funnels through AnafTokenManager.getValidAccessToken():
@Transactional
public String getValidAccessToken() {
AppSettings s = getOrCreate();
if (s.getAnafRefreshToken() == null || s.getAnafRefreshToken().isBlank()) {
return firstNonBlank(s.getAnafApiKey(), envAnafApiKey);
}
if (shouldRefresh(s)) {
try {
s = performRefresh(s);
} catch (Exception e) {
log.warn("ANAF token refresh failed — continuing with current token: {}", e.getMessage());
}
}
return firstNonBlank(s.getAnafApiKey(), envAnafApiKey);
}
private boolean shouldRefresh(AppSettings s) {
if (s.getAnafTokenExpiresAt() == null) return false;
return Instant.now().plusSeconds(REFRESH_WINDOW_SECONDS).isAfter(s.getAnafTokenExpiresAt());
}
with private static final long REFRESH_WINDOW_SECONDS = 86_400L; — the class comment says tokens are "auto-refreshed 24h before expiry". Be honest about what that means against ANAF's real token lifetime: the check refreshes whenever the token expires within the next 86,400 seconds, and a JWT that lives ~600–3600 seconds is always inside that window. So with a refresh token configured, every call to getValidAccessToken() triggers performRefresh() first:
String formBody = "grant_type=refresh_token"
+ "&refresh_token=" + enc(s.getAnafRefreshToken())
+ "&token_content_type=jwt";
// POST to the same token URL, same HTTP Basic header, then parseAndStore(...)
(no redirect_uri on the refresh grant — ANAF does not expect it there). That 24-hour window makes sense for a provider issuing 24h+ tokens, which ANAF is not; as implemented, the refresh never "rests": each SPV call first pays for a token-exchange round-trip, then executes the real request with a fresh JWT. Consequences to plan for: added latency per call, and no in-memory caching of the access token — every call re-reads AppSettings. If your traffic is bursty, add a small in-memory cache keyed by the stored expiry and refresh at most once per minute, keeping this class the single source of truth. And when a refresh fails, the code logs a warning and continues with the existing token; the next SPV call then fails with HTTP 401, which AnafClient surfaces as a result, not an exception.
Concurrency: transactional, single instance, last-write-wins
getValidAccessToken(), exchangeCodeForToken(), and storeToken() are @Transactional, and there is no token state shared between threads — every call re-reads the database row. On one instance, two concurrent requests can both observe "should refresh" and both hit the token endpoint; each exchange saves its result and the last write wins. If ANAF invalidates a refresh token after use, the losing thread's next refresh fails with invalid_grant — swallowed by the catch-and-continue path, then recovered on the next successful refresh. In practice: keep refresh cheap, run a single instance for the ANAF worker (or add single-flight locking around refresh), and treat the token store as shared state you serialize.
There is also a deliberate dev-skip convention: when nothing is configured, getValidAccessToken() returns null, and AnafClient answers SubmissionResult.devSkip() — a synthetic success carrying the id "DEV-SKIP" — so local development never touches ANAF. No token configured, no network call, no crash.
Testing your token: the hello endpoint
ANAF publishes a smoke-test endpoint, separate from the data API: GET https://api.anaf.ro/TestOauth/jaxrs/hello?name=... with your Bearer token. testConnection() wraps exactly that:
String body = restClient.get()
.uri("https://api.anaf.ro/TestOauth/jaxrs/hello?name=fiscallink")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.retrieve()
.body(String.class);
result.put("connected", true);
result.put("response", body);
Exposed through GET /v1/auth/anaf/status, it is the fastest way to tell "token expired or wrong client" (HTTP 401) apart from "ANAF is down" (timeout, 5xx, or a WAF page). POST /v1/auth/anaf/token even runs this check before answering, so manual injections are verified immediately.
Then the real calls
AnafClient is the single integration point for SPV: it takes the Bearer token from the manager, never throws, and returns result records — SubmissionResult, MessageListResult, AnswerDownloadResult — that callers inspect via success() / errorMessage():
String token = tokenManager.getValidAccessToken();
if (token == null || token.isBlank()) {
log.warn("ANAF API key not configured — skipping submission for VAT {} (dev mode)", vatNumber);
return SubmissionResult.devSkip();
}
// ...
String response = restClient.post()
.uri(uri.toString()) // /upload?standard=UBL&cif=...
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.contentType(MediaType.TEXT_PLAIN) // raw UBL XML — not multipart, not application/xml
.body(xmlToSend)
.retrieve()
.body(String.class);
Submission is POST {api-url}/upload?standard=UBL&cif=RO12345607[&extern=DA][&autofactura=DA] with the raw UBL 2.1 XML as text/plain (the xsi:schemaLocation is stripped first — ANAF is picky). The response is XML, and acceptance means ExecutionStatus="0" plus an index_incarcare id; business rejections arrive as HTTP 200 with an errorMessage attribute. Answers are polled with GET /listaMesajeFactura?zile=...&cif=...&filtru=E|T|P|R, falling back automatically to paginated GET /listaMesajePaginatieFactura?startTime=<epochMillis>&endTime=...&cif=...&pagina=..., and downloaded from GET /descarcare?id=... as a ZIP (the code checks the 0x50 0x4B magic bytes before trusting it). Error payloads come back in Romanian — "Lista de mesaje este mai mare...", "nu exista mesaje", "Pagina solicitata ... este mai mare" — matched by string and mapped onto structured results. HTTP 429 and 5xx are flagged retryable so the job layer backs off; business rejections are not retried.
Pitfalls checklist
-
token_content_type=jwton authorize and on the code exchange and on refresh. Forgetting it changes the token format ANAF hands you. -
redirect_urimust equal the ANAF-registered URI exactly and stay identical across authorize and exchange; keep it in one config value. - ANAF JWTs are short-lived (~10 minutes). Verify the CSRF
stateon the callback — this codebase logs it but does not check it; yours should. - Never log the
code, tokens, orAuthorizationheaders. Even error paths should trim raw token responses — the "missing access_token" exception embeds the whole response body, so truncate it in production. - Expect HTML sometimes: ANAF sits behind a WAF. Detect pages containing
Your support ID is:and treat them as infrastructure errors, not business answers. - Timeouts matter: connect 10 s, read 30 s (
app.anaf.connect-timeout-ms,app.anaf.read-timeout-ms); slow days at ANAF are real. - Answer downloads are ZIPs — validate the PK magic before unzipping anything.
- One-time codes: exchange immediately, never retry with the same
code. - A static
ANAF_API_KEYfallback is a rotation dead-end: without a refresh token, nothing ever auto-refreshes. Prefer the full OAuth flow.
The token manager and client above are the same code path that files real e-Factura invoices every day in FiscalLink's production ANAF integration — quirks included.
Top comments (0)