# Appium OTP Automation: Test SMS OTP with Python on Android

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

Appium OTP automation tends to break in one place. A nightly sign-up test on an Android emulator passes every step, taps "Send code", then sits on the OTP screen for 30 seconds and fails with `TimeoutException`. The Appium log is clean: your backend sent the SMS, but nothing on the test side received it. The emulator has no real SIM, and neither does the device plugged into the runner, so the test uses a QA engineer's number and someone has to ask them for the code on every run. To test SMS OTP with Appium and Python unattended, the code has to come from somewhere else.

This post covers the OTP step for your own app: a Python helper that reads the code over a REST API, the Appium steps, a pytest fixture that always releases the number, parallel runs and cost.

## Appium OTP automation: choose how to handle the OTP step first

Not every test with an OTP step needs a real number:

| Option | What it proves | Risk | Cost |
|---|---|---|---|
| 1. Mock or stub the SMS gateway on staging, fixed code | UI flow: enter number, next screen, type code, land in the app | Doesn't prove an SMS is sent; the mock drifts from the real gateway | Building and maintaining the mock |
| 2. [Firebase test phone numbers](https://firebase.google.com/docs/auth/android/phone-auth) (fictional, fixed 6-digit code, up to 10) | Your app calls the auth API correctly; works on an emulator | No SMS is sent, so delivery is untested | No SMS, no quota used |
| 3. Read the OTP from the staging DB, Redis or logs | The backend generates and stores the right code | SecOps often blocks CI from the DB; tests get tied to internal schemas | Writing the connector |
| 4. A real number rented through an API | A real SMS goes through your gateway and the carrier to a real number, and the app accepts that code | Paid per run, takes minutes, depends on carriers | Number price plus the SMS your gateway sends |

Run options 1-3 on every pull request: they're fast and cheap. Run option 4 on a schedule (nightly), before a release, after switching SMS providers, changing the message template or opening a new market. The rest of this post is about option 4.

Don't add a `SKIP_OTP=true` flag to your backend. Tests go green while SMS stays unchecked, and one config mistake turns the flag on in production. If you need a test mode, put it in the SMS gateway layer (option 1).

## Why SMS Retriever and OTP autofill can't help here

A rented number isn't the device's SIM. The SMS lands at the provider and never reaches the phone or emulator running the test. So:

- The Android [SMS Retriever API](https://developer.android.com/identity/sms-retriever), the SMS User Consent API, Firebase auto-retrieval (`onVerificationCompleted` on Android, `verificationCompleted` in FlutterFire) and the iOS `oneTimeCode` keyboard suggestion never fire.
- The test reads the code through the API and types it into the field like a user.

To test the autofill UI itself, the Appium Python client has `driver.send_sms(phone_number, message)`. It calls `mobile: sendSms` to fake an incoming SMS, which the [UiAutomator2 driver](https://github.com/appium/appium-uiautomator2-driver) supports on emulators only. Send a message with the code and the app hash SMS Retriever expects, and you can test the autofill path. That's a separate test, and it proves nothing about real delivery.

Emulator or real device? If your app sends OTPs through your own backend and SMS gateway, an emulator works: the device only needs network access to call your API, and the SMS goes to the rented number. If the app signs in with Firebase Phone Auth through FlutterFire and you test with a real number, use a physical device: the [FlutterFire docs](https://firebase.google.com/docs/auth/flutter/phone-auth) say phone number sign-in is only available on real devices and the web, and app verification (the SHA-1 fingerprint on Android) has to be set up first.

## Setup: Appium server, UiAutomator2, emulator or device

```bash
npm install -g appium              # Appium 3 needs Node.js ^20.19, ^22.12 or >= 24
appium driver install uiautomator2
appium driver doctor uiautomator2  # reports a missing ANDROID_HOME, JDK...
appium                             # default port 4723; the client calls http://127.0.0.1:4723

pip install Appium-Python-Client pytest pytest-xdist requests  # client 6.x needs Python >= 3.10
adb devices                        # get the udid: emulator-5554, or a real device's serial with USB debugging on

export HEROSMS_API_KEY=...         # CI secret, never commit it
export HEROSMS_COUNTRY=16          # country ID (16 = UK); look up the list in the API reference
export HEROSMS_SERVICE=ot          # "ot" = "Any other", for your own app
# export HEROSMS_MAX_PRICE=<price cap>  # optional: price cap per number
export APP_APK=/path/to/app-staging.apk
export ANDROID_UDIDS=emulator-5554,emulator-5556
```

Get locators with [Appium Inspector](https://appium.github.io/appium-inspector/latest/): connect to the server with the same capabilities, click an element in the screenshot and note the `resource-id` or accessibility id (`content-desc`) of the phone field, the "Send code" button, the code field and an element that exists only on the home screen. Prefer those over XPath, since they survive layout changes.

## A Python helper that reads the OTP over a REST API

The examples use [HeroSMS](https://hero-sms.com/?utm_source=hashnode&utm_medium=article&utm_campaign=hashnode_appium-otp), the provider I work with, because its REST API has a public OpenAPI reference with request and error schemas. With another provider that has a similar API, you only rewrite `otp_client.py`.

The base URL is `https://hero-sms.com/api/v1`, auth is an `Authorization: ApiKey <token>` header, and errors come back as JSON with `title` and `details`. Each purchase is an *activation*: one number for one service in one country, reserved for about 20 minutes (the exact end is in `expiredAt`). Five endpoints cover the flow:

| Step | Endpoint |
|---|---|
| Check price and stock | `GET /activations/offers/sms?services=ot&countries=<ID>` |
| Buy a number | `POST /activations` |
| Read the OTP | `GET /activations/{activationId}/otp/last` |
| Code received, close it | `POST /activations/{activationId}/finish` |
| No code, cancel and refund | `DELETE /activations/{activationId}` |

```python
# otp_client.py - pip install requests
import os
import re
import sys
import time

import requests

API = "https://hero-sms.com/api/v1"
CLOSED = {"ACTIVATION_NOT_ACTIVE", "CANCELED", "FINISHED", "REFUNDED"}
_session: requests.Session | None = None


def _http() -> requests.Session:
    # Create the session on first use: pytest imports this file even when real-SMS tests are skipped
    global _session
    if _session is None:
        _session = requests.Session()
        _session.headers["Authorization"] = f"ApiKey {os.environ['HEROSMS_API_KEY']}"
    return _session


class HeroSMSError(RuntimeError):
    def __init__(self, resp: requests.Response):
        try:
            body = resp.json()
        except ValueError:
            body = {}
        if not isinstance(body, dict):
            body = {}
        try:
            self.retry_after = int(resp.headers.get("Retry-After") or 0)
        except ValueError:  # Retry-After given as an HTTP date
            self.retry_after = 0
        self.status = resp.status_code
        self.title = body.get("title", "")
        details = body.get("details", resp.text[:200])
        super().__init__(f"HTTP {self.status} {self.title}: {details}")


class NoNumbers(Exception):
    pass


def call(method: str, path: str, **kwargs):
    for attempt in range(3):
        r = _http().request(method, API + path, timeout=15, **kwargs)
        if r.status_code == 429 and attempt < 2:  # RATE_LIMIT: wait, then retry
            time.sleep(HeroSMSError(r).retry_after or 5)
            continue
        if r.status_code >= 400:
            raise HeroSMSError(r)
        return r.json() if r.content else None  # finish/DELETE return an empty 204


def check_offer(service: str, country: int) -> dict:
    try:
        resp = call("GET", "/activations/offers/sms",
                    params={"services": service, "countries": country})
    except HeroSMSError as e:
        if e.status != 404:  # 404 = OFFER_NOT_FOUND
            raise
        resp = None
    data = (resp or {}).get("data") or {}
    offer = (data.get(service) or {}).get(str(country))
    if not offer or offer["counts"]["total"] == 0:
        raise NoNumbers(f"No {service} numbers left in country {country}")
    return offer


def buy_number(service: str, country: int, max_price: float | None = None) -> dict:
    body = {"service": service, "country": country,
            "amount": 1, "verificationType": "sms"}
    if max_price is not None:
        body["maxPrice"] = max_price
    return call("POST", "/activations", json=body)["data"][0]


def wait_for_otp(activation_id: int, timeout: int = 240, interval: int = 5) -> dict | None:
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        try:
            resp = call("GET", f"/activations/{activation_id}/otp/last")
            otp = (resp or {}).get("data") or {}
            if otp.get("smsCode") or otp.get("smsText"):
                return otp
        except HeroSMSError as e:
            if e.status == 429:  # still rate-limited: wait, then keep polling
                time.sleep(e.retry_after or interval)
                continue
            # 400/404 = no SMS yet, unless the activation is closed
            if e.status not in (400, 404) or e.title in CLOSED:
                raise
        time.sleep(interval)
    return None


def extract_code(otp: dict) -> str | None:
    if otp.get("smsCode"):
        return otp["smsCode"]
    match = re.search(r"\b\d{4,8}\b", otp.get("smsText") or "")
    return match.group() if match else None


def release(activation_id: int, got_sms: bool) -> None:
    try:
        if got_sms:
            call("POST", f"/activations/{activation_id}/finish")
        else:
            call("DELETE", f"/activations/{activation_id}")  # cancel + refund
    except (HeroSMSError, requests.RequestException) as e:
        print(f"Could not close activation {activation_id}: {e}", file=sys.stderr)
```

Easy to miss:

- `phone` in the purchase response is an integer without the `+`, so `str()` it first. If your form has a separate country picker, strip `countryPhoneCode` from the front.
- `smsCode` (the extracted code) and `smsText` (the full message) can both be null. The reference doesn't describe the response before the first SMS, so the helper treats 400, 404 and an empty `data` as "not yet" and stops on a closed-activation title such as `ACTIVATION_NOT_ACTIVE`. Side effect: a wrong `activationId` can look like a timeout.
- On 429 `RATE_LIMIT` (documented for the offers endpoint), `call()` waits for `Retry-After` and retries up to twice. The poll loop waits and keeps polling until its timeout.
- The 5-second interval is my choice, since the reference doesn't recommend one. Keep the wait to a few minutes, well before `expiredAt`. To skip polling, use the `sms-incoming` webhook: the provider POSTs the message to an HTTPS URL you add in your account profile, so the receiver has to be reachable from the internet.

## pytest fixtures for Appium: driver, rented number, cleanup in finally

Put `otp_client.py`, `conftest.py` and `test_signup_otp.py` in one folder. `driver` opens an Appium session on the worker's device, and `otp_number` buys a number and always releases it. Code after `yield` runs whether the test passes or fails, and the `try/finally` starts right after the purchase, so a failure while building the fixture doesn't leave an activation open either.

```python
# conftest.py
import os
from dataclasses import dataclass

import pytest
from appium import webdriver
from appium.options.android import UiAutomator2Options

import otp_client

APPIUM_URL = os.getenv("APPIUM_URL", "http://127.0.0.1:4723")
UDIDS = [u.strip() for u in os.getenv("ANDROID_UDIDS", "").split(",") if u.strip()]


def pytest_configure(config):
    config.addinivalue_line("markers", "real_sms: sends a real SMS, costs money")


def pytest_collection_modifyitems(config, items):
    # No key: skip real-SMS tests before opening Appium or buying a number
    if os.getenv("HEROSMS_API_KEY"):
        return
    skip = pytest.mark.skip(reason="HEROSMS_API_KEY is not set")
    for item in items:
        if "real_sms" in item.keywords:
            item.add_marker(skip)


@pytest.fixture
def driver(worker_id):
    # pytest-xdist worker_id: "master" in a normal run, "gw0", "gw1"... with -n
    idx = 0 if worker_id == "master" else int(worker_id.removeprefix("gw"))
    if idx >= len(UDIDS):
        pytest.fail(f"ANDROID_UDIDS has {len(UDIDS)} devices, not enough for worker {worker_id}")
    options = UiAutomator2Options()
    options.udid = UDIDS[idx]
    options.app = os.environ["APP_APK"]
    options.system_port = 8200 + idx  # one UiAutomator2 port per device
    options.auto_grant_permissions = True
    options.new_command_timeout = 300
    drv = webdriver.Remote(APPIUM_URL, options=options)
    try:
        yield drv
    finally:
        drv.quit()


@dataclass
class RentedNumber:
    activation_id: int
    phone_e164: str  # +<country code><number>
    national: str    # without the country code, for forms with a separate country picker
    got_sms: bool = False

    def wait_code(self, timeout: int = 240) -> str:
        otp = otp_client.wait_for_otp(self.activation_id, timeout=timeout)
        if otp is None:
            raise TimeoutError(f"No SMS for activation {self.activation_id} after {timeout}s")
        self.got_sms = True  # the SMS arrived: close with finish, even if a later step fails
        code = otp_client.extract_code(otp)
        if not code:
            raise AssertionError("SMS has no code, check your SMS template")
        return code


@pytest.fixture
def otp_number(driver):  # depends on driver: if Appium fails, nothing is bought
    service = os.getenv("HEROSMS_SERVICE", "ot")
    country = int(os.environ["HEROSMS_COUNTRY"])
    max_price = os.getenv("HEROSMS_MAX_PRICE")
    try:
        offer = otp_client.check_offer(service, country)
    except otp_client.NoNumbers as e:
        pytest.skip(str(e))
    print(f"{offer['counts']['total']} numbers left, price {offer['prices']['default']}")
    act = otp_client.buy_number(service, country, float(max_price) if max_price else None)
    rented = None
    try:
        phone = str(act["phone"])
        rented = RentedNumber(
            activation_id=act["id"],
            phone_e164=f"+{phone}",
            national=phone.removeprefix(str(act["countryPhoneCode"])),
        )
        print(f"Activation {act['id']}: price {act.get('price')}, expires {act.get('expiredAt')}")
        yield rented
    finally:
        otp_client.release(act["id"], got_sms=bool(rented and rented.got_sms))
```

Worth knowing:

- `otp_number` depends on `driver`, so pytest always opens the Appium session before buying: a wrong udid or a broken APK costs nothing. Declare the dependency instead of relying on argument order in the test signature. Teardown runs in reverse ([pytest fixture docs](https://docs.pytest.org/en/stable/how-to/fixtures.html)): the number is released first, then `driver.quit()`.
- Without `HEROSMS_API_KEY`, every `real_sms` test is skipped at collection: no Appium session, no spend. In your pull request pipeline, run `pytest -m "not real_sms"`.
- If the country runs out of numbers, the test is skipped, not failed. Change `pytest.skip` to `pytest.fail` if the nightly job should go red.
- `release()` calls `finish` after an SMS (no refund) and `DELETE` otherwise (cancel and refund). The API refuses a cancel during a number's first minutes (`EARLY_CANCEL_DENIED` with `info.minActivationTime`, about 2 minutes in the reference's examples), so a test that fails within seconds can't cancel yet, and `release()` just logs that without hiding the test's own error. An activation with no SMS is refunded to your balance when it expires.
- `worker_id` is a pytest-xdist fixture, so install pytest-xdist even before you run in parallel.

## Test SMS OTP with Appium and Python: enter the number, wait for the field, type the code

```python
# test_signup_otp.py
import pytest
from appium.webdriver.common.appiumby import AppiumBy
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait

# Sample locators: replace with the resource-id / accessibility id from Appium Inspector
PHONE_INPUT = (AppiumBy.ID, "com.example.app:id/phone_input")
SEND_CODE = (AppiumBy.ACCESSIBILITY_ID, "send_code")
CODE_INPUT = (AppiumBy.ID, "com.example.app:id/otp_input")
HOME = (AppiumBy.ACCESSIBILITY_ID, "home_screen")


@pytest.mark.real_sms
def test_signup_with_real_otp(driver, otp_number):
    wait = WebDriverWait(driver, 30)
    wait.until(EC.element_to_be_clickable(PHONE_INPUT)).send_keys(otp_number.national)
    driver.find_element(*SEND_CODE).click()

    # The code field must appear before we wait for the SMS: if the app shows an error, fail now
    code_box = wait.until(EC.visibility_of_element_located(CODE_INPUT))
    code = otp_number.wait_code(timeout=240)
    code_box.send_keys(code)

    WebDriverWait(driver, 60).until(EC.visibility_of_element_located(HOME))
```

- Wait for the code field **before** polling for the SMS. If the app rejects the number or hits a rate limit, the test fails after 30 seconds instead of 4 minutes, and a number that got no SMS costs nothing in the end: its price goes back to your balance when it's cancelled or expires.
- `national` has no country code. If your form expects a leading 0, prepend `"0"`. If it takes the full number, use `otp_number.phone_e164`.
- If the code field is six separate boxes, type one digit per box, but only search for `EditText` inside the code container (`OTP_CONTAINER`, also from Appium Inspector), or the first digit can land in the phone field. If the widget moves focus on its own, focus the first box and type the whole code.

```python
# in the test, instead of code_box.send_keys(code)
OTP_CONTAINER = (AppiumBy.ID, "com.example.app:id/otp_container")
boxes = driver.find_element(*OTP_CONTAINER).find_elements(
    AppiumBy.CLASS_NAME, "android.widget.EditText")
assert len(boxes) == len(code), f"{len(boxes)} boxes for a {len(code)}-digit code"
for box, digit in zip(boxes, code):
    box.send_keys(digit)
```

- If the keyboard covers the "Confirm" button, call `driver.hide_keyboard()` before tapping it.
- International SMS can be slow, so don't shorten the code timeout.
- Don't log the full code (print `code[:1] + "***"` to debug). Delete the test user afterwards through your staging admin API so the next run can sign up again.

## Run Appium in parallel on several Android devices with pytest-xdist

```bash
export ANDROID_UDIDS=emulator-5554,emulator-5556
pytest -m real_sms -n 2
```

- Each worker gets its own udid, its own `systemPort` (8200 plus its index, so two UiAutomator2 sessions don't fight over a port) and its own activation. Codes can't mix between devices because each `otp/last` call is scoped to one `activationId`. If `-n` is larger than the number of devices in `ANDROID_UDIDS`, the `driver` fixture fails with a clear message instead of hanging.
- One Appium server can run several sessions. For one server per device, give each worker its own `APPIUM_URL`, the same way each worker gets its own udid.
- Stock must cover your workers (`counts.total` in offers). Many workers calling offers at once can hit `429 RATE_LIMIT`. `call()` retries after `Retry-After`, but keep `-n` modest.
- Buy one number per test (`amount: 1` in `buy_number`): the test that buys a number releases it, so cleanup stays simple.

## What it costs and where it falls short

I'm not quoting prices: they vary by country, carrier and time. Each run pays for:

- The activation: `price` in the `POST /activations` response, `prices.default` in offers. The fixture logs both, and `pytest -rP` shows them (`-s` doesn't work together with `-n`, per the [pytest-xdist known limitations](https://pytest-xdist.readthedocs.io/en/stable/known-limitations.html)).
- The SMS your system sends: your gateway charges per message, and Firebase bills SMS by destination country. Every "Resend code" tap is one more SMS.
- Device, emulator and CI runner time.

To keep it under control: set `HEROSMS_MAX_PRICE` (sent as `maxPrice`), run on a schedule rather than per commit, and cap `-n`, since every worker rents a number. The fixture already opens Appium before buying and cancels when no SMS arrives.

Limits to plan for: an activation lasts about 20 minutes, so a run can't wait longer. If the SMS never comes, the number's price goes back to your balance once the activation is cancelled or expires. You lose only runner time and whatever your gateway charged for sending. Some services and anti-fraud rules reject virtual numbers. If your own app does, the test fails at the OTP step (the code field doesn't appear within 30 seconds, or no SMS arrives), which is worth knowing before real users hit the same rule.

## Use it on your own app only

- Test only your own app, or one you're authorized to test, with test accounts. This setup is not for mass registration, and using rented numbers to get around another service's sign-up limits or verification usually breaks its terms.
- A rented number is yours only for the activation window: don't tie it to an account you'll need to recover, and delete your test users.
- OTPs and API keys are secrets: keep the key in CI secrets, never commit or echo it, and don't print full SMS text. Appium server logs can record typed values, so don't post raw logs publicly.
- Run against staging (a staging backend or a separate test project), not production.

## FAQ

**Can Appium read an OTP SMS on an emulator?** No. The emulator only receives SMS you fake yourself, for example with `driver.send_sms()`, and then you already know the code. A real SMS to a rented number never reaches the device, so read it through the API and type it in.

**Are Firebase test phone numbers enough?** For UI tests on every pull request, yes. They send no SMS, so they can't test delivery.

## Wrap-up

Split Appium OTP automation into two layers: Firebase test numbers or mocks on every pull request, a real number through an API on a schedule or before a release. Checklist:

1. Locators from Appium Inspector, accessibility id and resource-id first.
2. `otp_number` depends on `driver`: if Appium fails, nothing is bought.
3. Release in `finally`: `finish` after an SMS, cancel without one.
4. One udid and one `systemPort` per worker.
5. API key in secrets, `maxPrice` set, no OTPs in logs.
