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 is verified: 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_limited with retry_after in 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

  1. Collect the number and normalize it to E.164 (+14155550132). Verify serves US and Canadian numbers.
  2. Call POST /v1/verify with phone (the SDKs name it to; the API accepts either). Optional app_name, up to 24 characters, puts your product name in the text. The response is 201 with a verification object: an id starting ver_, status: "pending", charged: false and an expires_at 10 minutes out.
  3. Send an Idempotency-Key header 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.
  4. 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.
  5. Call POST /v1/verify/check with phone and code. The response is { "verified": true, "status": "approved", "attempts": 1, "charged": true } on success. A wrong code returns verified: false with attempts_remaining; the fifth wrong attempt kills the code (max_attempts).
  6. Handle the other answers: 404 verification_not_found when no code is active for that number, 429 rate_limited on a resend inside the cooldown, and 403 verification_blocked with a reason when fraud protection refuses the destination.
  7. Optional: subscribe a webhook to verification.sent, verification.approved, verification.failed and verification.blocked for an audit trail, or read one verification back with GET /v1/verify/{id}.

Code

Send a code, then check it, with curl:

bash
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):

js
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):

python
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_name in 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; the 2fa use 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_blocked with charged: 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.

More in use cases