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. The first split is the channel type in the request’s channel value. Delivery channels (sms, whatsapp, email, voice, viber, telegram, rcs, flashcall) carry a code to the recipient, and a delivery-side failure (no route, blocked sender, no delivery receipt) is the job of the channel layer. Factor channels (totp, push, backup_code, sna, silent, magic_link) validate possession of a secret with no code sent — a factor value sent to /verify/send is short-circuited with a 422 and points at the matching /verify/factors/*, /verify/push/*, or /verify/passkey/* endpoint. The full split and per-channel behaviour are on the Verify overview.

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: If the 429 you actually received was the org-wide/per-API-key RATE_LIMIT_EXCEEDED code, the relevant layer is the rate-limits guides stack (per-key limit, org-wide cap, and per-recipient brute-force lockout that stacks on top). See Rate limits for the per-layer stack. 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), plus the channel the send attempted (sms, email, whatsapp, …).
That set lets support trace the send through the per-recipient gates and the provider dispatch without a back-and-forth.

See also