🤖 App Automation Testing Essential: Automate SMS Verification with Temporary Number Scripts

A battle‑tested guide from a QA engineer who’s tired of typing codes by hand. We’ll wrap a disposable number API into reliable Appium scripts so your regression runs never stall again.

The Pain: That One Manual Step That Destroys Automation

Picture this: your nightly regression suite runs perfectly until it reaches the registration flow. Suddenly the script stops and waits for a human to read a verification code from a physical phone. You’ve tried writing a fixed code into the app (fragile, breaks every build), or begged the backend team to expose a test API (they always say no). The SMS gateways maintained by your company are locked behind firewalls and cost a fortune to use just for testing.

That’s why I started using temporary number APIs within my Appium scripts. It’s a “wild” solution, but it works — and when you’re running 300 tests at 3 AM, all you care about is a green pipeline.

Three Ways to Grab a Verification Code — And Why I Chose “Wild”

ApproachHow it worksProsCons
A. Intercept internal SMS gateway Ask ops to configure a webhook or mock service that forwards messages to your test agent. Reliable, no external dependencies Requires cross‑team coordination; often blocked by security policies
B. Virtual number services (Twilio, Plivo) Buy a long‑term number and receive SMS via webhook API. Pay per month + per message. Stable, excellent deliverability Not free; setup overhead; numbers may be flagged by some apps
C. Free / cheap disposable number platforms Use a public or semi‑public API that provides temporary phone numbers and returns SMS content. Zero cost, instant number provisioning, thousands of numbers Number quality varies; some numbers already blocked; API may be flaky

For test automation, I pick Plan C every time. Why? Because a test environment doesn’t need a long‑lived number. It just needs a number that works for the 90 seconds it takes to register. When the test finishes, the number can go back into the pool. And the cost is $0.00 — which makes managers smile.

📊 The Complete Automated Flow — Illustrated

Appium Script Temp Number API Target App (Device/Emulator) ───────────── ──────────────── ─────────────────────────── | | | | 1. get_number() | | |----------------------->| | | returns +12223334444 | | |<-----------------------| | | | | | 2. Fill phone field | | |-------------------------------------------------->| | | | | 3. Tap "Send Code" | | |-------------------------------------------------->| | | (app sends SMS) | | |<--------------------------| | | (carrier delivers) | | | | | 4. wait_for_sms() | | | polling every 3 sec | | |----------------------->| | | returns "Your code: 873425" | |<-----------------------| | | | | | 5. Extract code (regex)| | | code = "873425" | | | | | | 6. Fill code field | | |-------------------------------------------------->| | | | | 7. Verify registration | | |-------------------------------------------------->| | (success toast?) | | |<--------------------------------------------------| | | | | 8. release_number() | | |----------------------->| | | | |

💻 Core Implementation: The TempPhoneService Wrapper

First, we need a Python class that talks to a disposable number API. I’ll use a fictional public API (replace it with the provider you find). The interface is what matters.

import requests
import time
import re
import logging
from tenacity import retry, stop_after_attempt, wait_fixed

logger = logging.getLogger(__name__)

class SMSCodeTimeoutError(Exception):
    pass

class TempPhoneService:
    def __init__(self, api_key=None):
        self.api_key = api_key or 'free-key'
        self.base_url = 'https://api.example-sms-provider.com'
        self.current_number = None
        self.number_id = None

    @retry(stop=stop_after_attempt(3), wait=wait_fixed(5))
    def get_number(self, country='US', service='app'):
        """Request a temporary number. Some services allow filtering by 'service'."""
        payload = {'country': country, 'service': service, 'apikey': self.api_key}
        resp = requests.get(f'{self.base_url}/get-number', params=payload, timeout=10)
        data = resp.json()
        if data.get('status') != 'success':
            raise RuntimeError(f"Failed to get number: {data}")
        self.current_number = data['phone_number']
        self.number_id = data['id']
        logger.info(f"Obtained number: {self.current_number} (id: {self.number_id})")
        return self.current_number

    @retry(stop=stop_after_attempt(3), wait=wait_fixed(5))
    def skip_number(self):
        """Mark a number as unusable and get a new one."""
        if self.number_id:
            requests.post(f'{self.base_url}/skip-number', json={'id': self.number_id, 'apikey': self.api_key})
        self.current_number = None
        self.number_id = None
        return self.get_number()  # recursive with retry

    def wait_for_sms(self, max_wait=60, poll_interval=3):
        """Poll for SMS content until timeout. Returns the full SMS body, or raises."""
        deadline = time.time() + max_wait
        while time.time() < deadline:
            try:
                resp = requests.get(
                    f'{self.base_url}/get-sms',
                    params={'id': self.number_id, 'apikey': self.api_key},
                    timeout=10
                )
                data = resp.json()
                if data.get('status') == 'success' and data.get('sms_list'):
                    # Sort by time to get the latest
                    latest = sorted(data['sms_list'], key=lambda x: x.get('time', ''), reverse=True)[0]
                    body = latest['text']
                    sender = latest.get('from', '')
                    logger.info(f"SMS received from {sender}: {body}")
                    return body, sender
            except Exception as e:
                logger.warning(f"Polling error: {e}")
            time.sleep(poll_interval)
        raise SMSCodeTimeoutError(f"No SMS received for {self.current_number} within {max_wait}s")

    def release_number(self):
        if self.number_id:
            requests.post(f'{self.base_url}/release-number', json={'id': self.number_id, 'apikey': self.api_key})
            logger.info(f"Released number {self.current_number}")

