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=60000long-polls, so there is no polling loop to write. - Filters.
phone,serviceanddeviceIdstop parallel tests from taking each other's codes.
One-time setup
- 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
Register the test account to that number
Use the number in the product you are testing, exactly as a user would.
- 3
Create an API key
Dashboard → REST API → Create key. Copy it straight away: it is shown once.
- 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;
}import os
import requests
API = "https://busysms.net/api/v1/codes"
def wait_for_sms_code(phone=None, service=None, timeout_ms=60_000):
params = {"timeoutMs": timeout_ms}
if phone:
params["phone"] = phone
if service:
params["service"] = service
r = requests.get(
API,
params=params,
headers={"Authorization": f"Bearer {os.environ['BUSYSMS_API_KEY']}"},
timeout=timeout_ms / 1000 + 15,
)
r.raise_for_status()
code = r.json()["code"]
if not code:
raise TimeoutError("No new SMS code arrived in time")
return codeStart 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
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:
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
},
});
},
},
});it("signs in with SMS two-factor", () => {
cy.visit("https://staging.example.com/login");
cy.get("input[name=phone]").type("+44 7700 900123");
cy.contains("button", "Send code").click();
cy.task("waitForSmsCode", { phone: "447700900123" }, { timeout: 90_000 }).then((code) => {
expect(code, "SMS code").to.be.a("string");
cy.get("input[name=otp]").type(String(code));
});
cy.contains("button", "Verify").click();
});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
serviceand 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/numbersreportscanReceiveand areasonsuch asgateway_offorphone_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.
# 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 articlesKeep reading
Related guides
All guidesSMS gateway API
The receive-side API reference: authentication, endpoints, the response shape, errors, and curl, Node.js and Python code.
Read the guideSMS codes for AI agents
Let Claude, Codex or any MCP client wait for SMS verification codes through the BusySMS MCP server.
Read the guideAndroid SMS gateway
Turn an Android phone you own into a gateway whose verification codes you read over an API, MCP, the dashboard or Chrome.
Read the guideTry 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.
