DEV Community

Willette
Willette

Posted on Fully Autonomous

How to Handle OTP in Selenium Java with a Real SMS Number API

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

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

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

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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"
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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;
import org.openqa.selenium.StaleElementReferenceException;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;

import java.time.Duration;
import java.util.List;

@Tag("real-sms")
@EnabledIfEnvironmentVariable(named = "HEROSMS_API_KEY", matches = ".+")
class OtpSignupTest {
    private static final By PHONE = By.cssSelector("[data-test=phone]");
    private static final By SEND_CODE = By.cssSelector("[data-test=send-code]");
    private static final By OTP = By.cssSelector("[data-test=otp]"); // the input, or a wrapper around one box per digit
    private static final By OTP_BOXES = By.cssSelector("[data-test=otp] input");
    private static final By VERIFY = By.cssSelector("[data-test=verify]");
    private static final By OTP_ERROR = By.cssSelector("[data-test=otp-error]");

    private WebDriver driver;
    private WebDriverWait wait;

    @BeforeEach
    void startBrowser() {
        ChromeOptions options = new ChromeOptions();
        options.addArguments("--headless=new", "--window-size=1366,900");
        driver = new ChromeDriver(options); // Selenium Manager finds or downloads the driver
        wait = new WebDriverWait(driver, Duration.ofSeconds(15)); // polls every 500 ms
        wait.ignoring(StaleElementReferenceException.class); // OTP boxes may re-render
    }

    @AfterEach
    void quitBrowser() {
        if (driver != null) driver.quit();
    }

    @Test
    void signsUpWithARealSmsCode() throws Exception {
        // Check the config before paying for a number
        String service = env("OTP_SERVICE", null);
        int country = Integer.parseInt(env("OTP_COUNTRY", null));
        String cap = env("HEROSMS_MAX_PRICE", "");
        Double maxPrice = cap.isEmpty() ? null : Double.valueOf(cap);
        String appUrl = env("APP_URL", null);

        HeroSmsClient sms = new HeroSmsClient(System.getenv("HEROSMS_API_KEY"));
        HeroSmsClient.Activation number = sms.buy(service, country, maxPrice);
        long boughtAt = System.nanoTime();
        boolean gotCode = false;
        try {
            driver.get(appUrl + "/signup");
            wait.until(ExpectedConditions.visibilityOfElementLocated(PHONE))
                .sendKeys("+" + number.phone());
            wait.until(ExpectedConditions.elementToBeClickable(SEND_CODE)).click();

            String code = SmsCodePoller.waitForCode(sms, number.id(), Duration.ofSeconds(120));
            gotCode = true;

            typeOtp(wrongCode(code)); // a wrong code must be rejected first
            wait.until(ExpectedConditions.elementToBeClickable(VERIFY)).click();
            wait.until(ExpectedConditions.visibilityOfElementLocated(OTP_ERROR));

            typeOtp(code);
            wait.until(ExpectedConditions.elementToBeClickable(VERIFY)).click();
            wait.until(ExpectedConditions.urlContains("/welcome")); // TimeoutException = test fails
        } finally {
            ActivationCleanup.release(sms, number.id(), gotCode, boughtAt);
        }
    }

    /** One input, or one box per digit: types by index, so it doesn't rely on auto-focus. */
    private void typeOtp(String code) {
        WebElement otp = wait.until(ExpectedConditions.visibilityOfElementLocated(OTP));
        if ("input".equalsIgnoreCase(otp.getTagName())) {
            otp.clear();
            otp.sendKeys(code);
            return;
        }
        wait.until(ExpectedConditions.numberOfElementsToBe(OTP_BOXES, code.length()));
        for (int i = 0; i < code.length(); i++) {
            int index = i;
            String digit = code.substring(i, i + 1);
            wait.until(d -> {
                List<WebElement> boxes = d.findElements(OTP_BOXES); // fresh lookup on every try
                if (boxes.size() <= index) return null; // mid re-render: keep waiting
                WebElement box = boxes.get(index);
                box.clear();
                box.sendKeys(digit);
                return true;
            });
        }
    }

