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:
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:
TEXTKIT_API_KEY=ssms_sk_test_... npx jointextkit verify +14155550132
npx jointextkit verify +14155550132 482193 # check the codeOr with the official SDK, which adds retries, typed errors and automatic idempotency keys:
npm install jointextkitimport { 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):
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
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" }'{
"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:
{ "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
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" }'{
"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:
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:
{ "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:
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_ipcounts 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.
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).