BusySMS

OTP automation

How to automate OTP and SMS 2FA in tests and scripts

SMS one-time codes are the step that breaks end-to-end tests. This guide shows how to keep the real step in the test: a real SIM on a phone you own, and one API call that waits for the code, with working examples for Playwright, Selenium and Cypress.

$5 per verified number per month · 7-day free trial · billed by Stripe on the web

On this page

Why OTP breaks automation

A test can click, type and assert, but it cannot read a phone. The usual workarounds each give something up:

  • Turning 2FA off in staging means the sign-in path users actually take is never tested.
  • Fixed test codes only work in systems you control, and they hide delivery problems.
  • A person with the phone does not scale to a nightly run, and is never there at 3 a.m.

The approach: a real number, read by API

Put a SIM you own in an Android phone (or use an iPhone with a Shortcuts automation), install BusySMS and verify the number. Register your test account to that number. In the test, trigger the SMS, ask the BusySMS API for the next code that arrives, and type it in. The code is real, the delivery path is real, and nobody has to watch the phone.

  • No stale codes. A waiting request only returns a code that arrived after it started, so a test never submits the previous run's code.
  • One request. GET /api/v1/codes?timeoutMs=60000 long-polls, so there is no polling loop to write.
  • Filters. phone, service and deviceId stop parallel tests from taking each other's codes.

One-time setup

  1. 1

    Enroll a phone

    Install BusySMS from Google Play on a phone with the test SIM, sign in and verify the number. The Android SMS gateway guide has the details.

  2. 2

    Register the test account to that number

    Use the number in the product you are testing, exactly as a user would.

  3. 3

    Create an API key

    Dashboard → REST API → Create key. Copy it straight away: it is shown once.

  4. 4

    Store it as a CI secret

    Expose it to the test runner as BUSYSMS_API_KEY. Never commit it, and never send it to the browser under test.

A small helper

Every example below uses the same idea: start waiting, trigger the SMS, take the code. Put the wait in one helper, in TypeScript or Python:

const API = "https://busysms.net/api/v1/codes";

export async function waitForSmsCode(
  opts: { phone?: string; service?: string; timeoutMs?: number } = {},
): Promise<string> {
  const params = new URLSearchParams({ timeoutMs: String(opts.timeoutMs ?? 60_000) });
  if (opts.phone) params.set("phone", opts.phone);
  if (opts.service) params.set("service", opts.service);

  const res = await fetch(`${API}?${params}`, {
    headers: { Authorization: `Bearer ${process.env.BUSYSMS_API_KEY}` },
  });
  if (!res.ok) throw new Error(`BusySMS ${res.status}: ${await res.text()}`);

  const { code } = (await res.json()) as { code: string | null };
  if (!code) throw new Error("No new SMS code arrived in time");
  return code;
}

Start the wait before you click Send code. That closes the race where a fast SMS lands before you begin waiting. A code that arrived up to about a second before the request is still treated as new, but anything older is not.

Playwright

login.spec.ts
import { test, expect } from "@playwright/test";
import { waitForSmsCode } from "./busysms";

test("signs in with SMS two-factor", async ({ page }) => {
  test.setTimeout(120_000); // the default 30 s is shorter than an SMS wait

  await page.goto("https://staging.example.com/login");
  await page.getByLabel("Phone number").fill("+44 7700 900123");

  // Start waiting BEFORE the SMS is sent, then trigger it.
  const code = waitForSmsCode({ phone: "447700900123" });
  await page.getByRole("button", { name: "Send code" }).click();

  await page.getByLabel("Verification code").fill(await code);
  await page.getByRole("button", { name: "Verify" }).click();
  await expect(page).toHaveURL(/dashboard/);
});

Selenium (Python)

Selenium calls block, so run the wait on a worker thread, trigger the SMS, then collect the result:

test_login.py
from concurrent.futures import ThreadPoolExecutor

from selenium import webdriver
from selenium.webdriver.common.by import By

from busysms import wait_for_sms_code

driver = webdriver.Chrome()
driver.get("https://staging.example.com/login")
driver.find_element(By.NAME, "phone").send_keys("+44 7700 900123")

