message.comDevelopers

Verify integration best practices

The two-call shape of Verify (start, check) is simple enough to get working in minutes and easy enough to get subtly wrong in production: the wrong code length annoys users, the wrong resend logic burns your own rate limits, and skipping webhooks means polling for state you already have. This page is the checklist to work through before you ship.

Code length and TTL

A Verify Service's codeLength accepts 4 to 8 digits and defaults to 6. codeTtlSeconds accepts 60 to 900 seconds (1 to 15 minutes) and defaults to 600 (10 minutes). Both are per-service settings, not platform-wide, so different apps in the same workspace can run different values.

  • Six digits is the right default for almost everyone. It is what users expect from a login or signup code, and the gap between 6 and 8 digits buys you very little: the attempt cap (five guesses per verification, fixed and not configurable) already makes brute force impractical regardless of length. Go shorter (4 digits) only for a low-risk, high-friction-sensitive flow like confirming a phone number on a free trial signup, never for anything gating account access or payment.
  • Ten minutes is a reasonable default TTL; shorten it for anything sensitive. A code that gates a password reset or a payment method change should use a shorter TTL, typically 5 minutes (codeTtlSeconds: 300), so a code sitting unread in an inbox has a smaller window to be found and used by someone other than the intended recipient. A code for a low-stakes action (confirming an email address on signup) can keep the 10-minute default without meaningfully changing your risk.
cURL
curl -X PATCH https://api.message.com/v1/verify/services/vs_7hK2pQmN4rT8sX1c \
  -H 'Authorization: Bearer mk_live_...' \
  -H 'Content-Type: application/json' \
  -d '{
    "codeLength": 6,
    "codeTtlSeconds": 300
  }'

See the full field list, bounds, and defaults on Verify Services API.

Retry UX

Checking a code always returns 200, even on a wrong guess: read the verified field, not the HTTP status, and branch your UI on reason when verified is false. Build the input for exactly five attempts, since that is the fixed cap: showing a "3 attempts remaining" style counter (derived from your own client-side count, since the API does not return remaining attempts) is more honest to the user than a form that silently locks after their fifth guess with no warning.

When a verification hits too_many_attempts or expired, do not keep retrying the same id. Both are permanent, unrecoverable states for that verification: start a fresh one instead, which gives the user a new code and a new five-attempt budget. Surface this as a clear "request a new code" action rather than a generic error, since the underlying cause (a locked-out verification) is not something the user did wrong in a way retrying the same code fixes.

Resend semantics: there is no resend endpoint

Verify has no dedicated resend call. "Resend" in a Verify integration means calling POST /v1/verify again with the same destination. This issues a brand-new verification with its own id, its own code, and its own five-attempt budget. The old verification's row still exists and still reports its own status if you check it directly, but for practical purposes a resend supersedes the prior code: keep only the newest id in your session state, and discard the old one the moment the new call succeeds.

JavaScript
async function requestCode(to, channel) {
  const res = await fetch('https://api.message.com/v1/verify', {
    method: 'POST',
    headers: {
      Authorization: 'Bearer mk_live_...',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ to, channel }),
  });
  // A fresh call for the same destination issues a NEW verification and a
  // NEW id. The prior code stops working the moment the new one is checked
  // against, so hold only the newest id in your session.
  const verification = await res.json();
  return verification; // store verification.id, discard any older id
}

A naive resend button that fires on every click, or a form that calls POST /v1/verifyon every keystroke, will hit the destination throttle (3 sends per destination per 60 seconds) fast. Debounce the resend action, disable the button immediately after a click, and show a visible cooldown ("resend available in 45s") rather than letting the user hammer it into a rate limit. See destination and IP throttles for the exact numbers.

Because resending replaces the prior code, do not tell users "we sent the same code again": the code is different every time. Copy like "we sent a new code to •••0142" matches what actually happened.

Channel choice

SMS and email are the two channels live for sending today. Choose based on what you are verifying, not on preference: verify a phone number over SMS, verify an email address over email. Trying to verify a phone number by emailing its owner (or vice versa) does not actually establish that the person controls the channel you are checking.

If your flow only has an email address and you need phone-level assurance (or the reverse), collect the other identifier first and verify it directly. Voice, WhatsApp, push, and TOTP are real, typed channels in the API today and appear in every request and response shape; each currently returns 422 channel_not_available when you try to send with it, so design your UI around SMS and email as the two options a user actually sees, and add the others once they go live rather than building dead UI for them now. See Verify channel setup timesfor what is live and what each channel's setup path looks like.

