Verify

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, or move to email 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 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. 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 is active, or a verified toll-free number. 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.

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 lifetime10 minutes
Check attempts5, then the code is dead
Resend cooldown60 seconds per number
Per number5 an hour, 10 a day, counted per account
Per email address1 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 domain30 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 account100 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 accountYour 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):

reasonWhat happenedSandbox number
unsupported_regionNot a US or Canadian number (or not a valid number)+15005550026
destination_cooldownA code went to this number in the last minute+15005550021
destination_velocity5 codes to this number in an hour, or 10 in a day+15005550022
tenant_velocityYour account's hourly ceiling (300 codes)+15005550023
ip_velocity20 codes an hour for one user_ip+15005550024
voip_not_supportedA VoIP number+15005550025
toll_free_destinationA toll-free number (800, 833, 844, 855, 866, 877, 888)+15005550027
domain_velocityEmail 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:

CodeResult
111111approved
000000invalid
222222expired
333333max 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 carries channel, and phone or email (both, after a fallback; a fallback's verification.sent has fallback: true).