DEV Community

Vlad Cristian Alexa
Vlad Cristian Alexa

Posted on

ANAF SPV E-Invoicing from Java/Spring Boot: OAuth2, JWT Access Tokens, and a Refresh Window That Never Rests

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}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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(...)
Enter fullscreen mode Exit fullscreen mode

(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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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=jwt on authorize and on the code exchange and on refresh. Forgetting it changes the token format ANAF hands you.
  • redirect_uri must 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 state on the callback — this codebase logs it but does not check it; yours should.
  • Never log the code, tokens, or Authorization headers. 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_KEY fallback 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)