    private static String wrongCode(String code) {
        return (code.startsWith("1") ? "2" : "1") + code.substring(1);
    }

    /** Blank counts as unset: GitHub passes an empty string for a missing variable. */
    private static String env(String name, String fallback) {
        String value = System.getenv(name);
        if (value != null && !value.isBlank()) return value.trim();
        if (fallback != null) return fallback;
        throw new IllegalStateException(name + " is not set");
    }
}
Enter fullscreen mode Exit fullscreen mode

To run it locally, export the variables above and run mvn test -Dgroups=real-sms. If Surefire reports Tests run: 1, ... Skipped: 1, the key wasn't exported in that shell. Once the key is exported, a plain mvn test runs this test too and buys a number. For everyday runs, use mvn test -DexcludedGroups=real-sms.

  • @Tag("real-sms") lets Maven select or exclude the test. @EnabledIfEnvironmentVariable disables it when the key isn't set, so a PR build without the secret never buys a number.
  • The test reads all of its config before buy, and APP_URL has no fallback. A missing or malformed variable therefore fails the test before it pays for a number.
  • Every interaction goes through an explicit wait. WebDriverWait takes a Duration and polls every 500 ms. It ignores NotFoundException by default and throws TimeoutException when time runs out.
  • Use visibilityOfElementLocated for fields, elementToBeClickable for buttons, and numberOfElementsToBe to wait until every OTP box exists.
  • Don't set an implicit wait as well. The Selenium waits page warns against mixing implicit and explicit waits, because the resulting timeouts are unpredictable.
  • Split OTP inputs get one digit per box, by index, with a fresh lookup each time. If a re-render briefly leaves fewer boxes, the lambda returns null and the wait polls again. The wait also ignores StaleElementReferenceException, so a box that re-renders mid-typing is retried instead of failing the test.
  • The wrong-code check proves your app rejects bad input. If your app burns the code after one wrong attempt, drop those three lines. If it submits on the last digit, drop the Verify clicks.
  • For a login or SMS 2FA test, open your login page instead and type the code at the second-factor prompt. If your form has a separate country picker, strip countryPhoneCode (also in the purchase response) from the start of the number.
  • The Selenium Chrome docs use --headless=new. Chrome 132 removed the old headless mode, so plain --headless now does the same thing.

Step 4: finish or cancel the number in finally

// src/test/java/com/example/otp/ActivationCleanup.java
package com.example.otp;

import com.example.otp.HeroSmsClient.HeroSmsException;

import java.time.Duration;

final class ActivationCleanup {
    private ActivationCleanup() {}

