Disclosure: I work with HeroSMS, the virtual number provider used in the examples below. Written with AI tools; code and endpoints checked against the HeroSMS API reference.

If you've tried to handle OTP in Selenium WebDriver, you know this test. It types a phone number, clicks "Send code", then sits in Thread.sleep(30000) while you read the code off your phone and type it in. That works on your own machine. In the nightly run nobody types anything, so the next explicit wait fails:

org.openqa.selenium.TimeoutException: Expected condition failed: waiting for url to contain "/welcome". Current url: "https://staging.example.com/signup/verify" (tried for 15 seconds with 500 milliseconds interval) 

The CI runner has no SIM card. This tutorial replaces the human with an API. The test rents a real number, polls for the SMS code from Java with a deadline, types the code after an explicit wait and releases the number in finally. GitHub Actions runs the whole thing on a schedule. Stack: Java 17+, Maven, Selenium 4.52 and JUnit 5.

How to automate OTP in Selenium WebDriver

Selenium can't read SMS because it drives a browser, not a phone. To automate OTP in Selenium:

  1. Rent a phone number through an SMS API and type it into your form.
  2. Poll the API from Java until the code arrives.
  3. Wait for the OTP field with WebDriverWait and type the code into it.

Your test code does the polling, and Selenium only does the typing. The same flow works for sign-up, login and SMS 2FA.

Mocks, test numbers, 2FA off for test users, or a real number

Start with the free options, since real numbers cost money. Selenium's own documentation advises against automating 2FA at all. It suggests these workarounds instead:

  • disable 2FA for test users or in the test environment
  • accept a test-only token
  • skip 2FA for requests from your test machines' IPs
Option What it proves Cost When to run
Mocked SMS sender UI and app logic Free Every PR
Provider test credentials or fictional numbers Your call to the SMS provider; UI with a fixed code Free Every PR
2FA off for test users, or a test-only token Everything after the second factor Free Every PR
TOTP secret in the test Your authenticator-app step Free Every PR
Real rented number A real SMS reaches a real number and your app accepts the code Paid per number Nightly, pre-release

Twilio's test credentials don't connect to real phone numbers. Firebase's fictional test numbers (up to 10, each with a fixed code) send no SMS. If your second factor is an authenticator app, you don't need a phone: generate the TOTP code from the shared secret.

None of these options proves that an SMS actually reaches a phone in your launch country. A real-number test fills that gap. I built the same split (mocks on every PR, real SMS on a schedule) three times before: in Python, for Playwright with a webhook and for Postman and Newman. This post is the Selenium Java version.

What you need

  • Java 17 or later (the CI job below uses 21), Maven 3.6.3 or later, and Chrome. Selenium Manager has been built in since 4.6 and downloads and caches the matching driver. Since 4.11 it can also fetch Chrome for Testing if no browser is installed.
  • A HeroSMS API key and some balance. What you buy is an activation: one number reserved for one service in one country for about 20 minutes. The exact end time is in expiredAt. Numbers cover 180+ countries, so you can usually test in the country you launch in.
  • These environment variables:
export HEROSMS_API_KEY="<your API key>" export OTP_SERVICE="<service code>" # 2-4 letters export OTP_COUNTRY="<country ID>" # an integer export APP_URL="https://staging.example.com" # optional: export HEROSMS_MAX_PRICE="<price cap per number>" 

Service codes and country IDs come from the getServicesList and getCountries endpoints in the HeroSMS API reference. GET /activations/offers/sms without filters returns them too: service codes are the keys of data, and country IDs are the keys inside each service.

The project has four classes under src/test and a workflow:

selenium-otp/ ├── pom.xml ├── .github/workflows/otp-nightly.yml └── src/test/java/com/example/otp/ ├── HeroSmsClient.java ├── SmsCodePoller.java ├── ActivationCleanup.java └── OtpSignupTest.java 
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>selenium-otp</artifactId> <version>1.0-SNAPSHOT</version> <properties> <maven.compiler.release>17</maven.compiler.release> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.junit</groupId> <artifactId>junit-bom</artifactId> <version>5.14.4</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.seleniumhq.selenium</groupId> <artifactId>selenium-java</artifactId> <version>4.52.0</version> <scope>test</scope> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.22.4</version> <scope>test</scope> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.16.0</version> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>3.6.1</version> </plugin> </plugins> </build> </project> 

