2FA API
Two-factor sign-in in two API calls
Call start when a user signs in and check with the digits they type. You never store the code, and you pay only for the message or call.
- Text or a spoken call
- Hashed, single-use codes
- Test keys return the code
Live from the marketplace
Price the exact number you will dial
Type any number in international format. Every live route that serves it comes back with its rate, billing increment and caller ID type, cheapest first.
2 routes serve +13125887000, cheapest first
United States (All Operators)
1/1 billing
$0.0092/msg
United States (All Operators)
1/1 billing
$0.02813/msg
Built for the sign-in flow
Everything two-factor needs, nothing to maintain
No code generator, no code table, no retry bookkeeping. Two endpoints carry the whole second factor, and the edge cases are handled on our side.

Two requests, no state on your side
Start returns a verification ID; check returns approved, denied, expired or max_attempts. Codes are 4 to 10 digits (6 by default), single use, and valid for 10 minutes unless you set 1 to 60.
A second factor for every user
Users on a landline, abroad or unable to read a text get the code by a call that reads it twice, in English, Spanish, French, German, Portuguese or Hindi.
Your brand on the code
Pass your brand and it is written into the text and spoken on the call. SMS codes go from it as an alphanumeric sender unless you pass your own.
The whole flow in CI
On a test key the start is routed and priced on a real route, nothing is delivered, and the reply carries the code as testCode so your tests can complete the check.
Call-in to confirm a number
At sign-up, users can confirm their number by calling one of your own phone numbers, bought in the DID Store in their country: no text to wait for and nothing to type, free for them, $0.01 to you when approved plus the number's monthly price. Keep codes for sign-in and recovery, where a caller ID alone is weaker proof.
Guessing and resend storms bounded
5 attempts per code, a 30-second resend cooldown per number, and a 429 with retryAfterSeconds your sign-in screen can show as a countdown.
How it works
The second factor in three steps
- Step 1
Your user passes the password step
Your server calls POST /verify/start with their number and a channel, sms or voice, plus your brand and language if you like. - Step 2
The code reaches them
As a text, or as a call from the caller ID you choose that reads the digits aloud. - Step 3
Check and let them in
POST /verify/check with the verification ID and what they typed. On approved, finish the sign-in.
# 1. Send the code. We generate it and keep only a hash.
curl -X POST https://packetexchange.io/api/v1/verify/start \
-H "Authorization: Bearer $PACKETEXCHANGE_API_KEY" \
-H "Content-Type: application/json" \
-H "X-Idempotency-Key: $(uuidgen)" \
-d '{
"to": "+14155550100",
"channel": "voice",
"from": "+14155550199",
"language": "es"
}'
# 2. Check what the user typed.
curl -X POST https://packetexchange.io/api/v1/verify/check \
-H "Authorization: Bearer $PACKETEXCHANGE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "verificationId": "<from step 1>", "code": "482913" }'import { randomUUID } from 'node:crypto';
const px = (path, body, headers = {}) =>
fetch(`https://packetexchange.io/api/v1${path}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PACKETEXCHANGE_API_KEY}`,
'Content-Type': 'application/json',
...headers,
},
body: JSON.stringify(body),
}).then((r) => r.json());
// 1. Send the code. We generate it and keep only a hash.
const start = await px(
'/verify/start',
{ to: '+14155550100', channel: 'voice', from: '+14155550199', language: 'es' },
{ 'X-Idempotency-Key': randomUUID() },
);
// 2. Check what the user typed: approved, denied, expired or max_attempts.
const check = await px('/verify/check', {
verificationId: start.data.verificationId,
code: userInput, // the digits your user typed
});
console.log(check.data.status);import os, uuid
import httpx
px = httpx.Client(
base_url="https://packetexchange.io/api/v1",
headers={"Authorization": f"Bearer {os.environ['PACKETEXCHANGE_API_KEY']}"},
)
# 1. Send the code. We generate it and keep only a hash.
start = px.post(
"/verify/start",
json={"to": "+14155550100", "channel": "voice", "from": "+14155550199", "language": "es"},
headers={"X-Idempotency-Key": str(uuid.uuid4())},
).json()["data"]
# 2. Check what the user typed: approved, denied, expired or max_attempts.
check = px.post(
"/verify/check",
json={"verificationId": start["verificationId"], "code": user_input}, # what they typed
).json()["data"]
print(check["status"])Voice channel shown, in Spanish. Change channel to sms for a text. Test keys return testCode instead of calling (sandbox credit is added with an invite code).
One request with your key. The same call works from any language, and every endpoint is documented with its fields and errors.
Every endpoint in the API reference3,062routes live on the marketplace now
The market, live
213 destinations with a live route. Prepaid from $5, platform fee 2% capped at $0.001.
- Platform fee
- 2%, capped at $0.001
- Top up from
- $5, card or crypto
Nothing worth stealing
Only an HMAC hash of each code is kept, and a code dies after five wrong guesses or its expiry.
Refused before routing
Premium-rate and high-risk ranges, embargoed destinations and your do-not-contact list are refused before anything is sent or billed.
Fails closed
If the limit store cannot be reached, the API refuses to send codes rather than send them without limits.
One key, one job
Scope a key to the verify permission and it can start and check codes and nothing else.
Questions
2FA API, answered
Anything else is in the help centre, or ask the team directly.
When should I use the voice channel instead of SMS?
Offer it as the second option on your code screen: for landlines, for users travelling without text roaming, and for anyone who prefers to hear the code. Start again with channel voice; the same check request completes it.
How long is a code valid, and how many tries does a user get?
Ten minutes by default, or anything from 1 to 60 minutes you set with expirySeconds. Five wrong attempts end the verification with max_attempts, and a used code cannot be checked again.
Do I have to store the code or compare it myself?
No. We generate the code, keep only its hash and do the comparison. You keep the verification ID for the few minutes between start and check.
What happens when a user taps resend too quickly?
A second code to the same number within 30 seconds is refused with a 429 that says how many seconds to wait, so your screen can show a countdown instead of an error.
Can the message and the call carry my brand?
Yes. Pass brand (up to 30 letters, digits and spaces) and it is used in the text and spoken on the call. Anything else in it is stripped, so a brand can never smuggle a link into a code message.
Can users verify by calling instead of typing a code?
Yes, in any country where you hold one of our phone numbers. Buy a number there in the DID Store, switch on call-in for it, then start with channel call_in and show the number we return; the user calls it from their phone, the call ends unanswered, and GET /verify/{id} or the verification.approved webhook tells you it worked. It suits confirming a phone number at sign-up. For the second factor at sign-in, account recovery or payments, use a code: a caller ID is weaker proof than a code the user has to read and type.
What does two-factor cost per sign-in?
One short SMS or one short call, at the route rate plus 2% capped at $0.001. There is no monthly fee and no fee per verification.
Ship two-factor sign-in
Free account and API key. Build it against a test key, then switch keys to go live. Prepaid from $5.