with ThreadPoolExecutor(max_workers=1) as pool:
    # Start waiting first, then trigger the SMS.
    pending = pool.submit(wait_for_sms_code, phone="447700900123")
    driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
    code = pending.result()

driver.find_element(By.NAME, "otp").send_keys(code)
driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()

Cypress

Cypress commands run in the browser, so keep the key out of it: call the API from a Node task in cypress.config.ts and use the result in the test.

import { defineConfig } from "cypress";

export default defineConfig({
  e2e: {
    setupNodeEvents(on) {
      on("task", {
        // Runs in Node, so the API key never reaches the browser.
        async waitForSmsCode({ phone }: { phone: string }) {
          const params = new URLSearchParams({ phone, timeoutMs: "60000" });
          const res = await fetch(`https://busysms.net/api/v1/codes?${params}`, {
            headers: { Authorization: `Bearer ${process.env.BUSYSMS_API_KEY}` },
          });
          if (!res.ok) throw new Error(`BusySMS ${res.status}`);
          const { code } = await res.json();
          return code; // null if nothing new arrived
        },
      });
    },
  },
});

Cypress runs commands in order, so here the wait starts just after the click. SMS delivery is rarely that fast, and a code that landed up to a second before the request still counts.

Parallel runs and flaky delivery

  • One number per parallel worker is the simplest rule. If workers must share a number, filter by service and run sign-ins to the same service one at a time.
  • A wait is capped at 90 seconds. If an SMS can take longer, retry the wait once rather than raising the timeout.
  • When a test gets null, check the phone before blaming the product: GET /api/v1/numbers reports canReceive and a reason such as gateway_off or phone_unreachable.
  • If nothing is capturing as a wait starts, BusySMS sends the phone a notification asking for the gateway to be switched back on. Someone has to tap it.
  • Captured messages are kept for up to 7 days, long enough to debug last night's failed run.

Plain scripts and CI jobs

Nothing here is specific to a test framework. A shell step in a CI job can do the same with curl and jq: start the wait in the background, trigger the SMS, then read the result.

bash
# Start waiting in the background, then trigger the SMS
curl -s -H "Authorization: Bearer $BUSYSMS_API_KEY" \
  "https://busysms.net/api/v1/codes?phone=447700900123&timeoutMs=60000" > code.json &
WAIT_PID=$!

./trigger-login.sh   # whatever makes the service send the SMS

wait "$WAIT_PID"
CODE=$(jq -r .code code.json)
[ "$CODE" != "null" ] || { echo "No SMS code arrived" >&2; exit 1; }
echo "Got code $CODE"

jq prints null when no code arrived, so the check fails the job instead of typing the word null into a form.

Use it on accounts you own

This works because the code is sent to a number you own. Use it for accounts you control: your product's test users, your own admin logins, your staging environment. Driving the browser with an AI agent instead of a test runner? The MCP guide covers that.

BusySMS reads codes sent to your own numbers. It is not a way to get around someone else's verification, and it is not built for creating accounts in bulk.

FAQ

Frequently asked questions

How do I automate OTP verification in Selenium?

Start GET /api/v1/codes?timeoutMs=60000 on a worker thread, trigger the SMS, then type the returned code into the field. The Python example above does exactly that.

Can I automate OTP without a real phone?

Not with BusySMS. The codes come from a SIM you own in a real phone; emulators are not supported as gateways.

Will a test ever pick up an old code?

No. With timeoutMs above zero the API only returns codes that arrived after the request started, and returns null if none did.

What does it cost to run in CI?

$5 per verified number per month after a 7-day free trial. The price is per number, not per test run or per code.

Does it handle email codes or authenticator apps?

No. BusySMS reads codes sent by SMS to your own numbers. Email codes and authenticator-app codes are not SMS, so they are out of scope.

Can my whole team use one test number?

Yes. Share the number with up to five other BusySMS users; once they accept, their own API keys receive its new codes too.

Blog

More from the blog

All articles

Keep reading

Related guides

All guides

Try it on a number you own

Install the app, verify your number and read your first code today. Every account starts with a 7-day free trial; after that it is $5 per verified number per month, and you can cancel any time.