message.comDevelopers

Verify TOTP quickstart Live

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. TOTP is factor-based rather than send-based: instead of one start call and one check call, your app creates a durable factor, the user verifies it once by scanning a QR code, and every check after that runs against the same factor.

Setup time: 1 hour$0.03 per successful check

TOTP does not use POST /v1/verify. That call is the send rail shared by SMS, email, voice, WhatsApp, and push; TOTP has no destination to send to, so calling it with channel: "totp" returns 422 channel_not_sendable pointing you back here. Use the four /v1/verify/totp/* endpoints on this page instead.

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 RFC 6238. By default the code is 6 digits and 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 to deliver 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.
  • An authenticator app on the user's device to scan the QR code with (Google Authenticator, Authy, 1Password, or any standard TOTP app).

Defaults and bounds

Every factor gets sane RFC 6238 defaults unless your app overrides them at creation. These are per-factor settings, not per-service: two factors on the same service can run different digit counts or periods.

SettingDefaultAllowed range
Digits66 to 8
Period30 seconds20 to 60 seconds
Skew1 time stepFixed, not caller-settable today
IssuerThe Verify Service's friendlyNameNot caller-settable: always the resolved service's name

Skew of 1 means the check accepts the current time step plus or minus one period, which absorbs ordinary clock drift between the user's device and the server without materially widening the guessable window.

Step 1: create a factor

Creating a factor mints a secret, encrypts it at rest, and returns the QR code your user scans. This is a reveal-once response: the raw secret and the otpauthUri it is embedded in appear on this call and never again on any other endpoint, including the list call. If you do not capture and display them now, the only recovery is deleting the factor and creating a new one.

POST/v1/verify/totp/factorsAuth: Bearer
FieldTypeDescription
accountNamerequiredstringLabel shown under the issuer in the authenticator app, typically the user's email or username. 1 to 254 characters.
serviceIdoptionalstringThe vs_... Verify Service to enroll against. Omit to use the workspace default. The service's friendlyName becomes the issuer.
digitsoptionalintegerCode length, 6 to 8. Defaults to 6.
periodoptionalintegerRotation period in seconds, 20 to 60. Defaults to 30.
cURL (macOS/Linux)
curl -X POST https://api.message.com/v1/verify/totp/factors \
  -H 'Authorization: Bearer mk_live_...' \
  -H 'Content-Type: application/json' \
  -d '{
    "accountName": "[email protected]"
  }'
cURL (Windows)
curl -X POST https://api.message.com/v1/verify/totp/factors ^
  -H "Authorization: Bearer mk_live_..." ^
  -H "Content-Type: application/json" ^
  -d "{\"accountName\": \"[email protected]\"}"
Node
const res = await fetch('https://api.message.com/v1/verify/totp/factors', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer mk_live_...',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ accountName: '[email protected]' }),
});
const { factor, secret, otpauthUri, qrDataUrl } = await res.json();
// Show qrDataUrl to the user now. secret and otpauthUri never appear on
// any other response, ever: store factor.id, discard the rest.
Python
res = requests.post(
    "https://api.message.com/v1/verify/totp/factors",
    headers={"Authorization": "Bearer mk_live_..."},
    json={"accountName": "[email protected]"},
)
body = res.json()
factor, secret, otpauth_uri, qr_data_url = (
    body["factor"], body["secret"], body["otpauthUri"], body["qrDataUrl"]
)
# Show qr_data_url to the user now. secret and otpauth_uri never appear on
# any other response, ever: store factor["id"], discard the rest.
201 Created
{
  "factor": {
    "id": "8f2c6b7a-9e0d-4a1e-8f2c-6b7a9e0d5c3f",
    "workspaceId": "b4d81f0a-2c5e-4f7b-9a3d-1e6c8f0b2a4d",
    "serviceId": "1f6a2e4c-9d3b-4a1e-8f2c-6b7a9e0d5c3f",
    "accountName": "[email protected]",
    "digits": 6,
    "period": 30,
    "skew": 1,
    "status": "pending",
    "createdAt": "2026-08-02T18:04:11.000Z",
    "lastStep": null,
    "verifiedAt": null,
    "lastUsedAt": null
  },
  "secret": "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP",
  "otpauthUri": "otpauth://totp/Acme:[email protected]?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Acme&algorithm=SHA1&digits=6&period=30",
  "qrDataUrl": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..."
}

Render qrDataUrl directly as an <img src>, it is a complete data URL. If your user is on the same device as the enrollment flow (no camera to scan with), show otpauthUri as a tappable link instead, most authenticator apps register as a handler for the otpauth:// scheme. The factor starts in pending status and cannot be checked yet.

Note the id shapes: factor.workspaceId and factor.serviceId in the response are internal row UUIDs, not the vs_... public id you pass in the request, and they are not accepted by other Verify endpoints. The only id you need from this response is factor.id: store it and use it for verify, check, and delete.

404 Not Found, unknown or cross-workspace serviceId
{
  "error": "service_not_found"
}
400 Bad Request, out-of-range digits or period
{
  "error": "validation_error",
  "details": {
    "digits": ["Number must be less than or equal to 8"]
  }
}

Step 2: verify the factor

After the user scans the QR code, have them type the current code their app shows back into your UI, then submit it here. The first correct code flips the factor from pending to active. This call is enrollment, not a billed check: it does not count against the $0.03 check fee and does not emit a verify.* event.

POST/v1/verify/totp/factors/:id/verifyAuth: Bearer
FieldTypeDescription
coderequiredstringThe current code from the user's authenticator app, matching the factor's configured digit length.
cURL
curl -X POST https://api.message.com/v1/verify/totp/factors/8f2c6b7a-9e0d-4a1e-8f2c-6b7a9e0d5c3f/verify \
  -H 'Authorization: Bearer mk_live_...' \
  -H 'Content-Type: application/json' \
  -d '{
    "code": "482913"
  }'
Node
const res = await fetch(
  `https://api.message.com/v1/verify/totp/factors/${factor.id}/verify`,
  {
    method: 'POST',
    headers: {
      Authorization: 'Bearer mk_live_...',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ code: userTypedCode }),
  },
);
const result = await res.json();
// success: { verified: true, status: 'active' }
// failure: 400 { error: 'bad_code' }
Python
res = requests.post(
    f"https://api.message.com/v1/verify/totp/factors/{factor['id']}/verify",
    headers={"Authorization": "Bearer mk_live_..."},
    json={"code": user_typed_code},
)
result = res.json()
# success: {"verified": True, "status": "active"}
# failure: 400 {"error": "bad_code"}
200 OK
{
  "verified": true,
  "status": "active"
}
400 Bad Request, wrong code
{
  "error": "bad_code"
}
409 Conflict, factor already active
{
  "error": "already_active"
}

A 404 { error: "not_found" } means the factor id does not exist or belongs to a different workspace than the calling key.

Step 3: check codes

Once a factor is active, every login or sensitive action checks a fresh code against it. This is the endpoint that bills: $0.03 per successful check, the same rate as every other Verify channel, charged only on verified: true.

POST/v1/verify/totp/checkAuth: Bearer
FieldTypeDescription
factorIdrequiredstringThe active factor's id, returned from the create call.
coderequiredstringThe current code from the user's authenticator app.
cURL (macOS/Linux)
curl -X POST https://api.message.com/v1/verify/totp/check \
  -H 'Authorization: Bearer mk_live_...' \
  -H 'Content-Type: application/json' \
  -d '{
    "factorId": "8f2c6b7a-9e0d-4a1e-8f2c-6b7a9e0d5c3f",
    "code": "119287"
  }'
cURL (Windows)
curl -X POST https://api.message.com/v1/verify/totp/check ^
  -H "Authorization: Bearer mk_live_..." ^
  -H "Content-Type: application/json" ^
  -d "{\"factorId\": \"8f2c6b7a-9e0d-4a1e-8f2c-6b7a9e0d5c3f\", \"code\": \"119287\"}"
Node
const res = await fetch('https://api.message.com/v1/verify/totp/check', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer mk_live_...',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ factorId: factor.id, code: userTypedCode }),
});
const result = await res.json();
// success: { verified: true }
// failure: { verified: false, reason: 'bad_code' | 'not_active' | 'not_found' | 'too_many_attempts' }
Python
res = requests.post(
    "https://api.message.com/v1/verify/totp/check",
    headers={"Authorization": "Bearer mk_live_..."},
    json={"factorId": factor["id"], "code": user_typed_code},
)
result = res.json()
# success: {"verified": True}
# failure: {"verified": False, "reason": "bad_code" | "not_active" | "not_found" | "too_many_attempts"}
200 OK, correct code
{
  "verified": true
}
400 Bad Request, wrong code
{
  "verified": false,
  "reason": "bad_code"
}
429 Too Many Requests
{
  "verified": false,
  "reason": "too_many_attempts"
}

A code that just succeeded cannot succeed again. The accepted time step is persisted atomically with the success itself, so even two concurrent requests submitting the identical valid code resolve to exactly one winner: the loser gets { verified: false, reason: "bad_code" }, the same response as a genuinely wrong code, not a special replay error. Do not build retry logic that resubmits a code that already returned verified: true.

Wrong guesses are capped: repeated failures on the same factor return 429 { verified: false, reason: "too_many_attempts" } and the secret is never even decrypted for that attempt. The cap is a rolling window on the factor, not a permanent lockout, so it clears with time rather than requiring the user to re-enroll.

List factors

Returns every factor in the workspace, scoped to whatever service filter you apply on your own side. The response never includes the encrypted secret or any form of the raw secret: this is the read surface for showing a user their enrolled devices, not a way to recover a lost QR code.

GET/v1/verify/totp/factorsAuth: Bearer
cURL
curl -X GET https://api.message.com/v1/verify/totp/factors \
  -H 'Authorization: Bearer mk_live_...'
200 OK
{
  "factors": [
    {
      "id": "8f2c6b7a-9e0d-4a1e-8f2c-6b7a9e0d5c3f",
      "workspaceId": "b4d81f0a-2c5e-4f7b-9a3d-1e6c8f0b2a4d",
      "serviceId": "1f6a2e4c-9d3b-4a1e-8f2c-6b7a9e0d5c3f",
      "accountName": "[email protected]",
      "digits": 6,
      "period": 30,
      "skew": 1,
      "status": "active",
      "createdAt": "2026-08-02T18:04:11.000Z",
      "lastStep": 61234567,
      "verifiedAt": "2026-08-02T18:05:02.000Z",
      "lastUsedAt": "2026-08-02T19:41:18.000Z"
    }
  ]
}

Delete a factor

Permanently removes a factor. Use this when a user disables two-factor authentication, loses their device, or wants to re-enroll: there is no way to reactivate a deleted factor and no recovery endpoint for its secret, which is stored encrypted at rest and never returned again after the creation response.

DELETE/v1/verify/totp/factors/:idAuth: Bearer
cURL
curl -X DELETE https://api.message.com/v1/verify/totp/factors/8f2c6b7a-9e0d-4a1e-8f2c-6b7a9e0d5c3f \
  -H 'Authorization: Bearer mk_live_...'
200 OK
{
  "deleted": true
}
404 Not Found
{
  "error": "not_found"
}

Why the generic start call rejects TOTP

POST /v1/verify is the shared send rail for SMS, email, voice, WhatsApp, and push: it mints a code, dispatches it to a destination, and bills nothing until the matching check succeeds. TOTP has no destination and nothing to dispatch, so the platform rejects it at that call rather than pretending to send:

cURL
curl -X POST https://api.message.com/v1/verify \
  -H 'Authorization: Bearer mk_live_...' \
  -H 'Content-Type: application/json' \
  -d '{
    "to": "[email protected]",
    "channel": "totp"
  }'
422 Unprocessable
{
  "error": "channel_not_sendable",
  "channel": "totp",
  "message": "totp is factor-based, not send-based: enroll and check codes via /api/v1/verify/totp/* instead of the send rail"
}

Every other typed error across every Verify endpoint, including this one, is documented in full on Verify API error codes.

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 limiting. Repeated wrong codes on the same factor return too_many_attempts, bounding brute-force guessing of the digit window without permanently bricking the credential.
  • Replay rejection. A code cannot be accepted twice, closing the short window where a leaked or observed code could otherwise be replayed before it rotates.
  • Short time window. The default 30-second rotation of the code itself limits how long any single leaked code stays useful.

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_sendableYou called POST /v1/verify with channel: "totp". TOTP is factor-based: use the four /v1/verify/totp/* endpoints on this page instead of the send rail.
422 channel_not_enabled on any factor endpointThe factor's service does not list totp in enabledChannels. Create, verify, and check all enforce this gate, and nothing is billed while the channel is disabled. Enable the channel on the service; existing factors survive a disable and work again once re-enabled.
Check returns reason: "not_active"The factor is still pending. Call the verify endpoint with a correct code first to activate it before checking.
Check returns reason: "bad_code"Wrong code, clock skew between the user's device and the server beyond the 1-step skew window, or the code already succeeded once (replay rejection returns the same reason as a wrong code).
Check returns reason: "too_many_attempts"The factor hit its attempt cap. Wait for the rolling window to clear rather than continuing to retry immediately.
Lost the QR code or secret after creationThere is no recovery endpoint by design: the secret is reveal-once. Delete the factor and create a new one.

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