Verify email quickstart Live
Send a one-time passcode over email and check it with two API calls. Email is the right channel when you are verifying an email address, when a user has no phone on file, or as a lower-cost path where SMS carrier fees are not worth it. This page takes you from an API key to a verified email in about 5 minutes.
Setup time: 5 minutesLive today$0.03 per successful verification
When to choose email
Choose email when the identifier you are verifying is an email address, or when you want a channel that needs no phone number and no carrier registration at all. Email has no country allowlist restriction, so it reaches users everywhere without an allowlist edit. Reach for SMS instead when you are verifying a phone number and want the immediacy of a text, and see voice for callers who prefer a spoken code.
Prerequisites
- A message.com workspace with Verify activated and a positive credit balance. See Get started with Verify.
- A Verify Service with
emailin itsenabledChannels(the default service enablesemailandsms). - An API key with the
verifyscope (mk_live_...). See Authentication. - No domain setup required: codes send from message.com's shared sending domain out of the box.
Full verification walk
Three steps every time: your backend starts a verification, the user receives the code in their inbox, and your backend checks the code they typed. You never see or store the code.
1. Send the code
Call POST /v1/verify with the email address as to and "email" as the channel. 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": "[email protected]",
"channel": "email"
}'curl -X POST https://api.message.com/v1/verify ^
-H "Authorization: Bearer mk_live_..." ^
-H "Content-Type: application/json" ^
-d "{\"to\": \"[email protected]\", \"channel\": \"email\"}"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: '[email protected]', channel: 'email' }),
});
const verification = await start.json();
// {
// id: '1f6a2e4c-...',
// status: 'pending',
// channel: 'email',
// hint: 'a•••@acme.com',
// 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": "[email protected]", "channel": "email"},
)
verification = start.json()
# {
# "id": "1f6a2e4c-...",
# "status": "pending",
# "channel": "email",
# "hint": "a•••@acme.com",
# "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": "email",
"hint": "a•••@acme.com",
"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 first character plus domain, masked, safe to render ("we emailed a code to a•••@acme.com").dispatch:sentmeans the email 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 an email whose subject and body come from the Verify Service. See subject and body customization below for the default copy and how to override it.
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.
The code is never in the API response, in logs, or in webhook payloads. It exists only in the email the user receives and as a salted hash on our side.
Subject and body customization
Unlike SMS, email content is customizable per Verify Service. Leave the service's emailSubject and emailBodyHtml unset and Verify sends a clean default built from the service friendlyName:
482913 is your Acme verification codeYour Acme verification code is 482913. It expires in 10 minutes. If you didn't request this, ignore this email.The Acme here is the service friendlyName: it is the sender brand across every code the service sends. The default HTML body is a self-contained, styled card with the code centered; the plain-text fallback is the line above.
To brand it yourself, PATCH the service with an emailSubject and/or emailBodyHtml. Both support three merge tokens that are substituted at send time:
{code}: the generated one-time code.{name}: the servicefriendlyName.{ttl}: the code lifetime in minutes.
curl -X PATCH https://api.message.com/v1/verify/services/vs_7hK2pQmN4rT8sX1c \
-H 'Authorization: Bearer mk_live_...' \
-H 'Content-Type: application/json' \
-d '{
"emailSubject": "Your {name} code: {code}",
"emailBodyHtml": "<p>Your code is <b>{code}</b>. It expires in {ttl} minutes.</p>"
}'A custom emailBodyHtml must contain the {code} token, otherwise the code never reaches the user. If you set a custom body but no subject, Verify keeps the default "{code} is your {name} verification code" subject. Full field constraints are on the Verify Services API page.
Fraud interactions
Email skips the country allowlist entirely (that check is phone-channel-only), but the send throttles, service velocity caps, and attempt cap all still apply:
- Per-destination throttle. More than 3 sends to the same address in 60 seconds returns
429 rate_limitedwithscope: "destination". - Per-service velocity. The service's per-minute and per-day caps (default 10/minute, 1,000/day) return
429 rate_limitedwithscope: "workspace". - Workspace ceiling. The workspace-wide daily total (default 5,000/day) returns
429 rate_limitedwithscope: "workspace_ceiling". - Attempt cap. Five wrong codes lock the verification with
too_many_attempts. Issue a fresh verification rather than retrying.
Velocity caps, workspace ceilings, and attempt limits 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. Email is live, so this points to a typo. Send "email" exactly. |
400 channel_not_enabled | The resolved service does not list email in enabledChannels. Add it, or send to a service that has it enabled. |
400 invalid_email | The to value fails the email-shape check. This is a shape check, not a deliverability check: validate the address client-side first. |
| Code missing from a custom email | The custom emailBodyHtml does not contain the {code} token. Add it. |
429 rate_limited | A send cap fired. Check the scope: destination, workspace, or workspace_ceiling. |
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.