Verify SMS quickstart Live
Send a one-time passcode over SMS and check it with two API calls. SMS is the default channel for phone-number verification: it reaches nearly every handset without an app install, and codes arrive in seconds. This page takes you from an API key to a verified phone number in about 5 minutes.
Setup time: 5 minutesLive today$0.03 per successful verification
When to choose SMS
Choose SMS when you are verifying a phone number and want the widest reach with the lowest user friction. It needs no app on the user's device, works on feature phones, and is the format users already expect for a login or signup code. Reach for a different channel when SMS is a poor fit: use email when you are verifying an email address rather than a phone, and consider voice as a fallback for users who did not receive a text or who prefer a phone call.
Prerequisites
- A message.com workspace with Verify activated and a positive credit balance. See Get started with Verify.
- A Verify Service with
smsin itsenabledChannels(the default service enablesemailandsms). - An API key with the
verifyscope (mk_live_...). See Authentication. - The destination country in the service's
allowedCountriesallowlist (default["US", "CA"]).
Full verification walk
The flow is always the same three steps: your backend starts a verification, the user receives the code on their phone, and your backend checks the code they typed. You never see or store the code yourself.
1. Send the code
Call POST /v1/verify with the phone number as to and "sms" as the channel. E.164 format (+14155550142) is preferred; digits with spaces, parentheses, dots, or hyphens are normalized server-side. Omit serviceId to use your default service.
curl -X POST https://api.message.com/v1/verify \
-H 'Authorization: Bearer mk_live_...' \
-H 'Content-Type: application/json' \
-d '{
"to": "+14155550142",
"channel": "sms"
}'curl -X POST https://api.message.com/v1/verify ^
-H "Authorization: Bearer mk_live_..." ^
-H "Content-Type: application/json" ^
-d "{\"to\": \"+14155550142\", \"channel\": \"sms\"}"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: 'sms' }),
});
const verification = await start.json();
// {
// id: '1f6a2e4c-...',
// status: 'pending',
// channel: 'sms',
// hint: '•••0142',
// dispatch: 'sent',
// expiresAt: '2026-07-25T18:14:11.000Z'
// }import requests
start = requests.post(
"https://api.message.com/v1/verify",
headers={"Authorization": "Bearer mk_live_..."},
json={"to": "+14155550142", "channel": "sms"},
)
verification = start.json()
# {
# "id": "1f6a2e4c-...",
# "status": "pending",
# "channel": "sms",
# "hint": "•••0142",
# "dispatch": "sent",
# "expiresAt": "2026-07-25T18:14:11.000Z"
# }A successful start returns 201 with a flat body:
{
"id": "1f6a2e4c-9d3b-4a1e-8f2c-6b7a9e0d5c3f",
"status": "pending",
"channel": "sms",
"hint": "•••0142",
"dispatch": "sent",
"expiresAt": "2026-07-25T18:14:11.000Z"
}id: store it so you can pass it to the check call.status: alwayspendingright after creation.hint: the last four digits of the number, masked, safe to render ("we texted a code to •••0142").dispatch:sentmeans the SMS left our systems. On a live channel this is the value you see on success.expiresAt: 10 minutes after creation by default, configurable per service (60 to 900 seconds).
2. User receives the code
The user gets a text from your Verify Service. The exact wording is determined by the service's smsTemplateVariant and its friendlyName. See template and branding below for the three variants and their exact copy.
3. Check the code
When the user types the code into your UI, 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: a wrong code is not an HTTP error.
The code is never in the API response, in logs, or in webhook payloads. It exists only in the SMS the user receives and as a salted hash on our side.
Template and branding
SMS copy is not free-form. Each Verify Service picks one of three pre-registered variants through its smsTemplateVariant field (standard, short, or secure), and the service's friendlyName is substituted as the brand name. Fixed message shapes are what let carriers and the registry approve the traffic, so free-text SMS is deliberately not offered here.
standard (default)
Your Acme verification code is 482913. It expires in 10 minutes. Don't share it with anyone. Reply HELP for help, STOP to opt out.short
Acme code: 482913. Expires in 10 min. Reply HELP for help, STOP to opt out.secure
482913 is your Acme verification code. We will never contact you to ask for it. Expires in 10 minutes. Reply HELP for help, STOP to opt out.The Acme in each example is the service friendlyName: set it when you create the service and it becomes the sender brand in every code the service sends. The expiry (10 minutes here) tracks the service's codeTtlSeconds, and the Reply HELP for help, STOP to opt out. footer is always present for compliance. To change the variant or the brand name, PATCH the service (see Verify Services API).
Fraud interactions
SMS is the channel most targeted by abuse (SMS pumping, where an attacker triggers floods of texts to numbers they control), so several guards apply to every send. They are on by default and documented in full on the fraud guide; the ones you will feel first:
- Country allowlist. A number whose country is not in the service's
allowedCountriesreturns403 country_not_allowedbefore any send. SMS is subject to this check; email is exempt. - Per-destination throttle. More than 3 sends to the same number in 60 seconds returns
429 rate_limitedwithscope: "destination". Do not resend on every keystroke. - Per-service velocity. The service's per-minute and per-day caps (default 10/minute, 1,000/day) return
429 rate_limitedwithscope: "workspace". - Attempt cap. Five wrong codes lock the verification with
too_many_attempts. Issue a fresh verification rather than retrying a locked one.
Country allowlists, velocity caps, and destination throttles are covered in depth in the Verify fraud guide.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
422 channel_not_available | The channel string is a typed channel but not live. SMS is live, so this points to a typo in the channel value. Send "sms" exactly. |
400 channel_not_enabled | The resolved service does not list sms in enabledChannels. Add it, or send to a service that has it enabled. |
403 country_not_allowed | The number's country is not in the service's allowedCountries. Add the country to the allowlist, or confirm the number is in a supported country. |
400 invalid_number | The to value fails the phone-shape check. Send digits, optionally with a leading +, spaces, parens, dots, or hyphens. |
429 rate_limited | A send cap fired. Check the scope: destination (too many to one number or IP), workspace (per-service velocity), or workspace_ceiling (workspace daily total). |
Check returns { verified: false, reason: "bad_code" } | Wrong code. Let the user retry up to five times, then issue a fresh verification. |
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.