Send OTP codes and verify phone numbers by SMS
You build a phone verification step: your server asks SimpleSMS to text a one-time code, the user types it into your app, and your server asks whether the code is right. SimpleSMS generates the code, sends it, enforces expiry and attempt limits, and bills only when a code is verified. You never store a code and you do not need to buy a phone number.
What you build
- A "start" endpoint in your backend that takes the phone number a user typed and calls
POST /v1/verify. - A "confirm" endpoint that takes the code the user typed and calls
POST /v1/verify/check. When the answer isverified: true, you mark the phone as verified in your own database. - A resend button. Live mode enforces a 60 second cooldown per number. A resend inside the cooldown answers
429 rate_limitedwithretry_afterin the body, which is the number your countdown renders. - Error states for a wrong code, an expired code, too many attempts, and a blocked number.
The same two calls cover sign-up confirmation, a second factor at sign-in, a step-up check before a sensitive change, and confirming a new number when a user edits their profile.
How it works
- Collect the number and normalize it to E.164 (
+14155550132). Verify serves US and Canadian numbers. - Call
POST /v1/verifywithphone(the SDKs name itto; the API accepts either). Optionalapp_name, up to 24 characters, puts your product name in the text. The response is201with a verification object: an id startingver_,status: "pending",charged: falseand anexpires_at10 minutes out. - Send an
Idempotency-Keyheader on that call. If your request times out and you retry it, the same key returns the first verification instead of texting a second code. - The user receives a fixed-template message from a SimpleSMS verification number. The same recipient always gets codes from the same sender, so a second code lands in the thread they already have.
- Call
POST /v1/verify/checkwithphoneandcode. The response is{ "verified": true, "status": "approved", "attempts": 1, "charged": true }on success. A wrong code returnsverified: falsewithattempts_remaining; the fifth wrong attempt kills the code (max_attempts). - Handle the other answers:
404 verification_not_foundwhen no code is active for that number,429 rate_limitedon a resend inside the cooldown, and403 verification_blockedwith areasonwhen fraud protection refuses the destination. - Optional: subscribe a webhook to
verification.sent,verification.approved,verification.failedandverification.blockedfor an audit trail, or read one verification back withGET /v1/verify/{id}.
Code
Send a code, then check it, with curl:
curl -X POST https://api.joinsimplesms.com/v1/verify \
-H "Authorization: Bearer $SIMPLESMS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: signup-user-8841-attempt-1" \
-d '{ "phone": "+14155550132", "app_name": "Acme" }'
curl -X POST https://api.joinsimplesms.com/v1/verify/check \
-H "Authorization: Bearer $SIMPLESMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "phone": "+14155550132", "code": "111111" }'Node.js (npm install joinsimplesms):
import { SimpleSMS, SimpleSMSError } from 'joinsimplesms';
const sms = new SimpleSMS(process.env.SIMPLESMS_API_KEY);
// Step 1: the user submitted their phone number.
export async function startVerification(phone) {
try {
const verification = await sms.verify.send({ to: phone, appName: 'Acme' });
return { ok: true, expiresAt: verification.expires_at };
} catch (err) {
if (err instanceof SimpleSMSError && err.code === 'rate_limited') {
return { ok: false, retryInSeconds: err.retryAfter }; // resend cooldown
}
if (err instanceof SimpleSMSError && err.code === 'verification_blocked') {
return { ok: false, blocked: err.reason }; // not charged
}
throw err;
}
}
// Step 2: the user typed the code.
export async function confirmVerification(phone, code) {
const result = await sms.verify.check({ to: phone, code });
// result.charged is true only when result.verified is true
return { verified: result.verified, attemptsLeft: result.attempts_remaining };
}Python (pip install joinsimplesms):
from joinsimplesms import SimpleSMS, SimpleSMSError
client = SimpleSMS() # reads SIMPLESMS_API_KEY
def start_verification(phone: str) -> dict:
try:
verification = client.verify.send(to=phone, app_name="Acme")
return {"ok": True, "expires_at": verification["expires_at"]}
except SimpleSMSError as err:
if err.code == "rate_limited":
return {"ok": False, "retry_in_seconds": err.retry_after}
if err.code == "verification_blocked":
return {"ok": False, "blocked": err.reason}
raise
def confirm_verification(phone: str, code: str) -> bool:
return client.verify.check(to=phone, code=code)["verified"]With a test key (ssms_sk_test_YOUR_KEY) no real message is sent and the outcomes are fixed: code 111111 is approved, 000000 is invalid, 222222 is expired and 333333 is max attempts. The sandbox has no resend cooldown; send to +15005550003 to get the cooldown 429 on demand.
Compliance notes
- The request is the consent. A code goes out because a person typed their number into your form and asked for it. Send codes only in response to that action, never on a schedule or to a list.
- You cannot change the message text. The body is a fixed template with your
app_namein it. That is what keeps verification traffic out of carrier spam filtering, and it means Verify cannot carry marketing or any other content. - No registration for the default path. Codes are sent from SimpleSMS verification numbers that are already registered with the carriers. If you pass your own number as
from, that number needs its own carrier registration; the2fause case exists for it. - Opt-outs do not block codes. A user who replied STOP to your other texts still receives a code they asked for, because blocking it would lock them out of their own account. Each such send is written to the consent ledger and emits
verification.sent_to_opted_out, so the exemption stays auditable. - Fraud protection runs before you are charged. Real US and Canadian area codes are allowlisted and other +1 destinations are rejected, disposable VoIP numbers are rejected, and velocity limits apply per destination, per account and per source. A blocked attempt answers
403 verification_blockedwithcharged: false.
What it costs
- $0.025 per successful verification. Sending a code costs nothing by itself. Wrong codes, expired codes, abandoned flows and blocked attempts are free, and every response says so in
charged. - No number fee. The default sender is ours, so there is no $0.95 monthly number to rent for verification.
- Free tier: 10 verifications a month with no credit card. Test keys are never metered.
- Worked example: 1,000 sign-ups start verification and 800 finish. You pay for 800: $20.00.
A monthly spend limit covers outbound messages only. Verifications are billed as usual and do not count toward it. The full table is on the pricing page.
Limits and caveats
- US and Canada only. A number outside those two countries is rejected, including other countries that share the +1 code.
- Codes are delivered by SMS. A user on a VoIP number cannot be verified this way, so keep another route (email, for example) for them.
- The rules are fixed: codes live 10 minutes, allow 5 check attempts, can be resent after 60 seconds, and one number can receive at most 5 codes an hour and 10 a day.
- Paid accounts have an abuse ceiling of 2,000 verifications a day, raised on request.
- Live keys are enabled after a short live-access review. Until then the sandbox simulates every outcome.
- A successful check proves that someone could read a text sent to that number at that moment. It says nothing else about who they are.
Related docs
- Verify: both calls, the enforced rules, fraud protection and sandbox codes
- Sandbox and test numbers: test keys and magic numbers
- Errors: the error envelope and every code
- Webhooks:
verification.*events and signature checks - Opt-out, consent and TCPA: why passcodes are exempt and how it is logged
- API reference