Phone verification looks like a two-hour feature. You send a code, the user types it back, you compare strings. Then it hits production and you discover that “receive an SMS” is not an operation your backend controls — it is an operation you wait on, with no guaranteed upper bound, across networks you have no visibility into.
This post is about the part nobody writes docs for: how you actually get the code into your application, and the specific ways each approach fails.
The three shapes of the problem
Most teams end up in one of these situations:
- You send the OTP yourself and need to test the receiving end during development and CI.
- A third party sends the OTP (a marketplace, an exchange, a social app) and you need to receive it programmatically to automate an account workflow.
- You’re QA’ing across countries and need numbers in specific regions because delivery behaviour is not uniform.
All three reduce to the same primitive: a number you can rent for a few minutes, plus a way to learn what arrived at it. For case 1 you can sometimes get away with a fixed test number and a stubbed provider. For 2 and 3 you need real numbers, which is where services like DogeSMS come in — you request a number for a given country and service, and read the message back over an API.
The interesting engineering question is how you read it back.
Polling: simple, and wrong in a specific way
The obvious implementation:
import time, requests
def wait_for_code(activation_id, timeout=180):
deadline = time.time() + timeout
while time.time() < deadline:
r = requests.get(f"{API}/activations/{activation_id}", headers=HEADERS, timeout=10)
r.raise_for_status()
data = r.json()
if data["status"] == "RECEIVED":
return data["code"]
time.sleep(3)
raise TimeoutError(activation_id)
Enter fullscreen mode Exit fullscreen mode
This works on your laptop. Here is what goes wrong when you run a few hundred of these.
The sleep interval is a rate limit in disguise. At sleep(3) and a 180-second window, one activation costs up to 60 requests. Run 50 concurrent verifications and you are issuing ~1000 requests/minute against an endpoint that almost always returns “nothing yet”. You will get throttled, and the throttling will look like a delivery failure in your logs.
Fixed intervals waste the fast path and starve the slow path. Codes usually land in 5–20 seconds. Occasionally they take 90. A constant 3-second poll is too slow for the common case and burns quota before the rare case resolves. Exponential backoff with a floor is better:
def intervals(start=1.0, factor=1.4, cap=8.0):
d = start
while True:
yield d
d = min(d * factor, cap)
Enter fullscreen mode Exit fullscreen mode
raise_for_status() inside the loop kills the whole activation on one blip. A single 502 from a load balancer should not lose a number you already paid for. Treat transport errors as “no news” and keep polling; treat only explicit terminal states from the API as terminal.
for delay in intervals():
if time.time() > deadline:
raise TimeoutError(activation_id)
try:
data = fetch(activation_id)
except (requests.Timeout, requests.ConnectionError, requests.HTTPError):
time.sleep(delay) # transport blip — not a verdict
continue
if data["status"] == "RECEIVED":
return data["code"]
if data["status"] in TERMINAL: # CANCELLED / EXPIRED / BANNED
raise ActivationFailed(data["status"])
time.sleep(delay)
Enter fullscreen mode Exit fullscreen mode
Webhooks: fewer requests, more edge cases
If the service supports callbacks, you register a URL and get a POST when a message arrives. Request count drops from ~60 to 1. In exchange you inherit every problem that comes with being an HTTP server.
You need a correlation key that exists before the message does. The callback arrives asynchronously with no memory of your request context. Store the activation id the moment you rent the number, keyed to whatever business object is waiting — otherwise you receive a code and have nowhere to put it.
Duplicates are normal, not exceptional. Any at-least-once delivery system will occasionally send the same event twice, and a retry after your 200 got lost looks identical to a genuine second message. Make the handler idempotent:
def handle_callback(payload):
key = f"otp:{payload['activation_id']}:{payload['message_id']}"
if not redis.set(key, "1", nx=True, ex=3600):
return 200 # already processed
store_code(payload["activation_id"], payload["code"])
return 200
Enter fullscreen mode Exit fullscreen mode
Verify the sender. An unauthenticated callback endpoint that writes OTP codes into your database is an open door. Check the signature header against a shared secret, compare with a constant-time function, and reject anything unsigned — including during development, because “we’ll add it later” is how it ships.
Return 200 fast, do work later. If your handler takes four seconds because it triggers downstream logic, you will get retried and see phantom duplicates. Acknowledge, enqueue, process.
Your dev machine is not reachable. This is the real reason teams stay on polling. A tunnel works but adds a moving part to CI. A reasonable compromise: webhook in production, polling in tests, behind one interface so the calling code doesn’t know the difference.
The hybrid that actually survives
Webhook as the primary path, with a slow poll as a safety net:
code = await wait_for_callback(activation_id, timeout=25)
if code is None:
code = poll_until(activation_id, deadline=90) # 5s interval, cheap
Enter fullscreen mode Exit fullscreen mode
You get the request savings in the 95% case and you stop caring whether a single callback was dropped. The cost is that both paths must write through the same idempotent store, or you will occasionally consume one code twice and fail a verification that actually succeeded.
Things that bite regardless of transport
Codes are not always digits. Parsing with d{6} breaks the first time a service sends an alphanumeric code or embeds a magic link instead. Prefer the parsed field the API gives you; fall back to the raw text.
More than one message can arrive. Users hit resend. The second message invalidates the first, but the first is what your regex found. Order by receive time and take the latest, not the first.
Country matters more than you expect. Delivery rates and latency vary by destination, and some services silently refuse ranges they classify as non-personal. If verification works in one country and fails in another with identical code, that is not a bug in your integration. Testing against several regions early is cheaper than debugging it after launch.
Time-box the whole thing at the business layer. A number rented for 20 minutes and a UI that waits forever is a resource leak with a nice spinner on it. Release explicitly when the user abandons the flow.
Wrapping up
The verification logic is trivial; the delivery path is not. If you take one thing from this: decide early whether the code reaches you by pull or by push, build one interface over both, and make the write idempotent. Everything above is a variation on those three decisions.
If you’re comparing services for the underlying numbers, I put together a longer breakdown of what to look for — coverage, API surface, and how pricing models differ — in this guide to SMS verification platforms.
답글 남기기