Skip to main content

Troubleshooting: Verify OTP sends and checks failing

A Verify failure shows up one of three ways: the recipient never gets the code, /verify/check keeps failing against a code the user definitely received, or the send itself returns an error before a code ever leaves the platform. Work the symptom map first, then the cause table for the specific VERIFY_* code you are seeing. This page covers OTP-style makes (/verify/send, /verify/check, /verify/bulk). The MFA factor suite (TOTP, push, passkeys, backup codes) has its own endpoints and does not appear here — those failures surface in your own application’s factor flow, not on the verify row.

Symptom map

Cause table: every VERIFY_* code and its gate

The send path stacks gates in a fixed order. Any gate can return the codes below; each names the gate that tripped it, and the remediation is always at that gate — not in the response. SIM_SWAP_DETECTED (403) is a separate pre-send guard: the recipient’s SIM changed inside your block window and the code never leaves the platform. That one returns last_swap_date and block_window_hours in the response details and emits no webhook.

Status semantics: approved, pending, expired, failed

The verification row moves through a small state machine, and most check-side confusion resolves once you read the row rather than the UI: Two result-side notes:
  • /check returns the row’s terminal state only. A 200 response carries status: "approved" and nothing else; a failure surfaces in the error envelope (VALIDATION_ERROR 422 with attempts_remaining, EXPIRED_TOKEN 410 after expiry, 409 after a terminal state). The separate status field on GET /api/v1/verify/:id is the polling fallback when you cannot consume webhooks.
  • /check failures land on the 30-minute mark. After the platform sees 30 wrong codes against one recipient in an hour, further /check calls return 429 RATE_LIMIT_EXCEEDED even with fresh verification_ids — the brute-force lockout is recipient-scoped, not row-scoped.

What not to do

  • Do not spam /verify/send against a rate limit. Retries only count against the API-key rate limit; the recipient’s hourly cap, resend cooldown, and brute-force lockout do not clear on retry. A rejected body resent unchanged earns a faster rejection next time.
  • Do not try /send again to “resend” a code the user lost. A second /send creates a brand-new row and orphans the first; the user is now holding an old code for a row you are no longer checking. Use POST /api/v1/verify/:id/resend — same row, fresh code, cooldown still applies.
  • Do not answer a terminal row with more checks. Once a row is approved, expired, or failed, every further /check returns 409 with recovery_action: "request_new_code" — the only move that helps is a fresh /send.
  • Do not treat VERIFY_FRAUD_BLOCKED, VERIFY_RND_REASSIGNED, or VERIFY_NUMBER_DEACTIVATED as retryable. These are destination-level guards; retrying the same destination returns the same code, and the only fix is at the recipient-policy or destination-data layer.

Bulk sends (207 and all)

POST /api/v1/verify/bulk returns a batch-status code plus one result row per recipient, so the HTTP status is a summary — never the whole answer. Handling a 207 in code:
For the full request/response shape, see the Bulk send reference.

Escalation payload

If you have worked the cause table and the send or check still fails, email support@devotel.io with:
  1. Your tenant id (returned as organizationId by GET /api/v1/me, and shown in the dashboard under Settings → Organization).
  2. The verification id (vrf_…) of one affected row, or the bulk request_id if the batch itself is failing.
  3. The recipient in the form you sent it (E.164 or email).
  4. The error code from the response envelope (VERIFY_*, RATE_LIMIT_EXCEEDED, EXPIRED_TOKEN, or the row-level bulk code).
That set lets support trace the send through the per-recipient gates and the provider dispatch without a back-and-forth.

See also