Skip to main content

Troubleshooting: caller-ID verification rejects

The caller-ID verification journey is two calls — register sends a one-time code to the handset, confirm submits it — and every reject it can return is one of four codes. The full flow, status lifecycle, and resend and revoke endpoints are on Manage outbound caller IDs; this runbook decodes what each reject means and where the fix lives. Reject-safety first: ALREADY_VERIFIED is deterministic steering, not a failure of your retry loop — treat it as “already done”. The other three are check-side outcomes; of them, only CHALLENGE_INVALID on a mismatched code is safe to retry, and only until the attempts counter or expires_at runs out.

The four codes

INVALID_CALLER_ID — the number fails acceptance

The number you passed failed acceptance — either E.164 shape (digits with an optional leading +, 8–20 characters) at register, or the ownership and verification check the outbound gate applies at dial time.
  • Shape — normalize to E.164 before calling POST /api/v1/voice/caller-ids/verify; the leading + may be omitted and is added back, but anything else malformed returns 422 before a challenge is dispatched.
  • Ownership at dial time — a dial refused because the presented number is neither an org-hosted number nor a verified caller ID returns UNVERIFIED_CALLER_ID instead; the fix is to register and confirm here first, then present. See the caller-ID FAQ entries for both the dial-time gate and the SIT-trunk VOICE_CALLER_ID_REJECTED variant.
Retries with the same malformed number return the same code — fix the number at the data layer.

ALREADY_VERIFIED — a no-op, not an error

Two paths return this, both as 409:
  • Register — a verified row already exists for the number. Re-verifying would burn an OTP dispatch and a wallet charge for nothing; the short- circuit is the platform saving you both. If you registered the number again under a different friendly name, revoke the existing row first, then re-register.
  • Confirm — the row bound to the verification_id is already verified. Your earlier confirm succeeded; the response was lost somewhere, but the state is settled. Read the current status from GET /api/v1/voice/caller-ids instead of re-submitting the code.
Treat 409 ALREADY_VERIFIED as an idempotent “already done” outcome. It belongs on the deterministic-steering side of your error handling — surface the row, don’t retry the call.

VERIFICATION_REJECTED — the check closed without approval

The confirm call reached the OTP check and the check’s terminal status was not approved — most commonly expired (the challenge outlived its TTL) or failed (the attempts budget ran out). The submitted code itself may have been correct; the challenge was already closed when it arrived. Recovery is at the challenge layer, deterministically:
  1. Resend while the row is still pending — the row is left in pending when the check rejects, so POST /api/v1/voice/caller-ids/:id/resend mints a fresh code on the same row without re-running register.
  2. Re-register after expiry — once the row flips to expired, only a fresh register (or resend before expiry) re-arms it.
The message in the error envelope carries the check’s terminal status (status=expired, status=failed) — read that before choosing resend vs. re-register. Driving your UI’s countdown from the expires_at register returned is the clean fix for stops on the third try.

CHALLENGE_INVALID — the per-attempt failure

The shared Verify challenge surfaces mint a challenge, then consume it one attempt at a time — an OTP code, a nonce, or a spoken possession phrase — and the per-attempt mismatch rejects with 400 CHALLENGE_INVALID. On caller-ID verify specifically that means the submitted code verification_id ↔ code pair did not match on that attempt. Each attempt decrements the challenge’s attempts budget; confirm reads the row only after the check, so budget exhaustion surfaces through VERIFICATION_REJECTED above. Retry is safe only while attempts_remaining (and expires_at) still hold — honour them in your UI so a user sees the countdown instead of a surprise hard-stop on the last try.

Deciding retry-safe vs deterministic

The last row is the register-side dispatch outcome — an OTP that never leaves the platform because a pre-send gate tripped. Work the gates tripped by rate limits and outbound state page when register fails before a challenge ever dispatches.

Ticket-grade sample

A fix never needs support until the four codes stop matching their fix. When they do, include:
  1. Tenant id — from GET /api/v1/me, or Settings → Organization in the dashboard.
  2. The reject code and its step — e.g. VERIFICATION_REJECTED on confirm, not “verify failed”.
  3. The numbers at confirm — the verification_id (from the register response), the caller-ID row id (cid_…), and the terminal status the error message carried.
  4. The number as passed — the E.164 string as it hit register.
  5. Attempts and expiryattempts_remaining if you surfaced it, and the register-returned expires_at for a “stopped on the third try” report.
That set lets support trace the register → confirm path without a back-and- forth, and separates a dispatch-side miss from a check-side reject.

See also