Available Hire Me
← All Writing Betfair

API Authentication — Certificate Login and Session Management in Java

How Betfair's non-interactive certificate login works, how to implement it in Java, and how to manage the session token lifecycle correctly in an automated system.

Betfair’s API has two authentication paths: interactive login (username, password, two-factor code via the browser-based SSO) and non-interactive login (certificate-based, for automated systems). If you’re building a trading bot or any system that runs unattended, you need the non-interactive path. The certificate login mechanism is well-documented but the implementation details — particularly around session token lifecycle management in a running Java application — have enough edge cases to trip you up.

How Certificate Login Works

Instead of username and password, you authenticate with:

  1. A client SSL certificate issued by Betfair (a .crt file and a private key .pem file, combined into a .p12 or loaded into a KeyStore).
  2. Your API application key (not your personal key — the “delayed data” key won’t work for live trading; you need the live app key from your Betfair developer account).
  3. Your username and password, sent as POST parameters alongside the certificate in a mutually authenticated TLS handshake.

Betfair’s identity endpoint (https://identitysso-cert.betfair.com/api/certlogin) performs the mutual TLS handshake using your certificate, validates your credentials, and returns a sessionToken if everything checks out. That token is what you include in every subsequent API call via the X-Authentication header.

Loading the Certificate

Betfair provides the client certificate as a .p12 (PKCS12) file. Load it into a KeyStore and wire it into an SSLContext:

public SSLContext buildSslContext(
        String p12Path,
        String p12Password) throws Exception {

    KeyStore keyStore = KeyStore.getInstance("PKCS12");
    try (InputStream in = new FileInputStream(p12Path)) {
        keyStore.load(in, p12Password.toCharArray());
    }

    KeyManagerFactory kmf = KeyManagerFactory.getInstance(
        KeyManagerFactory.getDefaultAlgorithm());
    kmf.init(keyStore, p12Password.toCharArray());

    SSLContext sslContext = SSLContext.getInstance("TLS");
    sslContext.init(kmf.getKeyManagers(), null, null);

    return sslContext;
}

Never hardcode p12Path or p12Password in source code. Load them from environment variables or AWS Secrets Manager.

Performing the Login Request

With the SSLContext in hand, make the POST request to Betfair’s identity endpoint:

public String login(
        SSLContext sslContext,
        String username,
        String password,
        String appKey) throws Exception {

    HttpClient httpClient = HttpClient.newBuilder()
        .sslContext(sslContext)
        .connectTimeout(Duration.ofSeconds(10))
        .build();

    String body = "username=" + URLEncoder.encode(username, StandardCharsets.UTF_8)
        + "&password=" + URLEncoder.encode(password, StandardCharsets.UTF_8);

    HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create("https://identitysso-cert.betfair.com/api/certlogin"))
        .header("Content-Type", "application/x-www-form-urlencoded")
        .header("X-Application", appKey)
        .POST(HttpRequest.BodyPublishers.ofString(body))
        .build();

    HttpResponse<String> response = httpClient.send(
        request, HttpResponse.BodyHandlers.ofString());

    if (response.statusCode() != 200) {
        throw new AuthenticationException(
            "Login failed with status " + response.statusCode());
    }

    LoginResponse loginResponse = objectMapper.readValue(
        response.body(), LoginResponse.class);

    if (!"SUCCESS".equals(loginResponse.loginStatus())) {
        throw new AuthenticationException(
            "Login rejected: " + loginResponse.loginStatus());
    }

    return loginResponse.sessionToken();
}

public record LoginResponse(String sessionToken, String loginStatus) {}

The loginStatus field is worth checking explicitly — even a 200 response can indicate failure (e.g. INVALID_USERNAME_OR_PASSWORD, LOGIN_RESTRICTED, CERT_AUTH_REQUIRED).

Session Token Lifecycle

A session token is valid for four hours. After four hours, the token expires and every API call returns an ANGX-0003 error. You need a strategy for keeping the token current.

The naive approach — re-login on every API call if you see an auth error — works but causes a thundering-herd problem if multiple threads hit the expiry simultaneously. The cleaner approach is proactive refresh:

@Component
public class BetfairSessionManager {

    private static final Duration TOKEN_VALIDITY   = Duration.ofHours(4);
    private static final Duration REFRESH_BEFORE   = Duration.ofMinutes(15);

    private volatile String  sessionToken;
    private volatile Instant tokenExpiry;
    private final    Object  refreshLock = new Object();

    private final BetfairAuthClient authClient;

    public String getSessionToken() {
        if (needsRefresh()) {
            refreshToken();
        }
        return sessionToken;
    }

