message.comDevelopers

Verify TOTP quickstart Disabled

TOTP (time-based one-time password) verifies a code that the user's own authenticator app generates, rather than a code you send them. It is the standard for app-based two-factor authentication: no message is transmitted, so there is no carrier cost, no delivery delay, and no SMS-interception risk. The committed check contract below is stable; the disabled-for-sending state is called out where it applies.

Setup time: 1 hour$0.03 per successful verification

TOTP is currently disabled. POST /v1/verify with channel: "totp" returns 422 with { "error": "channel_not_available", "channel": "totp" }. The verification check contract shown below is the same committed, channel-agnostic call every channel uses.

What TOTP is

With SMS, email, voice, and WhatsApp, Verify generates a code and delivers it to the user. TOTP inverts that: the code is produced on the user's device by an authenticator app (Google Authenticator, Authy, 1Password, and similar) from a shared secret and the current time, following the TOTP algorithm in RFC 6238. The code rotates every 30 seconds, and your backend verifies whatever the user reads off their app. Nothing is sent over any network to the user, which is what makes TOTP immune to SMS pumping and interception.

When to choose TOTP

Choose TOTP for account security on accounts that already have a password, where you want a second factor that costs nothing per check and cannot be intercepted in transit. It is the natural fit for a security-settings "enable two-factor authentication" flow. It is not a fit for first-contact verification of a phone or email you do not yet control: for that, send a code with SMS or email.

Prerequisites

  • A message.com workspace with Verify activated. See Get started with Verify.
  • A Verify Service with totp in its enabledChannels.
  • An API key with the verify scope (mk_live_...). See Authentication.
  • Enrollment logic on your side, where the user scans a QR code or enters a secret into their authenticator app. TOTP setup is enrollment on your side, not carrier registration on ours, which is why it sits at a 1-hour setup. See setup times.

The verification contract

The one piece of the Verify API that is committed and stable for TOTP today is the check call: your backend submits the code the user read off their authenticator app, scoped to a verification id, and gets back { id, verified }. This is the exact channel-agnostic check every channel uses.

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 returns 200 with verified: false and a reason. Read the verified field, not the HTTP status.

The TOTP enrollment surface (registering a secret, generating a provisioning URI or QR, and managing a user's factors) is not part of the committed API yet, so this page documents only the generic start and check contract, not TOTP-specific enrollment endpoints. The start call today is the same POST /v1/verify shape as every channel and returns 422 channel_not_available for totp.

Starting a TOTP verification today

The generic start call accepts channel: "totp" and returns the disabled response.

cURL (macOS/Linux)
curl -X POST https://api.message.com/v1/verify \
  -H 'Authorization: Bearer mk_live_...' \
  -H 'Content-Type: application/json' \
  -d '{
    "to": "user_8123",
    "channel": "totp"
  }'
422 Unprocessable (today)
{
  "error": "channel_not_available",
  "channel": "totp"
}

Fraud interactions

TOTP has a different threat model from the delivered channels. Because no code is transmitted, SMS pumping, interception, and delivery-based abuse do not apply, and there is no country allowlist or per-destination send throttle for a channel that never sends. The controls that remain are on the check side:

  • Attempt cap. Five wrong codes lock the verification with too_many_attempts, the same as every channel. This bounds brute-force guessing of the 6-digit window.
  • Short time window. The 30-second rotation of the TOTP code itself limits how long any single code is guessable.

Attempt limits and the rest of the Verify fraud model are covered in depth in the Verify fraud guide.

Troubleshooting

SymptomCause and fix
422 channel_not_availableExpected today: TOTP is disabled. Use SMS or email now. Do not retry this call for the same channel; it will not become available mid-session.
400 channel_not_enabledThe resolved service does not list totp in enabledChannels. This is a per-service setting, distinct from the platform-wide channel_not_available.
Check returns reason: "bad_code" (when live)Wrong code, or clock skew between the user's device and the server. Let the user retry with the current code; if skew is chronic, confirm their device time is set to automatic.
Check returns reason: "too_many_attempts"Five wrong codes locked the verification. Start a fresh verification rather than continuing to retry.

Every reason above, with its exact firing condition, is on the Verify API error codes page.