message.comDevelopers

Verify API error codes Live

Every machine-readable reason the Verify API can return, across creating a verification, checking a code, and reading a verification, with the HTTP status it comes back with, exactly when it fires, and what to do about it. This list is source-verified against the server: nothing here is invented, and any reason the server exposes that is not on this list is a bug, not a doc gap.

Three of these reasons are the ones most docs get wrong: already_used, expired, and too_many_attempts all return HTTP 200, not 404 or 410. Check the reason field, not the status code, when handling a failed check.

Creating a verification: POST /v1/verify

These fire in the order listed, top to bottom. A request stops at the first guard it fails, so a call that fails country allowlisting never reaches the rate limiter, and a call that fails validation never touches your credit balance.

StatusReasonWhen it firesWhat to do
400validation_errorRequest body fails schema validation: missing to or channel, to outside 3 to 254 characters, channel not a recognized string, or a malformed serviceId. The response includes a details object with field-level messages.Fix the request shape. This is a client bug, not a runtime condition to handle at scale.
400bad_channelDefensive check inside the shared pipeline for a channel string that is not a member of the typed channel union. In practice this is unreachable through the public API: the route-level schema validates channel against the same enum first and returns validation_error for anything outside it. Kept in the reference because the pipeline can return it and other callers of the shared pipeline exist.Same as validation_error: use one of the seven documented channel values.
422channel_not_availableThe channel is a real, typed VerifyChannel but is not live for sending today: everything except email and sms. Response body echoes the channel: { error, channel }. See setup times for which channels are live.Use email or sms today. Do not retry this call for the same channel; it will not become available mid-session.
400invalid_emailChannel is email and to fails a basic email-shape check.Validate the address client-side before calling. This is a shape check, not a deliverability check.
400invalid_numberChannel is not email (currently only reachable via sms, since other non-email channels fail earlier at channel_not_available) and to fails a permissive phone-shape check.Send digits, optionally with a leading +, spaces, parens, dots, or hyphens. Normalization happens server-side.
404service_not_foundRequest included a serviceId that does not resolve to a service in the caller's workspace.Confirm the vs_... id belongs to this workspace, or omit it to use the default service.
400no_default_serviceRequest omitted serviceId and the workspace has no Verify Service at all yet.Create a Verify Service first (Verify Services API). The first one you create becomes the default automatically.
400channel_not_enabledThe resolved service exists, but its enabledChannels list does not include the requested channel. Different from channel_not_available: this is a per-service configuration choice, not a platform-wide liveness state.Add the channel to the service's enabledChannels, or send to a different service that has it enabled.
403country_not_allowedChannel is sms or another non-email channel, and the destination's resolved country is not in the service's allowedCountries list (default ["US", "CA"]). Unknown-prefix numbers are rejected unless the service allows the "*" wildcard. Email is exempt from this check entirely.Add the country to the service's allowlist, or confirm the number is in a supported country.
429rate_limited, scope: "workspace"The Verify Service's per-minute cap (default 10/minute) or per-day cap (default 1,000/day) was hit. Both share this scope value.Back off and retry after the window rolls. Raise perMinuteCap / perDayCap on the service if the workspace has outgrown the defaults.
429rate_limited, scope: "workspace_ceiling"The workspace-wide daily ceiling (default 5,000/day, across every Verify Service combined) was hit.This is a workspace-level backstop, not a per-service knob. Contact support to raise it if legitimate volume needs it.
402insufficient_creditsThe workspace's credit balance gate failed: zero or negative balance, or the workspace is over its configured spend cap.Top up the workspace balance. See the Billing API.
429rate_limited, scope: "destination"The engine-level send throttle fired: more than 3 sends to this exact destination in 60 seconds, or more than 10 sends from this source IP in 60 seconds. This is the last guard before dispatch, so it can fire even after every earlier check passes.Do not resend on every keystroke or page reload. Debounce resend UI and give users clear feedback that a code is already in flight.

Example bodies

400 validation_error
{
  "error": "validation_error",
  "details": {
    "to": ["String must contain at least 3 character(s)"]
  }
}
429 rate_limited, scope workspace
{
  "error": "rate_limited",
  "scope": "workspace"
}
429 rate_limited, scope workspace_ceiling
{
  "error": "rate_limited",
  "scope": "workspace_ceiling"
}
429 rate_limited, scope destination
{
  "error": "rate_limited",
  "scope": "destination"
}

Checking a code: POST /v1/verify/check

The authenticated (keyed) check call returns 200 for every outcome except an unknown or cross-workspace id, which returns 404. A wrong or unusable code is not an HTTP error; it is a normal 200 response with verified: false and a reason.

StatusReasonWhen it firesWhat to do
400validation_errorRequest body is missing code, missing both id and verifyId, or code is not 4 to 8 digits.Fix the request shape before submitting.
401unauthorizedBearer token is missing, malformed, unrecognized, or revoked.Check the Authorization header and key validity.
403insufficient_scopeThe key is valid but lacks the verify scope.Mint a key with the verify scope, or add the scope to the existing key.
404not_foundThe id does not exist, or exists but belongs to a different workspace (including a platform-owned hosted-screen row). Cross-workspace ids are deliberately reported the same as nonexistent ids, so an attacker probing ids from another tenant learns nothing.Confirm the id came from a create call made with this same API key's workspace.
200bad_codeThe row exists, is not expired, is not already used, and is under the attempt cap, but the submitted code does not match. Each wrong guess increments the row's attempt counter.Let the user retry. Show remaining attempts if you want to warn them before too_many_attempts.
200already_usedThe row was already consumed by a prior successful check (consumedAt is set, or status is already verified). Checked before the expiry check, so a used-then-expired code reports already_used, not expired.Do not resubmit a code twice. If your UI double-fires the check call (for example on a form re-render), guard against a duplicate submit rather than treating this as a user error.
200expiredCurrent time is past the row's expiresAt (10 minutes after creation by default, configurable per service). The row is marked expired in storage on this check.Create a new verification. There is no separate resend endpoint; calling POST /v1/verify again for the same destination issues a fresh code and implicitly supersedes the old one for UX purposes (the old row still exists and reports its own status if checked).
200too_many_attemptsThe row has reached 5 failed attempts. Marked failed in storage. This is the only check-failure reason that also emits a verify.failed webhook event.Create a new verification rather than continuing to retry the same one; the row is permanently locked out regardless of whether the next guess would have been correct.

Example bodies

200 OK, already_used
{
  "id": "1f6a2e4c-9d3b-4a1e-8f2c-6b7a9e0d5c3f",
  "verified": false,
  "reason": "already_used"
}
404 Not Found
{
  "id": "1f6a2e4c-9d3b-4a1e-8f2c-6b7a9e0d5c3f",
  "verified": false,
  "reason": "not_found"
}

Reading verifications: GET /v1/verify/:id and GET /v1/verify

StatusReasonWhen it firesWhat to do
404not_foundGET /v1/verify/:id with an id that does not exist or belongs to a different workspace.Confirm the id and the calling key's workspace match.
400validation_errorGET /v1/verify with an out-of-range limit (must be 1 to 200) or a malformed serviceId.Fix the query parameters.
404service_not_foundGET /v1/verify?serviceId=... where the id does not resolve in this workspace.Confirm the vs_... id, or omit serviceId to list across all services.

Verify Services and webhooks errors

The services and webhooks management surface (/v1/verify/services, /v1/verify/webhooks) has its own error set, documented alongside its endpoints on Verify Services API rather than duplicated here, since several of its reasons (default_required, forbidden_url, preview_not_available) are specific to service and webhook configuration rather than sending or checking a code.