To use JUnit 6 (which needs Java 17+), change the BOM version to 6.1.3. The test compiles unchanged because it only uses the Jupiter API. HTTP calls go through the JDK's own HttpClient, so Jackson is the only extra dependency.

Step 1: a small SMS API client in Java

// src/test/java/com/example/otp/HeroSmsClient.java package com.example.otp; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; import java.util.LinkedHashMap; import java.util.Map; /** Minimal HeroSMS v1 client: rent a number, read the last OTP, finish or cancel. */ public final class HeroSmsClient { private static final String API = "https://hero-sms.com/api/v1"; private static final ObjectMapper JSON = new ObjectMapper(); private final HttpClient http = HttpClient.newHttpClient(); // immutable, reuse it private final String apiKey; public HeroSmsClient(String apiKey) { if (apiKey == null || apiKey.isBlank()) { throw new IllegalStateException("HEROSMS_API_KEY is not set"); } this.apiKey = apiKey; } /** One rented number. phone is digits without the '+'. */ public record Activation(String id, String phone, String countryPhoneCode, String expiredAt) {} /** Error body from the API: branch on title, not on the HTTP status. */ public static final class HeroSmsException extends IOException { public final int status; public final String title; public final String details; public final transient JsonNode info; public final long retryAfter; // seconds, 0 if the header is absent HeroSmsException(int status, String title, String details, JsonNode info, long retryAfter) { super("HTTP " + status + " " + title + ": " + details); this.status = status; this.title = title; this.details = details; this.info = info; this.retryAfter = retryAfter; } } public Activation buy(String service, int country, Double maxPrice) throws IOException, InterruptedException { Map<String, Object> body = new LinkedHashMap<>(); body.put("service", service); body.put("country", country); body.put("amount", 1); body.put("verificationType", "sms"); if (maxPrice != null) body.put("maxPrice", maxPrice); JsonNode a = call("POST", "/activations", body).path("data").path(0); if (a.isMissingNode()) throw new IOException("POST /activations returned no activation"); return new Activation(a.path("id").asText(), a.path("phone").asText(), a.path("countryPhoneCode").asText(), a.path("expiredAt").asText()); } /** data of GET /activations/{id}/otp/last: smsCode and smsText, either may be null. */ public JsonNode lastOtp(String id) throws IOException, InterruptedException { return call("GET", "/activations/" + id + "/otp/last", null).path("data"); } /** Closes the activation after the code was used. No refund. */ public void finish(String id) throws IOException, InterruptedException { call("POST", "/activations/" + id + "/finish", null); } /** Cancels an activation that got no SMS. The price is refunded. */ public void cancel(String id) throws IOException, InterruptedException { call("DELETE", "/activations/" + id, null); } private JsonNode call(String method, String path, Object body) throws IOException, InterruptedException { HttpRequest.Builder req = HttpRequest.newBuilder(URI.create(API + path)) .timeout(Duration.ofSeconds(15)) // without it, send() can block forever .header("Authorization", "ApiKey " + apiKey) .header("Accept", "application/json"); if (body != null) { req.header("Content-Type", "application/json") .method(method, HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body))); } else { req.method(method, HttpRequest.BodyPublishers.noBody()); } HttpResponse<String> res = http.send(req.build(), HttpResponse.BodyHandlers.ofString()); String text = res.body() == null ? "" : res.body(); if (res.statusCode() >= 400) { JsonNode err = parse(text); long retryAfter = res.headers().firstValue("Retry-After") .map(HeroSmsClient::seconds).orElse(0L); throw new HeroSmsException(res.statusCode(), err.path("title").asText(""), err.path("details").asText(""), err.path("info"), retryAfter); } return parse(text); // 204 No Content -> missing node } private static JsonNode parse(String text) { if (text.isBlank()) return JSON.missingNode(); try { return JSON.readTree(text); } catch (IOException e) { return JSON.missingNode(); // not JSON } } private static long seconds(String value) { try { return Long.parseLong(value.trim()); } catch (NumberFormatException e) { return 0; // an HTTP date: use the caller's fallback } } } 
  • Auth is one header, Authorization: ApiKey <key>. The key is read from the environment and never touches the browser.
  • Errors come back as JSON with title and details, and sometimes info. HeroSmsException keeps all three plus the Retry-After header, so callers can branch on title.
  • Always set HttpRequest.timeout. Without it, send() can wait forever, and one stuck request hangs the whole job. With it, a slow request throws an HttpTimeoutException.
  • phone comes back as an integer without the +. asText() keeps every digit.