    /** Finish if the code was used, cancel otherwise. Never throws: it must not hide the test failure. */
    static void release(HeroSmsClient sms, String id, boolean gotCode, long boughtAtNanos) {
        try {
            if (gotCode) {
                sms.finish(id); // the SMS arrived: close it, no refund
                return;
            }
            try {
                sms.cancel(id); // no SMS: cancel, the price goes back to your balance
            } catch (HeroSmsException e) {
                if ("OTP_RECEIVED".equals(e.title)) {
                    sms.finish(id); // the SMS came late
                } else if ("EARLY_CANCEL_DENIED".equals(e.title)) {
                    long minSec = e.info.path("minActivationTime").asLong(120);
                    long waitNanos = boughtAtNanos + Duration.ofSeconds(minSec + 5).toNanos() - System.nanoTime();
                    if (waitNanos > 0) Thread.sleep(Duration.ofNanos(waitNanos).toMillis());
                    try {
                        sms.cancel(id); // one more try, no more waiting
                    } catch (HeroSmsException again) {
                        if (!"OTP_RECEIVED".equals(again.title)) throw again;
                        sms.finish(id);
                    }
                } else {
                    throw e;
                }
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.err.println("[otp] cleanup of " + id + " interrupted; check GET /activations");
        } catch (Exception e) {
            System.err.println("[otp] cleanup of " + id + " failed: " + e.getMessage());
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The release call sits in the test's own finally, right after the purchase, so it runs whether the assertions pass or throw. @AfterEach only quits the browser.

  • If the code arrived, finish closes the activation, with no refund. If it didn't, DELETE cancels the activation and refunds the price.
  • The API refuses a cancel during a number's first minutes. It returns EARLY_CANCEL_DENIED with info.minActivationTime, which is about 2 minutes in the reference's examples. A test that fails within seconds therefore waits out that window plus 5 seconds, then tries once more.
  • If the SMS arrives late, the cancel fails with OTP_RECEIVED and the code calls finish instead. Cleanup branches on title, not on the HTTP status.
  • Cleanup errors are only logged. Throwing from finally would replace the real failure (say, a TimeoutException on the OTP field) with a less useful one.
  • If the runner itself dies, no Java code runs at all. Unused numbers are refunded to your balance, and GET /activations lists anything still open.

Step 5: run the Selenium Java test nightly in GitHub Actions

Real numbers cost money, so run this test on a schedule rather than on every push:

# .github/workflows/otp-nightly.yml
name: otp-nightly
on:
  schedule:
    - cron: '17 4 * * 1-5' # 04:17 UTC on weekdays; every run buys a number
  workflow_dispatch:

concurrency:
  group: otp-nightly
  cancel-in-progress: false # a canceled run may never reach the finally block

jobs:
  real-sms:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - name: Fail if the API key is missing
        env:
          HEROSMS_API_KEY: ${{ secrets.HEROSMS_API_KEY }}
        run: test -n "$HEROSMS_API_KEY" || { echo "::error::HEROSMS_API_KEY is not set"; exit 1; }
      - uses: actions/checkout@v7
      - uses: actions/setup-java@v6
        with:
          distribution: temurin
          java-version: '21'
          cache: maven
      - name: Real-SMS Selenium test
        env:
          HEROSMS_API_KEY: ${{ secrets.HEROSMS_API_KEY }}
          OTP_SERVICE: ${{ vars.OTP_SERVICE }}
          OTP_COUNTRY: ${{ vars.OTP_COUNTRY }}
          HEROSMS_MAX_PRICE: ${{ vars.HEROSMS_MAX_PRICE }}
          APP_URL: ${{ vars.APP_URL }}
        run: mvn -B test -Dgroups=real-sms
      - uses: actions/upload-artifact@v7
        if: failure()
        with:
          name: surefire-reports
          path: target/surefire-reports
          retention-days: 7
Enter fullscreen mode Exit fullscreen mode
  • The first step fails the job if the secret is missing or misnamed. Without that step, GitHub passes an empty string, @EnabledIfEnvironmentVariable skips the test, and the nightly run goes green with Skipped: 1.
  • The key comes in through the step's env:, not the command line, as GitHub's secrets guide recommends. Surefire's forked test JVM inherits Maven's environment, so System.getenv sees the key.
  • Your PR workflow should run mvn -B test -DexcludedGroups=real-sms and not pass the key at all. GitHub doesn't pass secrets other than GITHUB_TOKEN to workflows triggered from forks, so a fork PR can't spend your balance. Don't move this job to pull_request_target, because that trigger does get secrets.
  • The ubuntu-latest image ships with Chrome and ChromeDriver, and Selenium Manager finds the driver itself, so the workflow needs no browser setup step.
  • Scheduled workflows have a few quirks:
    • They run on the latest commit of the default branch.
    • Times are in UTC unless you set a timezone.
    • Under load they can start late or be dropped, especially at the top of the hour. That's why the cron uses minute 17.
    • In a public repository, they are disabled after 60 days without activity.
  • Set HEROSMS_MAX_PRICE as a repository variable, so a price change can't quietly raise the cost of every run.
  • If you add failure screenshots, treat them as sensitive: they show the phone number and the code. Keep artifact retention short.

Troubleshooting

  • The code never arrives.
    • Check that your app accepted the number with the +.
    • Check that OTP_SERVICE matches the service that sends your SMS, since an activation is reserved for one service.
    • If your app rejects certain prefixes, phoneException in the purchase body excludes them up front.
    • POST /activations/{activationId}/replace gives you a free replacement number. If you use it, pass the activation it returns to ActivationCleanup.release, not the original ID.
  • TimeoutException on the OTP field. One of three things is usually wrong:
    • The locator is wrong.
    • The field is inside an iframe. Call driver.switchTo().frame(...) first.
    • Your app shows the field only after the send-code request returns. Raise the wait for that one step instead of adding an implicit wait.
  • StaleElementReferenceException on split boxes. Your component re-renders after each digit. Re-query each box by index, as typeOtp does, and keep ignoring(StaleElementReferenceException.class) on the wait.
  • Skipped: 1 instead of a run. HEROSMS_API_KEY isn't set in that shell, so @EnabledIfEnvironmentVariable disabled the test. In CI, the guard step fails the job instead.
  • 429 Too Many Requests. The poller honors Retry-After and backs off to 10 seconds. The concurrency group stops two scheduled runs from polling at the same time.
  • EARLY_CANCEL_DENIED in the log. This is expected when a test fails fast. Cleanup waits and retries once. If the retry is refused too, the number stays open until it expires.
  • Expired activation. Polling stops on ACTIVATION_NOT_ACTIVE, CANCELED, FINISHED or REFUNDED. A 2-minute poll sits well inside the ~20-minute activation. If you wait longer, compare against expiredAt.
  • SessionNotCreatedException about the ChromeDriver version. The usual cause is an old driver pinned with webdriver.chrome.driver, or a leftover WebDriverManager setup. Remove it and let Selenium Manager pick the matching driver.
  • Passes locally, fails headless. Headless Chrome may lay the page out differently. Set an explicit --window-size, as above, and take a screenshot on failure to see what the browser saw.

FAQ

Can I automate Selenium 2FA in Python instead? Yes, and the API calls are the same. My Python OTP post has the client and the polling loop with requests, so you can call them from a pytest Selenium test.

How do I automate email OTP in Selenium Java? It's the same loop with a different inbox. Create a test inbox with an email-testing API and poll it for the message with the same deadline loop. Then pull the code out with the same regex and type it with the same typeOtp. Only the client class changes.

Can I just skip OTP or CAPTCHA in tests? For most runs, yes. Selenium's docs recommend a test-only token, or 2FA disabled for test users. Keep one real-SMS run to check delivery. Never try to solve CAPTCHAs in automation; disable them in your test environment instead.

What about TOTP and Google Authenticator in Selenium? You don't need a phone for those. Store the shared secret as a CI secret and generate the current code at runtime with any RFC 6238 TOTP library.

How do I automate mobile OTP in Selenium Java? "Mobile OTP" usually means an SMS code sent to a phone number, which is exactly what this test handles. If the form lives in a native app rather than a web page, the same HeroSmsClient and poller work from an Appium test.

How much does a run cost? Each run pays for one number. The price depends on the service and country, and HEROSMS_MAX_PRICE caps it. A run that gets no SMS is canceled and refunded. If the cancel retry is refused too, the number stays open until it expires. A run that receives an SMS is paid even if your app then fails. A weekday schedule comes to about 22 numbers a month.

Use it on your own app only

Test only apps you own or are authorized to test. A rented number is yours only for the activation window, so don't tie it to an account you'll need later, and delete test users afterwards.

Wrap-up

Keep mocks and test tokens on every PR, and add one scheduled Selenium run against a real number. The PR runs prove the page works. The nightly run proves that a real user gets a code they can type into that page, in the country you launch in, with the SMS template you actually ship. If you'd rather have codes pushed to you than polled, the HeroSMS API reference documents an sms-incoming webhook. The Playwright post above shows that setup.

How do you handle OTP in Selenium today: a test token, a shared phone, or something else?

Top comments (0)