Step 1
Get an API key
Open an account, then create a key under Dashboard, API keys. Live keys (wmmn_live_sk_) send real traffic and charge your balance. Test keys (wmmn_test_sk_) run the same request through routing and pricing without delivering it, and draw on test credit, which comes with an invite code.
A key restricted to scopes needs verify:write. On a test key no real call is needed: POST /verify/{verificationId}/simulate-call approves a pending verification as if the user had called, and the response says simulated: true.
Every sample reads the key from the environment. Keep it out of source control.
export PACKETEXCHANGE_API_KEY=wmmn_live_sk_...Step 2
Fund the balance
PacketExchange is prepaid. Top up from $5 by card or crypto, or from $100 by wire, under Dashboard, Billing. An agent can top itself up with x402. There is no contract and no monthly fee.
Set up once. Buy a phone number in the DID Store, in the country of the people you verify (each call-in number verifies phone numbers in its own country). Switch on call-in for it on the Phone verification page of the control panel, or with POST /verify/call-in/numbers and its phoneNumberId from GET /verify/call-in/numbers. While call-in is on, calls to that number are used for verification only and do not ring. Then keep at least $0.01 in your balance.
Cost
The monthly price of the phone number you use, plus $0.01 per approved verification taken from your balance. A verification that expires without a call costs nothing, and there are no message or minute fees.
Step 3
Make the request
Start with channel "call_in" and the E.164 number to verify. reference is your own id for the user or session: starting again with the same reference, or the same number, while one is pending returns the pending verification instead of a new one. Then show the user callNumberDisplay, which is your call-in number for their country, and wait.
# 0. Once: switch on call-in for a phone number you bought in the DID Store, in the
# country you verify. Pick an active number with callInEnabled false; while call-in is
# on, calls to it are used for verification only and do not ring.
curl -s https://packetexchange.io/api/v1/verify/call-in/numbers \
-H "Authorization: Bearer $PACKETEXCHANGE_API_KEY" | jq '.data[] | {phoneNumberId, numberDisplay, verifiesDialCode, status, callInEnabled}'
# curl -s -X POST https://packetexchange.io/api/v1/verify/call-in/numbers \
# -H "Authorization: Bearer $PACKETEXCHANGE_API_KEY" -H "Content-Type: application/json" \
# -d '{"phoneNumberId": "<phoneNumberId>"}'
# 1. Start a call-in verification. reference is your own id for this user; starting again
# with the same reference while one is pending returns the pending one.
started=$(curl -s 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": "+17735550100", "channel": "call_in", "reference": "user-8812"}')
verification_id=$(echo "$started" | jq -r .data.verificationId)
# 2. Show your user the number to call: your call-in number for their country. The call
# ends by itself and is free for them.
echo "$started" | jq -r .data.instructions
# 3. Wait for the call: poll every 3 seconds (or handle the verification.approved webhook).
while :; do
status=$(curl -s "https://packetexchange.io/api/v1/verify/$verification_id" \
-H "Authorization: Bearer $PACKETEXCHANGE_API_KEY" | jq -r .data.status)
[ "$status" != "pending" ] && break
sleep 3
done
echo "$status"Step 4
Handle the response
Start returns the number to call and a ready-made instruction. Read the verification with GET /verify/{verificationId} until status leaves "pending", or subscribe to the verification.approved webhook and skip polling.
{
"success": true,
"data": {
"phoneNumberId": "<uuid>",
"number": "+17735550142",
"numberDisplay": "+1 (773) 555-0142",
"country": "United States",
"verifiesDialCode": "+1",
"type": "Local",
"status": "active",
"callInEnabled": true,
"hasCallRouting": false
}
}{
"success": true,
"data": {
"verificationId": "<uuid>",
"reference": "user-8812",
"to": "+17735550100",
"channel": "call_in",
"status": "pending",
"callNumber": "+17735550142",
"callNumberDisplay": "+1 (773) 555-0142",
"callerNumber": null,
"callReceivedAt": null,
"callerIdAttestation": null,
"expiresAt": "<ISO-8601 UTC>",
"createdAt": "<ISO-8601 UTC>",
"approvedAt": null,
"price": "0.010000",
"instructions": "Call +1 (773) 555-0142 from the phone you are verifying. The call ends by itself and is free; you are verified as soon as it connects."
}
}{
"success": true,
"data": {
"verificationId": "<uuid>",
"reference": "user-8812",
"to": "+17735550100",
"channel": "call_in",
"status": "approved",
"callNumber": "+17735550142",
"callNumberDisplay": "+1 (773) 555-0142",
"callerNumber": "+17735550100",
"callReceivedAt": "<ISO-8601 UTC>",
"callerIdAttestation": "A",
"expiresAt": "<ISO-8601 UTC>",
"createdAt": "<ISO-8601 UTC>",
"approvedAt": "<ISO-8601 UTC>",
"price": "0.010000",
"instructions": "<as above>"
}
}Values in angle brackets are placeholders for your own ids, times and amounts.
Response fields
- verifiesDialCode
- The dial code of the phone numbers a call-in number verifies: its own country.
- hasCallRouting
- true when the number rings somewhere today. Switching on call-in stops that ringing until you switch call-in off with DELETE /verify/call-in/numbers/{phoneNumberId}.
- status
- pending, approved or expired. Only approved proves a call arrived from the number.
- callNumberDisplay
- Your call-in number for the user's country, formatted for reading. callNumber is the same number in E.164, for a tap-to-call link.
- instructions
- A one-line instruction you can show as it is, or replace with your own words.
- expiresAt
- Call-in verifications expire after 300 seconds by default; set expirySeconds from 60 to 3600.
- callerIdAttestation
- When approved: the STIR/SHAKEN attestation the calling network attached (A, B or C), another verification status string from the network, or null when none was sent.
- reference
- Your own id, up to 128 letters, digits and . _ : @ - characters. Echoed on every read and webhook, and filterable with GET /verify?reference=.
When something goes wrong
- Call-in works in any country where you hold one of our phone numbers switched on for call-in. Starting one for a country where you have none is a 400 that says how to add a number, or to use channel sms or voice, so fall back to a code.
- Only an active number can be switched on; POST /verify/call-in/numbers on a number still being set up is a 400.
- A 402 INSUFFICIENT_BALANCE means the balance cannot cover the $0.01. Starts are rate limited per number and per account; a refusal is a 429 with RATE_LIMITED.
- POST /verify/check on a call-in verification is a 400: there is no code to check.
- A caller ID is a weaker proof than a code, because some networks let callers set it. Use call-in for sign-up and phone number confirmation; for account recovery, payments and other high-risk actions, ask for an SMS or voice code.
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid input",
"details": [
{ "path": "to", "message": "<why the field was refused>" }
]
}
}Step 5
Go further
- Buy a phone numberSearch the DID Store by country and digits, and buy the number your users will call.
- Get the result as a webhookSubscribe to verification.approved and verification.expired, and verify each signed delivery.
- Fall back to a codeWhere you hold no number, or for high-risk actions, send an SMS code with the same endpoint.
