How to Test an SMS Verification Flow End to End
Your signup flow sends a one-time code by SMS. Your unit tests mock the SMS provider and pass. Then a user in production reports that codes never arrive for their carrier, or arrive after the code has expired, or arrive with a template your parser does not recognise. None of that was ever going to show up in a mock.
This guide covers what to test at each layer, and how to run the last layer, a real code to a real number, from a script or a CI job.
The three layers of an OTP test
1. Unit tests, with the SMS sender mocked. Code generation, hashing and storage, expiry, attempt limits, resend cooldowns. Fast, deterministic, and where most of your logic bugs live. Mock at your own boundary (your sendSms() function), not deep inside the vendor SDK.
2. Provider sandbox or test credentials. Most SMS gateways have test credentials or magic numbers that accept a request without delivering anything. These prove that your request is well formed and that you handle the gateway's error responses. They do not prove delivery.
3. Real delivery to a real number. The only layer that catches carrier filtering, sender ID problems, template changes, latency, and region-specific failures. This is the layer most teams skip, because it traditionally meant a drawer of prepaid SIMs and someone reading codes off a phone.
The rest of this post is about layer 3.
What real-delivery tests catch
- Carrier filtering. US carriers filter application-to-person traffic that is not registered (10DLC, toll-free verification). An unregistered sender can look fine in your logs and never reach a handset.
- Latency. Your code expires in five minutes; the P99 delivery on a given carrier is four. You will not see that with a mock.
- Template drift. A copy change adds a link or reorders the message, and the code is no longer where your autofill hint or your support team expects it.
- Line-type rules. If you block VoIP numbers at signup, you need a test proving a real mobile number still passes, and ideally one proving a VoIP number is refused.
- Resend and rate-limit behaviour. Does the third resend actually send? Does the lockout message show?
Using a rented number as the test phone
Instead of physical SIMs, rent a real carrier number by API for the length of one test, read the SMS by API, and release it when you are done. The flow with the SMS Portal API:
- Order a number for the service you are testing. The response includes the number and an order id.
- Trigger your app's signup with that number.
- Wait for the code, either by polling the order or by receiving a webhook.
- Submit the code to your app and assert on the result.
- Release the number if nothing arrived, which cancels the order and returns the charge.
Every endpoint takes a bearer token and returns the same envelope: { "data": …, "success": true|false, "summery": "…" }. (Yes, summery. It is the field name, kept for compatibility with older integrations.)
Step 1: order a number
curl -X POST https://smsportal.io/api/v2/tn101/request-number \
-H "Authorization: Bearer $SMSPORTAL_TOKEN" \
-H "Content-Type: application/json" \
-d '{"service_key": "Google"}'service_key is the service you are verifying with. The list is at GET /api/v2/tn101/get-services. If your own app is not in the catalogue, pick a general-purpose entry from that list.
Steps 2 to 5: a test in Python
import os
import time
import requests
API = "https://smsportal.io/api/v2/tn101"
HEADERS = {"Authorization": f"Bearer {os.environ['SMSPORTAL_TOKEN']}"}
def call(path, **kwargs):
body = requests.request(url=f"{API}{path}", headers=HEADERS, timeout=30, **kwargs).json()
if not body["success"]:
raise RuntimeError(body["summery"])
return body["data"]
def wait_for_code(order_id, timeout_s=300, every_s=5):
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
detail = call("/get-number-details", method="GET", params={"order_id": order_id})
if detail["pin"]:
return detail["pin"], detail["message"]
time.sleep(every_s)
return None, None
def test_signup_with_real_sms(app_client):
order = call("/request-number", method="POST", json={"service_key": "Google"})
try:
started = time.monotonic()
app_client.start_signup(phone=order["number"])
code, text = wait_for_code(order["order_id"])
assert code is not None, "no SMS arrived before timeout"
latency = time.monotonic() - started
assert latency < 120, f"code took {latency:.0f}s, longer than we allow"
assert "Your code is" in text # the template you expect
assert app_client.verify(phone=order["number"], code=code).ok
except Exception:
# Nothing arrived, or the test failed before the code landed:
# release the number so the charge comes back.
call("/reject-number", method="POST", json={"order_id": order["order_id"]})
raiseThe get-number-details response carries pin (the extracted code) and message (the full text). Both are null until the SMS lands, so assert on the template with message, and on the flow with pin.
Webhooks instead of polling
Polling is fine for a handful of tests. For anything larger, register a webhook endpoint in the panel and subscribe to sms.received. Each delivery is a JSON envelope:
{
"id": "3f2b8c1e-…",
"type": "sms.received",
"created_at": "2026-09-28T12:00:00.000Z",
"data": {
"order_id": "12345",
"service": "Google",
"number": "…",
"status": "…",
"pin": "123456",
"message": "…",
"expires_at": "…",
"created_at": "…"
}
}The id is a UUID, unique per event; use it to de-duplicate retried deliveries. Other events you will want in a test harness: order.number_assigned, order.expired, order.cancelled and order.refunded. Failed deliveries are retried with backoff.
Verify the signature
Every delivery is signed. The signature is an HMAC-SHA256 over {timestamp}.{raw body} with your endpoint secret, sent as X-Webhook-Signature: sha256=<hex> alongside X-Webhook-Timestamp. Reject old timestamps to stop replays.
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verify(secret, rawBody, headers, maxAgeS = 300) {
const ts = Number(headers['x-webhook-timestamp'])
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > maxAgeS) return false
const expected = Buffer.from(
'sha256=' + createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex'),
)
const received = Buffer.from(String(headers['x-webhook-signature'] ?? ''))
return expected.length === received.length && timingSafeEqual(expected, received)
}Verify against the raw request body, before any JSON parsing. Re-serialising the parsed object changes whitespace and breaks the MAC.
Running it in CI
- Keep real-SMS tests in a separate job, nightly or pre-release rather than on every push. They cost money and take minutes.
- Put the token in your CI secret store, and use a dedicated account with a small balance so a runaway loop has a ceiling.
- Always release in a
finally/except. An order you abandon expires on its own, but releasing promptly returns the charge straight away and keeps your balance predictable. - Record latency as a metric, not just a pass/fail. A slow creep in delivery time is the early warning before users complain.
- Test the negative too. If your app refuses VoIP numbers, keep a known VoIP number in the suite and assert that it is refused.
When a rented number is the wrong tool
If you only need to prove your request reaches the gateway, use the gateway's test credentials, which are free and instant. If you need the same number for weeks (for example, testing login and account recovery on a long-lived account), use a rental rather than a one-time order, so the number does not go back into the pool between runs.
For endpoint-by-endpoint detail, see the SMS verification API page. The full reference and your tokens live in the panel under API access.
FAQ
What is an OTP testing API?
An API that gives your tests a real phone number and returns the SMS sent to it, so an automated test can complete a real verification without a human reading a phone. It complements, rather than replaces, mocking in unit tests.
Can I test SMS verification without a real phone number?
For logic, yes: mock your SMS sender. For delivery, no. Carrier filtering, latency and template problems only show up when a real message crosses a real carrier.
Should test numbers be VoIP or non-VoIP?
Match what your users have. Most real users are on mobile carrier lines, so your happy-path test should use one. If your app blocks VoIP, add a VoIP number as a negative test.