message.comDevelopers

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 email in its enabledChannels (the default service enables email and sms).
  • An API key with the verify scope (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 (macOS/Linux)
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 (Windows)
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\"}"
Node
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'
// }
Python
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:

201 Created
{
  "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: always pending right after creation.
  • hint: the first character plus domain, masked, safe to render ("we emailed a code to a•••@acme.com").
  • dispatch: sent means 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 (macOS/Linux)
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 (Windows)
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\"}"
Node
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' }
Python
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:

Default subject
482913 is your Acme verification code
Default plain-text body
Your 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 service friendlyName.
  • {ttl}: the code lifetime in minutes.
cURL (macOS/Linux)
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_limited with scope: "destination".
  • Per-service velocity. The service's per-minute and per-day caps (default 10/minute, 1,000/day) return 429 rate_limited with scope: "workspace".
  • Workspace ceiling. The workspace-wide daily total (default 5,000/day) returns 429 rate_limited with scope: "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

SymptomCause and fix
422 channel_not_availableThe channel string is a typed channel but not live. Email is live, so this points to a typo. Send "email" exactly.
400 channel_not_enabledThe resolved service does not list email in enabledChannels. Add it, or send to a service that has it enabled.
400 invalid_emailThe 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 emailThe custom emailBodyHtml does not contain the {code} token. Add it.
429 rate_limitedA 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.