AxioAPI

SMS OTP API: test an OTP login with Selenium and Python

By the AxioAPI team · Published

An OTP step is the usual reason a login test is flaky: the SMS arrives late, or not on the number you expected. An SMS OTP API removes the guesswork by giving your test a public number and returning the code as soon as the message arrives, so the Selenium script does not need its own polling loop.

Before you start

  • Use this only on staging. The numbers are public and shared, so anyone can see the messages.
  • Some services block public numbers, so a missing SMS does not always mean your app failed.
  • Store your key as AXIOAPI_KEY and install selenium and requests.

The test

The test lists a US number with GET /api/v1/sms/numbers, remembers the time, submits the number in the login form and calls POST /api/v1/verify/wait with the number and that timestamp. The since value makes the call ignore older messages.

import os
import time

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

API = "https://axioapi.com/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['AXIOAPI_KEY']}", "Accept": "application/json"}


def pick_number(country="US"):
    res = requests.get(f"{API}/sms/numbers", headers=HEADERS, params={"country": country, "limit": 1}, timeout=60)
    res.raise_for_status()
    return res.json()["data"]["items"][0]["number"]


def wait_for_code(number, since):
    res = requests.post(f"{API}/verify/wait", headers=HEADERS, timeout=120, json={"number": number, "since": since})
    res.raise_for_status()
    items = res.json()["data"].get("items") or []
    return items[0]["code"] if items else None


def test_otp_login():
    number = pick_number()
    since = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
    driver = webdriver.Chrome()
    try:
        driver.get("https://staging.example.com/login")
        driver.find_element(By.NAME, "phone").send_keys(number)
        driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()

        code = wait_for_code(number, since)
        assert code, "no OTP arrived before the timeout"

        driver.find_element(By.NAME, "otp").send_keys(code)
        driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
        assert "/dashboard" in driver.current_url
    finally:
        driver.quit()

How it works

  • The wait call returns data.items, and each item has the sender, the full message and the extracted code.
  • A 4 to 8 digit code is extracted for you, so the test does not need a regular expression.
  • If nothing arrives before the timeout, the call returns no items. Pick another number and retry the whole flow.

Keep it reliable in CI

  • Pass since for every run so a code from a previous run is never reused.
  • Retry with a different number if the first one is rejected by the service you test.
  • Never use a public number for an account you need to keep.

When to use the SMS API instead

If you want to inspect every message a number received, use GET /api/v1/sms/numbers/{number}/messages and parse the text yourself. The OTP API is the shorter path when you only need the code.