# Send OTP codes and verify phone numbers by SMS

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

You build a phone verification step: your server asks TextKit to text a one-time code, the user types it into your app, and your server asks whether the code is right. TextKit 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 TextKit 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.jointextkit.com/v1/verify \
  -H "Authorization: Bearer $TEXTKIT_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.jointextkit.com/v1/verify/check \
  -H "Authorization: Bearer $TEXTKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "phone": "+14155550132", "code": "111111" }'
```

Node.js (`npm install jointextkit`):

```js
import { TextKit, TextKitError } from 'jointextkit';

const sms = new TextKit(process.env.TEXTKIT_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 TextKitError && err.code === 'rate_limited') {
      return { ok: false, retryInSeconds: err.retryAfter }; // resend cooldown
    }
    if (err instanceof TextKitError && 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 jointextkit`):

```python
from jointextkit import TextKit, TextKitError

client = TextKit()  # reads TEXTKIT_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 TextKitError 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 TextKit 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.
- **STOP blocks codes too.** A code to a number that replied STOP to your texts, or to the number the code comes from, answers `403 recipient_opted_out`: nothing is sent and nothing is billed. The person texts START to get codes again, so keep another route (email, for example) for them.
- **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): what a STOP blocks, codes included
- [API reference](/docs/api)
