# Verify

Source: https://jointextkit.com/docs/verify
Index: https://jointextkit.com/llms.txt

Phone verification in two calls. TextKit generates the code, sends it, enforces
expiry and attempt limits, and defends against SMS pumping. You never store a
code, and you don't need to own a phone number. Codes can also go
[by email](#by-email), or [move to email](#when-the-text-doesnt-arrive) when a
text doesn't arrive.

**Live keys need a live account.** Verify needs no number of your own, but
until your account is live a live key answers `403 live_access_required`.
An account goes live when the carriers verify its toll-free number, usually
in 3 to 7 business days. Build and test with a test key until then: the
[sandbox](#sandbox) answers like live, blocks included.

## The whole integration

Two calls, no dependency required:

```js
const send = (to) => fetch('https://api.jointextkit.com/v1/verify', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.TEXTKIT_API_KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ to }),
}).then((r) => r.json());

const check = (to, code) => fetch('https://api.jointextkit.com/v1/verify/check', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.TEXTKIT_API_KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ to, code }),
}).then((r) => r.json());
```

Or from the terminal: the CLI ships inside the SDK package, so `npx` needs
no install at all:

```bash
TEXTKIT_API_KEY=ssms_sk_test_... npx jointextkit verify +14155550132
npx jointextkit verify +14155550132 482193   # check the code
```

Or with the official SDK, which adds retries, typed errors and automatic
idempotency keys:

```bash
npm install jointextkit
```

```js
import { TextKit } from 'jointextkit';
const sms = new TextKit(process.env.TEXTKIT_API_KEY);

await sms.verify.send({ to: '+14155550132' });
const { verified } = await sms.verify.check({ to: '+14155550132', code });
```

In Python (`pip install jointextkit`, no dependencies):

```python
from jointextkit import TextKit
client = TextKit()  # reads TEXTKIT_API_KEY

client.verify.send(to="+14155550132")
verified = client.verify.check(to="+14155550132", code=code)["verified"]
```

## Send a code

```bash
curl -X POST https://api.jointextkit.com/v1/verify \
  -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "phone": "+14155550132" }'
```

```json
{
  "id": "ver_a1B2c3D4e5F6g7H8",
  "object": "verification",
  "channel": "sms",
  "phone": "+14155550132",
  "email": null,
  "status": "pending",
  "attempts": 0,
  "attempts_remaining": 5,
  "max_attempts": 5,
  "test": true,
  "created_at": "2026-08-07T12:00:00.000Z",
  "expires_at": "2026-08-07T12:10:00.000Z",
  "expires_in": 600,
  "charged": false
}
```

Send an `Idempotency-Key` header (or an `idempotency_key` body field) so
that a retried request, after a timeout for example, returns the first
verification instead of texting a second code. It works the same way as
[on messages](/docs/messages#idempotency). The response comes back as soon as
the code is sent; webhooks for it go out right after.

Optional `app_name` (24 chars max) puts your product's name in the message.
The body is a fixed TextKit template; you can't set the text, which is what
keeps verification traffic out of spam filtering.

## You don't need a phone number

Verify sends from TextKit's own verification numbers, registered under our 10DLC
campaign. You don't buy a number, you don't provision anything, and you pay no
monthly number fee; verification is the whole product.

The same recipient always gets codes from the same sender, so a second code
lands in the thread they already have.

If our verification numbers are not available, a live code goes out from one
of your own numbers that can already text anyone: a US local number whose
[sender status](/docs/numbers#sender-status) is `active`, or a
[verified toll-free number](/docs/numbers#toll-free-numbers). A number still
being registered is never used, because the carriers would refuse the
message.

A code sent from a verified toll-free number always names the business its
verification names. For a verification like "Acme Holdings Inc d/b/a Acme",
`app_name` may be the d/b/a ("Acme"), the legal name or the full name; left
out, codes use the d/b/a. Another name is replaced by the verified one, and
the response carries a `warning` saying so, so an integration keeps working
the day its number is approved. When no number is ready, the request
answers **400** `invalid_request` with a message that says so, and nothing
is sent or charged.

If you'd rather codes came from a number you own, pass it as `from`:

```json
{ "phone": "+14155550132", "from": "+16155550184" }
```

Platforms verifying for their own customers can pass `customer_id`; it is
echoed on the verification and its events and counted in that
[customer's usage](/docs/customers).

## Check the code

```bash
curl -X POST https://api.jointextkit.com/v1/verify/check \
  -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "phone": "+14155550132", "code": "482193" }'
```

```json
{
  "verified": true,
  "channel": "sms",
  "phone": "+14155550132",
  "status": "approved",
  "attempts": 1,
  "attempts_remaining": 4,
  "max_attempts": 5,
  "charged": true
}
```

A wrong code answers `verified: false` with the tries left. Checking a
verification that was already approved (a double submit, a retry) answers
**409** `verification_already_approved` with its `verification_id` and
`approved_at`, and nothing is charged again; `verification_not_found` means
no code was sent, or it expired. The phone field in errors is named as you
sent it (`phone` or `to`), and a number with a stray character in it
(`+12125551234x`) is refused rather than trimmed.

That's the whole integration.

## By email

Set `channel` to `"email"` and pass `email` instead of `phone`:

```bash
curl -X POST https://api.jointextkit.com/v1/verify \
  -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "channel": "email", "email": "ada@example.com", "app_name": "Acme" }'
```

Check it with `email` in place of `phone`:

```json
{ "email": "ada@example.com", "code": "482193" }
```

The email is plain text with no links: the code, your `app_name`, that it
expires in 10 minutes, and not to share it. It comes from TextKit, named
"Acme via TextKit". You can't change the text. Billing is the same as a
text: charged only when the code is checked.

Disposable addresses and domains that take no mail are refused with
`403 verification_blocked`. If email can't go out right now, the request
answers `503 channel_unavailable`, nothing is charged, and a text still works.

## When the text doesn't arrive

Move a pending text verification to email. It keeps its id, so it is still
one verification and one charge:

```bash
curl -X POST https://api.jointextkit.com/v1/verify/ver_a1B2c3D4e5F6g7H8/fallback \
  -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "email": "ada@example.com" }'
```

The email carries a new code, and the texted code stops working. **Check it
by `email`**: the code proves the email address, not the phone, so the
answer is `{ "verified": true, "channel": "email", "email": "ada@example.com" }`
and a check by `phone` answers `409 invalid_state` (no try is used). If
your sign-in is keyed on the phone, treat the person as having proved the
email address, not the number. The tries carry over (5 in all), and
the new code gets a fresh 10 minutes. A verification moves to email once,
while it is pending; after that the request answers `409 invalid_state`.
The email limits below apply.

## You only pay when it works

A verification is billed **only** when a code is actually verified. Wrong
codes, expiries, abandoned flows and anything Shield blocks are free;
every response tells you plainly with `charged`.

## Rules TextKit enforces for you

| | |
| --- | --- |
| Code lifetime | 10 minutes |
| Check attempts | 5, then the code is dead |
| Resend cooldown | 60 seconds per number |
| Per number | 5 an hour, 10 a day, counted per account |
| Per email address | 1 a minute, 5 an hour, 10 a day, counted per account (`ada+1@` and `ada@` are one address; so are Gmail's dotted spellings) |
| Per email domain | 30 an hour, 200 a day, counted per account on the registrable domain (`a.acme.com` and `b.acme.com` count together). Large mailbox providers (Gmail, Outlook, iCloud and similar) are counted by address only |
| Email codes per account | 100 a day (raised on request), within your daily verification limit; past it, `429 quota_exceeded`. A code that fails to send does not count |
| Codes sent per account | Your daily verification limit (2,000 on paid accounts, raised on request), texts and emails together; past it, `429 quota_exceeded` until the next day |

Statuses: `pending`, `approved`, `expired`, `max_attempts`, `blocked`.

## Shield

SMS pumping is fraud where someone farms revenue-share by triggering
verification codes to numbers they control. TextKit blocks it before you're
charged:

- **US and Canada only.** Country code +1 also covers Jamaica, the Dominican
  Republic and the Bahamas, the classic pumping destinations. We allowlist
  real US and Canadian area codes and reject the rest.
- **Velocity limits** per destination and per account, and per end user
  when you pass your user's IP address as `user_ip` (20 codes an hour per
  IP on your account). Pass it from your sign-up or login form: it stops one
  person requesting codes for many numbers. Requests come from your server,
  so without it there is no per-IP limit.
- **No VoIP.** Disposable VoIP numbers are rejected.
- **By email:** no disposable addresses, the domain must take mail, and the
  per-address and per-domain limits above. `user_ip` counts texts and
  emails together.

Free accounts can text codes only to numbers they have verified, and email
codes only to the account's own email address.

Blocked attempts return `403` with code `verification_blocked`, a `reason`,
and `charged: false` (velocity blocks add `Retry-After`):

| `reason` | What happened | Sandbox number |
| --- | --- | --- |
| `unsupported_region` | Not a US or Canadian number (or not a valid number) | `+15005550026` |
| `destination_cooldown` | A code went to this number in the last minute | `+15005550021` |
| `destination_velocity` | 5 codes to this number in an hour, or 10 in a day | `+15005550022` |
| `tenant_velocity` | Your account's hourly ceiling (300 codes) | `+15005550023` |
| `ip_velocity` | 20 codes an hour for one `user_ip` | `+15005550024` |
| `voip_not_supported` | A VoIP number | `+15005550025` |
| `toll_free_destination` | A toll-free number (800, 833, 844, 855, 866, 877, 888) | `+15005550027` |
| `domain_velocity` | Email only: too many codes to one email domain | |

## Sandbox

Test keys never send a real message. Any code you send returns a verification
whose code is `111111`, and these codes are deterministic:

| Code | Result |
| --- | --- |
| `111111` | approved |
| `000000` | invalid |
| `222222` | expired |
| `333333` | max attempts |

Sandbox has **no resend cooldown**, so you can iterate on a "resend code"
button freely. Live enforces 60 seconds per number. To test that path
deterministically, send to `+15005550003`; it always returns the cooldown
`429` with `retry_after` in the body.

The sandbox skips the Shield, so each block has its own number (table
above): sending a code to it answers the same `403 verification_blocked`,
`reason` and `Retry-After` a live key would get, and records a blocked
verification with a `verification.blocked` event.

By email, test keys never send anything: any address works, reserved
domains like `example.com` included, and the code is `111111`. A fallback
keeps `111111`. `cooldown@textkit.test` always returns the cooldown
`429`, and a disposable address is refused as in live.

## Retrieve a verification

`GET /v1/verify/{id}` returns the object with its current status, attempt
count, channel, and whether it was charged. It is the quickest way to debug
a sign-in flow: a code that "didn't work" shows here as `pending` with
tries used, `expired`, `max_attempts`, `approved` or `blocked`, and
`test: true` tells you a test key made it.

```bash
curl https://api.jointextkit.com/v1/verify/ver_a1B2c3D4e5F6g7H8 \
  -H "Authorization: Bearer ssms_sk_test_YOUR_KEY"
```

Every `verification.*` [webhook](/docs/webhooks) carries `channel`, and
`phone` or `email` (both, after a fallback; a fallback's
`verification.sent` has `fallback: true`).