Step 2: poll for the code with a deadline, not Thread.sleep

A fixed Thread.sleep(30000) is the classic way to wait for an OTP, and it fails in both directions. It's too short when a carrier is slow, and it wastes 28 seconds when the SMS lands in two. Poll instead, and stop at a deadline:

// src/test/java/com/example/otp/SmsCodePoller.java package com.example.otp; import com.example.otp.HeroSmsClient.HeroSmsException; import com.fasterxml.jackson.databind.JsonNode; import java.io.IOException; import java.time.Duration; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; /** Polls GET /activations/{id}/otp/last until a code arrives or the deadline passes. */ public final class SmsCodePoller { private static final Set<String> CLOSED = Set.of("ACTIVATION_NOT_ACTIVE", "CANCELED", "FINISHED", "REFUNDED"); private static final Pattern CODE = Pattern.compile("\\b\\d{4,8}\\b"); // fit it to your template private SmsCodePoller() {} public static String waitForCode(HeroSmsClient sms, String activationId, Duration timeout) throws IOException, InterruptedException { long deadline = System.nanoTime() + timeout.toNanos(); long delayMs = 2_000; // backs off to 10 s IOException lastError = null; // becomes the cause if time runs out while (System.nanoTime() < deadline) { long sleepMs = delayMs; try { JsonNode otp = sms.lastOtp(activationId); lastError = null; // the API answered String code = extractCode(otp); if (code != null) return code; if (!text(otp.path("smsText")).isEmpty()) { throw new IllegalStateException("SMS arrived without a 4-8 digit code: adjust the regex"); } } catch (HeroSmsException e) { if (CLOSED.contains(e.title)) { throw e; // closed or expired: stop polling } else if (e.status == 429) { sleepMs = (e.retryAfter > 0 ? e.retryAfter : 5) * 1_000; // documented for offers only } else if (e.status >= 500) { lastError = e; // server error: retry until the deadline } else if (e.status == 400 || e.status == 404) { lastError = null; // open activation, no SMS yet } else { throw e; // any other error: stop polling } } catch (IOException e) { lastError = e; // timed-out or dropped request: retry until the deadline } long leftMs = Duration.ofNanos(deadline - System.nanoTime()).toMillis(); if (leftMs <= 0) break; Thread.sleep(Math.min(sleepMs, leftMs)); delayMs = Math.min(delayMs * 3 / 2, 10_000); } throw new IllegalStateException( "No SMS for activation " + activationId + " within " + timeout.toSeconds() + " s", lastError); } static String extractCode(JsonNode otp) { String code = text(otp.path("smsCode")).trim(); if (!code.isEmpty()) return code; Matcher m = CODE.matcher(text(otp.path("smsText"))); return m.find() ? m.group() : null; } private static String text(JsonNode node) { return node.isMissingNode() || node.isNull() ? "" : node.asText(); // NullNode.asText() is "null" } } 
  • GET /activations/{activationId}/otp/last returns smsCode (the code) and smsText (the whole message). Either one can be null. When smsCode is empty, the regex takes the first 4-8 digit number from smsText, so adjust it to your SMS template.
  • The API reference doesn't document the response before the first SMS. The poller therefore treats 400, 404 and an empty data as "not yet", unless title says the activation is closed. One side effect: a 404 caused by a wrong ID looks like a timeout.
  • A 5xx response, a timed-out request or a dropped connection is retried until the deadline. If polling runs out of time, the last such error becomes the exception's cause, so a proxy or DNS problem doesn't look like a missing SMS.
  • On 429 the loop honors Retry-After. The reference documents that header only for the offers endpoint, so the loop falls back to 5 seconds when it's absent.
  • The deadline uses System.nanoTime(), which doesn't jump when the runner's clock is adjusted.

Why not use Selenium's own FluentWait? It's generic, so new FluentWait<>(activationId) can poll any object, not just a driver. But Function.apply can't throw checked exceptions, and the polling interval is fixed. The 429 and closed-activation rules would end up wrapped and hidden. A plain loop is easier to read.

Step 3: the Selenium OTP test with JUnit 5 and explicit waits

// src/test/java/com/example/otp/OtpSignupTest.java package com.example.otp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.openqa.selenium.By; Source:https://dev.to/willette_samer/how-to-handle-otp-in-selenium-java-with-a-real-sms-number-api-544n

Related posts