> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Troubleshooting: Verify OTP sends and checks failing

> Diagnose a verification code that never arrives, a /check that keeps failing, or a send rejected by a VERIFY_* error — map each error code to the gate that tripped it, and fix the cause instead of retrying.

# 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

| Symptom                                           | Most likely causes                                                                                                                             | What to do                                                                                                                                                                                                                                                                                                                                                |
| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **The code never arrives at the recipient**       | The send was accepted (201 + `pending`) but the carrier, mailbox, or Meta dropped it; or the route has no delivery receipts                    | Read `GET /api/v1/verify/:id` — `status: "pending"` means the row is fine and the delay is downstream. Check the Delivery Log for the per-channel receipt state. For SMS-specific non-delivery, work the [message sent but no delivery receipt](/troubleshooting/submitted-no-receipt) page — the OTP is a message on that pipeline once it is dispatched |
| **`/check` keeps failing on a code the user has** | Wrong `verification_id` ↔ code pairing, more attempts than the row allows, an expired row, or a resend invalidated the code the user is typing | Read the check response's error and `details`; match it against the status semantics section below                                                                                                                                                                                                                                                        |
| **The send itself is rejected**                   | A pre-send or per-recipient gate tripped: rate limit, resend cooldown, fraud policy, RND/deactivation guard, or an undeliverable email         | Find the error code in the cause table and fix the gate named there — retrying the same call never clears these                                                                                                                                                                                                                                           |

## 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.

| Code                            | HTTP        | Gate                                                                                                                                                         | Remediation                                                                                                                                                                                                            |
| ------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VERIFY_RECIPIENT_RATE_LIMITED` | 429         | Per-recipient hourly send cap (default 5 sends/hour/recipient, tenant-configurable; profile-level velocity caps override it when set)                        | Wait for the window to pass, or raise the cap if the use case legitimately sends more. Never retry inside the window — retries only burn API-key rate-limit budget                                                     |
| `VERIFY_RESEND_COOLDOWN`        | 429         | Per-recipient resend interval (\~30s) between sends to the same destination                                                                                  | Honour the window. Fix the user flow so the resend button goes through `POST /api/v1/verify/:id/resend`, not a fresh `/send`, and disable the button until the cooldown passes                                         |
| `VERIFY_FRAUD_BLOCKED`          | 403         | Tenant Fraud Guard: the destination is blocked by your anti-fraud policy (geo, high-risk operator, or composite risk score)                                  | Verify the destination is one you mean to OTP. If it is, review the fraud policy on the profile or tenant config that matched. If it is not, treat the block as the policy working — do not attempt to route around it |
| `VERIFY_RND_REASSIGNED`         | 422         | Pre-send guard against the FCC Reassigned Numbers Database (US +1 destinations) — the number has a permanent-disconnect record and may belong to someone new | Take the number out of your recipient list and re-verify the user's ownership of it before sending again. Treat this as a recipient-data freshness signal, not an error to retry                                       |
| `VERIFY_NUMBER_DEACTIVATED`     | 422         | Pre-send guard against the carrier deactivation/churn feed (NANP +1 destinations) — the number has a disconnect-presence record                              | Same as above: drop the destination until the recipient re-confirms the number is theirs and active                                                                                                                    |
| `VERIFY_EMAIL_UNDELIVERABLE`    | 422         | Email pre-send guard — the address's domain provably cannot receive mail (no MX record)                                                                      | Fix the address. Retrying the same address returns the same code                                                                                                                                                       |
| `VERIFY_SEND_FAILED`            | — (per-row) | Bulk-only code carried in a result row, not an HTTP status                                                                                                   | Read the row's `error.message` and `error.code` for the underlying gate, then fix that gate the same way as the single-send codes above                                                                                |

`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:

| Status     | Meaning                                                                                               | What to do                                                                                                                                                                                                                                                   |
| ---------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `pending`  | In-flight: the send was accepted and the row is waiting for a matching `/check` (or a terminal event) | Nothing — the row is healthy. Confirm whether the recipient actually received the message                                                                                                                                                                    |
| `approved` | A `/check` matched the code. The row is terminal and will never accept another check                  | Treat the user as verified and discard the `verification_id`. A further `/check` against this id returns 409 `Verification already processed` with `recovery_action: "request_new_code"` — that call is correct behaviour, not a second shot at the same row |
| `expired`  | The row outlived `expires_at` (default 10 minutes, or your profile's TTL)                             | Above 410 `EXPIRED_TOKEN`. The row can never be approved; mint a new verification. Driving a countdown from the `expires_at` returned by `/send` is the cleanest fix for users who hit this                                                                  |
| `failed`   | `max_attempts` wrong codes exhausted the row                                                          | Same as above — the row is terminal. Mint a new verification. Honour `attempts_remaining` in your UI so users see the countdown instead of surprise-failing on the last try                                                                                  |

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_id`s — 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.

| HTTP                 | Meaning                                                                                                                                          | Handling                                                                                                                                                                                |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `201`                | Every recipient was sent (`failed === 0`)                                                                                                        | Nothing to do                                                                                                                                                                           |
| `207` (Multi-Status) | At least one sent and at least one failed                                                                                                        | Read `results[]` and branch on `status` per row: `sent` rows carry `verification_id`; `failed` rows carry `error.code` / `error.message` and need gate-level fixing per the cause table |
| `400`                | Every recipient failed, and none of the failures was a genuine upstream outage                                                                   | Every row's `error` names a caller-side cause. Fix the cause in the payload — never blanket-retry the batch                                                                             |
| `503`                | Every recipient failed and every failure was a real upstream/softswitch delivery outage (`DELIVERY_FAILED` or `ALL_CHANNELS_FAILED` in each row) | Safe to retry with backoff; the failure is not in your payload                                                                                                                          |

Handling a 207 in code:

```ts theme={null}
const res = await fetch('https://api.orbit.devotel.io/api/v1/verify/bulk', {
  method: 'POST',
  headers: { 'X-API-Key': process.env.ORBIT_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({ recipients: tos, channel: 'sms' }),
});

const { data } = await res.json();

if (res.status === 207) {
  // Partial success — act row-by-row, never blanket-retry the batch.
  for (const row of data.results) {
    if (row.status === 'sent') {
      remember(row.recipient, row.verification_id);
    } else {
      // row.error.code / row.error.message name the gate per row.
      // Fix at the gate; queue or drop the recipient accordingly.
    }
  }
} else if (res.status === 201) {
  // All sent — nothing to fix.
} else if (res.status === 503) {
  // Genuine upstream outage — safe to retry the whole batch with backoff.
} else {
  // 400 — every row failed for a caller-side cause.
  // Read data.results[].error before retrying anything.
}
```

For the full request/response shape, see the
[Bulk send](/api-reference/verify#bulk-send) reference.

## Escalation payload

If you have worked the cause table and the send or check still fails,
email [support@devotel.io](mailto: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

* [Verify without an SDK](/guides/verify-no-sdk) — the end-to-end
  send/check flow, status model, webhook events, and rate-limit stack.
* [Verify API reference](/api-reference/verify) — every endpoint,
  including bulk, cancel, and the analytics queries.
* [Error codes reference](/reference/error-codes) — the platform-wide
  error enum, including the `VERIFY_*` cluster.
* [Webhook events reference](/reference/webhook-events) —
  `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](/troubleshooting/submitted-no-receipt)
  — the per-channel DLR layer beneath any Verify channel that dispatches
  a real message.