Deliverability

  • SMS copy is fixed by design, which helps deliverability. Every SMS a Verify Service sends uses one of three pre-registered variants (standard, short, secure), with your service's friendlyName substituted as the sender brand. Free-text SMS bodies are not offered here on purpose: fixed, pre-approved message shapes are what let carriers and the registry clear the traffic without per-message review, and it is also why you cannot accidentally trigger a spam filter with unusual wording. Pick a variant that matches your brand voice once, in smsTemplateVariant, rather than trying to customize per send.
  • Set friendlyName to something the recipient will recognize.It appears directly in the message ("Your Acme verification code is 482913"), so a generic or unfamiliar value increases the odds a user ignores or reports the message. Match it to the brand name your users already associate with your app, not your internal workspace name.
  • Email subject lines are generated from the code by default("482913 is your Acme verification code"), which puts the code where most inbox previews show it and improves open-to-enter speed. If you set a custom emailSubject or emailBodyHtml, keep the code visible and unambiguous near the top; a heavily branded template that buries the code hurts completion rates more than it helps trust.

Webhook usage

Polling GET /v1/verify/:id to find out whether a user finished a verification works, but a webhook subscription removes the polling loop entirely. Verify emits three event topics: verify.started (a verification was created and dispatch was attempted), verify.succeeded (a check matched and the verification billed), and verify.failed (a verification reached a terminal failure state: hitting the five-attempt cap, or sitting unchecked past its expiry; a single wrong guess does not emit an event, only a terminal outcome does).

cURL
curl -X POST https://api.message.com/v1/verify/webhooks \
  -H 'Authorization: Bearer mk_live_...' \
  -H 'Content-Type: application/json' \
  -d '{
    "url": "https://yourapp.com/webhooks/verify",
    "topics": ["verify.succeeded", "verify.failed"]
  }'

Every delivery shares the same envelope: an eventId unique to that event (shared across every endpoint it fans out to), the topic string, a createdAt timestamp, and a data object holding the verification. The verification payload never includes the code itself, matching the API and log behavior everywhere else in Verify.

verify.succeeded delivery
{
  "eventId": "evt_9f2c6b7a9e0d5c3f",
  "topic": "verify.succeeded",
  "createdAt": "2026-07-25T18:14:22.000Z",
  "data": {
    "verification": {
      "id": "1f6a2e4c-9d3b-4a1e-8f2c-6b7a9e0d5c3f",
      "channel": "sms",
      "hint": "•••0142",
      "status": "verified",
      "serviceId": "vs_7hK2pQmN4rT8sX1c"
    }
  }
}

Verify subscriptions live on the same generic webhook substrate as the rest of the platform: create an endpoint with POST /v1/verify/webhooks, scope it to specific topics or use "verify.*" to catch all three, and optionally scope it to one Verify Service with serviceId if you run more than one service and want separate endpoints per app. Verify signs every delivery the same way the rest of the platform does; see webhook signature verification before you trust a payload in production, and webhook retries and idempotency since a redelivered event carries the same eventId and your handler should treat it as a duplicate, not a new event.

Use verify.succeededto drive server-side state (mark an account phone-verified, unlock a gated action) instead of relying solely on your frontend's check-call response reaching your backend. Use verify.failed to flag repeated lockouts against the same destination for your own abuse monitoring, on top of the guards Verify already runs; see Prevent SMS fraud with Verify for what those guards catch before a lockout even happens.

Pre-launch checklist

CheckWhy
Code length and TTL match the sensitivity of the actionShorter TTL for payment or account-recovery flows, default is fine for signup confirmation.
UI reads verified, not HTTP status, on checkA wrong code is a normal 200 response, not an error.
Resend button is debounced with a visible cooldownAvoids tripping the 3-per-destination-per-60-second throttle against real users.
Only the newest verification id is kept in session stateA resend supersedes the prior code; stale ids just fail checks.
friendlyName matches your user-facing brandIt appears verbatim in every SMS and the default email subject line.
Webhook endpoint subscribed to verify.succeeded / verify.failedRemoves polling and gives you a durable event for account state changes.
Webhook signature verified before trusting a payloadPrevents a forged request from claiming a verification succeeded.

Next steps