Note the tenacity retry decorators. Those save us when the free API randomly returns 503. Also, skip_number is essential — if the app silently blocks the number, we immediately discard it and grab a fresh one.

🔌 Integrating with Appium Page Objects

We’ll extend the registration page object to accept a temporary number service instance. The method register_with_temp_phone encapsulates the entire flow.

from appium.webdriver.common.appiumby import AppiumBy
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
import re

class RegistrationPage:
    def __init__(self, driver):
        self.driver = driver
        self.phone_input = (AppiumBy.ID, 'phoneNumberField')
        self.send_code_btn = (AppiumBy.ID, 'sendCodeButton')
        self.code_input = (AppiumBy.ID, 'verificationCodeField')
        self.verify_btn = (AppiumBy.ID, 'verifyButton')
        self.success_lbl = (AppiumBy.ID, 'registrationSuccess')

    def register_with_temp_phone(self, temp_service: TempPhoneService, country_code='US'):
        # 1. Get a fresh number
        phone = temp_service.get_number(country=country_code)
        # 2. Enter phone in app
        self.driver.find_element(*self.phone_input).send_keys(phone)
        self.driver.find_element(*self.send_code_btn).click()
        # 3. Wait for verification code
        try:
            sms_body, sender = temp_service.wait_for_sms(max_wait=60)
        except SMSCodeTimeoutError:
            # Capture screenshot and skip / raise
            self.driver.save_screenshot('sms_timeout.png')
            temp_service.skip_number()
            raise
        # 4. Extract 4‑6 digit code
        code_match = re.search(r'\b(\d{4,6})\b', sms_body)
        if not code_match:
            raise ValueError(f"No verification code found in: {sms_body}")
        code = code_match.group(1)
        # 5. Enter code and verify
        self.driver.find_element(*self.code_input).send_keys(code)
        self.driver.find_element(*self.verify_btn).click()
        # 6. Wait for success
        WebDriverWait(self.driver, 15).until(
            EC.visibility_of_element_located(self.success_lbl)
        )
        # 7. Release number (cleanup)
        temp_service.release_number()
        return phone

The page object method is now a one‑call command: no outside coordination needed.

🧪 A Complete Pytest Test Case

import pytest
from temp_phone_service import TempPhoneService
from pages.registration_page import RegistrationPage

@pytest.fixture(scope='function')
def temp_phone():
    service = TempPhoneService(api_key='my-test-key')
    yield service
    # Best effort cleanup
    try:
        service.release_number()
    except:
        pass

def test_user_registration_with_temporary_number(driver, temp_phone):
    reg_page = RegistrationPage(driver)
    try:
        phone = reg_page.register_with_temp_phone(temp_phone, country_code='US')
        # Additional assertions (e.g., welcome page, database)
        print(f"✅ Registered successfully with {phone}")
    except Exception:
        driver.save_screenshot('registration_failure.png')
        raise

💣 The Four Pitfalls I’ve Stumbled Into (So You Don’t)

1. The number is already dead on arrival.

Many temporary numbers are known to popular apps and blacklisted. The app never sends an SMS. Solution: the skip_number() method combined with a retry loop in the test (e.g., attempt up to 3 different numbers).

2. You get an SMS, but it’s from a previous session.

A reused number may receive dozens of old messages. If you just grab the latest without filtering by sender or time window, you might insert a stale code. Fix: check the timestamp returned by the API and only accept messages that arrived after you clicked “Send Code”.

3. The free API is about as stable as a sandcastle.

503 errors, empty responses, or connection timeouts happen regularly. That’s why we wrapped every API call with tenacity retries. Also set a global request timeout (5‑10 seconds) so your test doesn’t hang forever.

4. The target app actively blocks temporary numbers.

Some services maintain their own database of virtual numbers and refuse to send SMS to them. When you hit this, the API‑based temporary number approach fails entirely. Fallback options: switch to a different provider, use a real SIM bank (like the hardware module solution we covered earlier), or mock the SMS verification in your testing environment if possible.

🚀 Running in CI/CD Pipelines

When you move these scripts to Jenkins, GitHub Actions, or GitLab CI, keep these rules:

⚖️ A Quick Word on Responsibility

Temporary number services are powerful, but they walk a fine line. I use them exclusively for internal test automation on applications my team owns. Using them to bypass registration limits on third‑party services or to create fake accounts is almost certainly against the terms of service and may have legal consequences. Keep your automation ethical and within the boundaries of the software you’re authorized to test.

🏁 Wrapping Up

You now have a complete, runnable pattern for automating SMS verification with temporary numbers. The core takeaways: wrap the flaky API in retries, always have a skip‑and‑retry fallback for dead numbers, and integrate the entire flow into a single page object method so your tests stay clean and readable. Your regression suite can finally cross that registration hurdle without human hands.

Go ahead, plug it into your Appium project tonight, and watch those verification codes pour into your logs like magic.