    private boolean needsRefresh() {
        return sessionToken == null
            || Instant.now().isAfter(tokenExpiry.minus(REFRESH_BEFORE));
    }

    private void refreshToken() {
        synchronized (refreshLock) {
            if (!needsRefresh()) return;  // another thread already refreshed

            log.info("Refreshing Betfair session token");
            sessionToken = authClient.login();
            tokenExpiry  = Instant.now().plus(TOKEN_VALIDITY);
            log.info("Session token refreshed, expires at {}", tokenExpiry);
        }
    }
}

The double-checked lock inside refreshToken() ensures that if multiple threads reach the expiry boundary simultaneously, only one performs the login — the rest wait and then use the freshly minted token.

Schedule a keep-alive to proactively refresh every three hours rather than relying solely on the expiry check:

@Scheduled(fixedDelay = 3, timeUnit = TimeUnit.HOURS)
public void keepAlive() {
    try {
        sessionManager.refreshToken();
    } catch (AuthenticationException e) {
        log.error("Failed to refresh session token — alerting", e);
        alertService.raiseAuthFailure(e);
    }
}

Auth failures in a live trading system are critical. The scheduled refresh failing silently would mean the next order placement fails with an auth error. Alert on it.

Using the Token in API Calls

Every call to the Betfair Exchange API and Sports Betting API requires two headers:

public HttpRequest buildApiRequest(String endpoint, String body) {
    return HttpRequest.newBuilder()
        .uri(URI.create("https://api.betfair.com/exchange/betting/json-rpc/v1" + endpoint))
        .header("Content-Type",  "application/json")
        .header("Accept",        "application/json")
        .header("X-Application", appKey)
        .header("X-Authentication", sessionManager.getSessionToken())
        .POST(HttpRequest.BodyPublishers.ofString(body))
        .build();
}

X-Application is your app key. X-Authentication is the session token. Both are required. A common mistake is using the wrong app key — using the “delayed data” key for live API calls returns market data with a 60-second delay, which is disastrous for in-play trading.

Handling Auth Errors at the Call Site

Even with proactive refresh, you can encounter auth errors if the token is revoked externally (Betfair account settings change, concurrent session limit reached, or a network issue during refresh). Handle it at the call site:

public <T> T executeWithRetry(ApiCall<T> call) {
    try {
        return call.execute(sessionManager.getSessionToken());
    } catch (BetfairAuthException e) {
        log.warn("Auth error on API call, forcing token refresh");
        sessionManager.forceRefresh();
        return call.execute(sessionManager.getSessionToken());
    }
}

Retry once after a forced refresh. If the second attempt also fails with an auth error, propagate it — there is a genuine account problem that cannot be resolved by retrying.

Secrets Management

Your certificate file and credentials must not live in the application classpath or be committed to source control. For AWS-hosted systems, pull them at startup from Secrets Manager:

@Bean
public BetfairCredentials betfairCredentials(SecretsManagerClient secretsManager) {
    GetSecretValueResponse response = secretsManager.getSecretValue(
        GetSecretValueRequest.builder()
            .secretId("betfair/credentials")
            .build());

    return objectMapper.readValue(response.secretString(), BetfairCredentials.class);
}

Store the .p12 as a base64-encoded string in Secrets Manager, decode it at startup, and write it to a temp file or load it directly into a KeyStore from a ByteArrayInputStream. Never write credential files to a persistent path accessible outside the process.

Common Failure Modes

  • CERT_AUTH_REQUIRED: you’ve hit the standard login endpoint instead of the cert login endpoint. Check the URL.
  • INVALID_USERNAME_OR_PASSWORD: the credentials are wrong, or the account is locked after too many failed attempts. Check your Betfair account directly.
  • SSLHandshakeException: the certificate is not trusted, the wrong keystore type was used, or the p12 password is wrong. Log the exception chain — it usually tells you exactly which step failed.
  • ANGX-0003 on API calls: the session token has expired. Your refresh logic has a gap.
  • ACCOUNT_NOW_LOCKED: too many failed logins. Betfair temporarily locks accounts on repeated failures — exponential backoff on auth retries is essential.

If you’re building automated trading infrastructure on Betfair and want to get the auth layer right before anything else, get in touch.

Samuel Jackson

Samuel Jackson

Senior Java Back End Developer & Contractor

Senior Java Back End Developer — Betfair Exchange API specialist, Spring Boot, AWS, and event-driven architecture. 20+ years delivering high-performance systems across betting, finance, energy, retail, and government. Available for Java contracting.