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:
/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).
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.