# Send OTP codes and verify phone numbers by SMS

Source: https://joinsimplesms.com/use-cases/otp
Index: https://joinsimplesms.com/llms.txt

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](/docs/spend-limits) covers outbound messages only. Verifications are billed as usual and do not count toward it. The full table is on the [pricing page](/pricing).

## 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](/docs/verify): both calls, the enforced rules, fraud protection and sandbox codes
- [Sandbox and test numbers](/docs/sandbox): test keys and magic numbers
- [Errors](/docs/errors): the error envelope and every code
- [Webhooks](/docs/webhooks): `verification.*` events and signature checks
- [Opt-out, consent and TCPA](/docs/opt-out): why passcodes are exempt and how it is logged
- [API reference](/docs/api)
