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:
/checkreturns the row’s terminal state only. A 200 response carriesstatus: "approved"and nothing else; a failure surfaces in the error envelope (VALIDATION_ERROR422 withattempts_remaining,EXPIRED_TOKEN410 after expiry, 409 after a terminal state). The separatestatusfield onGET /api/v1/verify/:idis the polling fallback when you cannot consume webhooks./checkfailures land on the 30-minute mark. After the platform sees 30 wrong codes against one recipient in an hour, further/checkcalls return 429RATE_LIMIT_EXCEEDEDeven with freshverification_ids — the brute-force lockout is recipient-scoped, not row-scoped.
What not to do
- Do not spam
/verify/sendagainst 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
/sendagain to “resend” a code the user lost. A second/sendcreates a brand-new row and orphans the first; the user is now holding an old code for a row you are no longer checking. UsePOST /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, orfailed, every further/checkreturns 409 withrecovery_action: "request_new_code"— the only move that helps is a fresh/send. - Do not treat
VERIFY_FRAUD_BLOCKED,VERIFY_RND_REASSIGNED, orVERIFY_NUMBER_DEACTIVATEDas 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:
Escalation payload
If you have worked the cause table and the send or check still fails, email support@devotel.io with:- Your tenant id (returned as
organizationIdbyGET /api/v1/me, and shown in the dashboard under Settings → Organization). - The verification id (
vrf_…) of one affected row, or the bulkrequest_idif the batch itself is failing. - The recipient in the form you sent it (E.164 or email).
- 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, …).
See also
- Verify without an SDK — the end-to-end send/check flow, status model, webhook events, and rate-limit stack.
- Verify API reference — every endpoint, including bulk, cancel, and the analytics queries.
- Error codes reference — the platform-wide
error enum, including the
VERIFY_*cluster. - Webhook events reference —
verification.sent,verification.approved,verification.checked, and the fallback-engine events for driving recovery from your own systems. - Troubleshoot a message with no delivery receipt — the per-channel DLR layer beneath any Verify channel that dispatches a real message.