Posted on 21/05/26 12:59 pm
Most guides on this make it look like a weekend project. It genuinely isn't — here's what actually matters and how to get it working fast.
You've built the login flow, the signup form, the email confirmation. Now someone on the team says "we should add phone verification too" and suddenly you're staring at Twilio's pricing page trying to figure out if you need a Messaging Service or just a plain phone number and whether the $20 trial credit covers international numbers or not.
Here's the thing: SMS OTP is actually pretty simple once you strip away the enterprise-y packaging. The concept is just four steps. Your app generates a short code, sends it to the user's phone, the user types it in, and your app checks it. That's the whole thing.
Let's get into how you actually build it.
Three things. An SMS API that can send messages programmatically, somewhere to temporarily store the code while you wait for the user to enter it, and two endpoints — one to send the code, one to verify it.
For the SMS API part, you want something with a clean REST interface, not a heavy SDK you have to vendor into your project. SMS Pin Verify's API is a good example — you send a POST request, get a number back, poll for the incoming SMS. Simple to reason about, easy to swap out later if you need to.
For temporary storage, Redis is the natural fit because you can set an expiry on the key automatically. No cron job to clean up old codes. If Redis isn't in your stack yet, a database table with a created_at column and a bit of cleanup logic works too — it's just a bit more to maintain.
Before the actual code, a few things that matter and are easy to get wrong the first time.
Never store the code in plaintext. Hash it — SHA-256 is fine here, OTP codes don't need bcrypt-level protection since they're short-lived and single-use. When the user submits their code, hash what they sent and compare it to what's stored. Matching means valid.
Rate limit the send endpoint. Without this, someone can hammer it and run up your SMS bill in minutes. Three attempts per phone number per ten minutes is a reasonable starting point. Block the fourth request entirely — don't silently drop it, return a proper 429 so the client knows to back off.
Delete the code immediately after a successful verification. Not after the session ends, not on next login — right then. A one-time password that can be used twice is just a password.
Set an expiry. Five minutes is the standard. Long enough that someone who got distracted can still finish. Short enough that a leaked code is worthless by the time anyone tries to use it.
User enters their phone number and hits submit.
Your backend generates a random 6-digit code.
You hash it and store the hash in Redis with a 5-minute TTL, keyed to the phone number.
You call the SMS API to send the code to that number.
User gets the text, types the code into your form.
Your backend hashes what they typed and compares it to what's in Redis.
Match? Delete the key, mark the number as verified. Done.
That's the full loop. Whether you're building this in Python, Node, Go, PHP — the logic is identical. The API call in step 4 and the Redis calls are the only things that change between languages.
The API call
The SMS Pin Verify API works with simple query parameters. You pass your API key, the country, the app you're verifying against, and get a virtual number back. Then you poll to get the incoming SMS. Full endpoint details are on the API page — the structure is clean and there's not much to it.
One thing worth knowing: the get_number endpoint and the get_sms endpoint are separate calls. You get the number first, then poll get_sms until the code arrives. Build in a retry with a short sleep between polls — don't hammer it.
Copy:
GET api.smspinverify.com/user/get_number.php
?customer=YOUR_API_KEY
&app=Google
&country=USA
And also copy:
GET api.smspinverify.com/user/get_sms.php
?customer=YOUR_API_KEY
&number=NUMBER_FROM_ABOVE
&country=USA
&app=Google
Testing it — without spending money on every test run
This is where a lot of people waste time early on. If you're testing with your own real number, you'll hit carrier delays, you'll burn through whatever free trial credits you have, and you can't easily simulate edge cases like expired codes or wrong number formats.
A much cleaner approach: use virtual numbers from SMS Pin Verify during development. Pick a number, point your test calls at it, watch the codes arrive in the dashboard in real time. It's fast, cheap, and you can test multiple countries without needing SIM cards for each one.
For the actual verification logic — the hash comparison, the expiry, the rate limiting — you can test all of that with a dummy SMS service that just returns a hardcoded code. Get the business logic solid first, then test the full SMS flow at the end.
Common things that go wrong
Phone number format is the most common one. Your API expects E.164 format (+12025551234) but users type numbers in every imaginable format. Strip everything that isn't a digit, then add the country code. The phonenumbers library handles the parsing in Python; libphonenumber-js does the same in Node.
The other one is not handling the "OTP not received yet" case gracefully. Your polling loop needs a timeout and a clear error message. "We sent you a code — if you don't see it after a minute, check that the number is correct and try again" is more useful than a generic error.
The full API reference for getting numbers and polling for SMS is at smspinverify.com/api.php. It's a short page — ten minutes to read, another ten to have a working test call running.
If you want to test your implementation with real virtual numbers — different countries, different apps — SMS Pin Verify has pay-as-you-go numbers starting from a few cents. No subscription needed.