Verify voice quickstart Live
Verify a phone number by placing a call that reads a one-time passcode aloud, digit by digit. Voice is the accessibility and fallback channel for phone verification: it reaches landlines, works when SMS is filtered or undelivered, and helps users who prefer a spoken code. Voice runs on message.com's own carrier stack, so there is no carrier registration to wait on: setup is a one-time per-workspace step, not a review queue.
Setup time: 1 hourLive today$0.03 per successful verification
When to choose voice
Choose voice when SMS is not reaching a user, when the number is a landline that cannot receive text, or when you want a fallback path after an SMS attempt goes unanswered. It is also the more accessible option for users who cannot read a text. For the widest reach and lowest friction on mobile numbers, start with SMS; use email when the identifier is an email address rather than a phone.
Prerequisites
- A message.com workspace with Verify activated and a positive credit balance. See Get started with Verify.
- A Verify Service with
voicein itsenabledChannels. - An API key with the
verifyscope (mk_live_...). See Authentication. - The destination country in the service's
allowedCountriesallowlist: voice is a phone channel and subject to the same allowlist as SMS. - Voice OTP delivery runs on message.com's own callout stack and is provisioned per workspace, which is the 1-hour setup step. See setup times.
Full verification walk
Three steps every time: start a verification, the user answers a call and hears the code, then check the code they typed. The shapes are identical to SMS; only the delivery channel differs.
1. Send the code
Call POST /v1/verify with the phone number as to and "voice" as the channel. E.164 format is preferred.
curl -X POST https://api.message.com/v1/verify \
-H 'Authorization: Bearer mk_live_...' \
-H 'Content-Type: application/json' \
-d '{
"to": "+14155550142",
"channel": "voice"
}'curl -X POST https://api.message.com/v1/verify ^
-H "Authorization: Bearer mk_live_..." ^
-H "Content-Type: application/json" ^
-d "{\"to\": \"+14155550142\", \"channel\": \"voice\"}"const start = await fetch('https://api.message.com/v1/verify', {
method: 'POST',
headers: {
Authorization: 'Bearer mk_live_...',
'Content-Type': 'application/json',
},
body: JSON.stringify({ to: '+14155550142', channel: 'voice' }),
});
const verification = await start.json();
// { id, status: 'pending', channel: 'voice', hint: '•••0142', dispatch: 'sent', expiresAt }import requests
start = requests.post(
"https://api.message.com/v1/verify",
headers={"Authorization": "Bearer mk_live_..."},
json={"to": "+14155550142", "channel": "voice"},
)
verification = start.json()
# { "id": ..., "status": "pending", "channel": "voice", "hint": "•••0142", "dispatch": "sent", "expiresAt": ... }A successful start returns 201 with the same flat body every channel uses:
{
"id": "1f6a2e4c-9d3b-4a1e-8f2c-6b7a9e0d5c3f",
"status": "pending",
"channel": "voice",
"hint": "•••0142",
"dispatch": "sent",
"expiresAt": "2026-07-25T18:14:11.000Z"
}dispatch: "sent" means the call was placed. Call-answer confirmation is asynchronous (a phone that rings and is not picked up still shows sent here), so treat this the same way you would treat an SMS carrier accepting a message for delivery.
2. User receives the code
The user answers a call and hears the code read out digit by digit. See the spoken template below for the exact script.
3. Check the code
The check call is channel-agnostic and works identically across channels. When the user types the code they heard, send it back with the id from step 1.
curl -X POST https://api.message.com/v1/verify/check \
-H 'Authorization: Bearer mk_live_...' \
-H 'Content-Type: application/json' \
-d '{
"id": "1f6a2e4c-9d3b-4a1e-8f2c-6b7a9e0d5c3f",
"code": "482913"
}'curl -X POST https://api.message.com/v1/verify/check ^
-H "Authorization: Bearer mk_live_..." ^
-H "Content-Type: application/json" ^
-d "{\"id\": \"1f6a2e4c-9d3b-4a1e-8f2c-6b7a9e0d5c3f\", \"code\": \"482913\"}"const check = await fetch('https://api.message.com/v1/verify/check', {
method: 'POST',
headers: {
Authorization: 'Bearer mk_live_...',
'Content-Type': 'application/json',
},
body: JSON.stringify({ id: verification.id, code: userSubmittedCode }),
});
const result = await check.json();
// success: { id: '1f6a2e4c-...', verified: true }
// failure: { id: '1f6a2e4c-...', verified: false, reason: 'bad_code' }check = requests.post(
"https://api.message.com/v1/verify/check",
headers={"Authorization": "Bearer mk_live_..."},
json={"id": verification["id"], "code": user_submitted_code},
)
result = check.json()
# success: {"id": "1f6a2e4c-...", "verified": True}
# failure: {"id": "1f6a2e4c-...", "verified": False, "reason": "bad_code"}A correct code returns 200 with { id, verified: true } and bills $0.03. A wrong code also returns 200, with verified: false and a reason. Read the verified field, not the HTTP status.
The spoken template
Voice reads the code one digit at a time and repeats it once, so the user has time to write it down. The script is built from the service friendlyName and the generated code:
Your Acme verification code is: 4, 8, 2, 9, 1, 3. Again, 4, 8, 2, 9, 1, 3. Goodbye.The Acme is the service friendlyName, the sender brand for the call. The spoken script is fixed (there are no voice template variants), and the digits are read out individually and then repeated for clarity.
Fraud interactions
Voice is a phone channel, so it inherits the same fraud controls as SMS. These apply on every send:
- Country allowlist. A number whose country is not in the service's
allowedCountriesreturns403 country_not_allowed. - Per-destination throttle. More than 3 sends to the same number in 60 seconds returns
429 rate_limitedwithscope: "destination". - Per-service velocity. The per-minute and per-day caps return
429 rate_limitedwithscope: "workspace". - Attempt cap. Five wrong codes lock the verification with
too_many_attempts.
Country allowlists, velocity caps, and destination throttles are covered in depth in the Verify fraud guide.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
400 channel_not_enabled | The resolved service does not list voice in enabledChannels. This is a per-service setting, distinct from the platform-wide channel_not_available. |
403 country_not_allowed | The number's country is not in the service's allowedCountries. Add the country to the allowlist. |
400 invalid_number | The to value fails the phone-shape check. Send digits, optionally with a leading +. |
dispatch: "error" on a 201 | The verification was created but the call could not be placed (a real carrier or trunk failure, not a provisioning gap). Retry the send. |
Check returns reason: "expired" | The code is past expiresAt. Start a new verification. There is no separate resend endpoint. |
Every reason above, with its exact firing condition, is on the Verify API error codes page.