ReceiveSMS.ink

Testing SMS Verification Flows Without Burning Real Numbers

9 min read

Testing a signup flow that sends a text message is one of those tasks that looks trivial until you try to make it repeatable. This is what we have learned watching people integrate against our numbers, and what we would do differently if we were writing the tests.

Decide what you are actually testing

Most teams conflate three separate questions, then write one slow flaky test that answers none of them well:

Does my code generate and store an OTP correctly? That is a unit test. No network, no carrier, no phone. Stub the sender and assert on what you stored — expiry, attempt counter, single-use enforcement.

Does my integration with the SMS provider work? That needs a real API call, but not a real delivery. Most providers offer test credentials that accept a send and report success without touching a carrier.

Does a message actually arrive on a real handset? That is the only question needing a real number, and it is the one worth spending real numbers on. Run it rarely — a smoke test after deploy, not on every commit.

Once you separate them, the expensive test shrinks to a handful of runs a day and stops being the reason your pipeline is slow.

Do not poll an inbox from a test

The obvious implementation is a loop: send the code, then poll the inbox every second until something shows up or a timeout fires. It works on your machine and then fails in CI at three in the morning.

The problems compound. A fixed timeout is either too short, making the test flaky, or long enough that a hundred tests add ten minutes to the run. Polling a shared public number gives you someone else’s message. And when the test fails, the output tells you “timed out waiting for SMS”, which is the least useful failure message available — it cannot distinguish a broken send from a slow carrier.

Use a webhook instead. Point our webhook endpoint at a collector your test can query, and let delivery push to you. The test waits on an event rather than a clock, and when it fails you can tell whether the message arrived at all.

Use a private number for anything automated

Public numbers are shared, and that breaks automation in ways that are hard to debug. Two CI runs against the same number interleave. A test that greps the inbox for a six-digit string will happily match a stranger’s code and then fail on an assertion three steps later, pointing you at entirely the wrong thing.

A private number removes the whole class of problem: one number, one consumer, deterministic contents.

Match on more than six digits

A regex for \d{6} will match an order number, a timestamp fragment, or a price. Anchor on something specific to your message — the sender, a phrase from your template — and only then extract the digits. Assert on the sender too, or you are not testing what you think.

Verify the signature

Our webhook requests carry an HMAC-SHA256 signature, and the temptation in a test harness is to skip checking it. Do not — not because your test needs the security, but because the verification code is production code, and the test harness is the cheapest place to find out it is wrong. Teams discover their signature comparison was subtly broken far too late. The webhook documentation has the exact scheme.

Test the failures, not just the happy path

The happy path is the case least likely to break in production. Worth covering explicitly:

An expired code — travel the clock past expiry and assert rejection. A reused code — submit a valid code twice, assert the second attempt fails. Brute force — hit the endpoint with wrong codes and assert lockout after your threshold. Concurrent requests — request two codes in quick succession and pin down which one is valid, because that answer is usually accidental rather than designed. Delivery failure — make the provider return an error and assert the user sees something actionable rather than a spinner.

That last one matters more than the rest combined. Given how often carriers drop messages for reasons nobody controls, a flow with no path forward when the code never arrives is a flow that strands real users.

A word on scope

These numbers are for testing your own systems and for signups you are entitled to make. Automating registrations against a service in violation of its terms, or at volume, is outside what we allow — see the terms of service.

Related reading

For why deliveries fail in the first place, see the nine reasons codes do not arrive. For the mechanics of the codes themselves, what is an OTP code.