Errors and troubleshooting
Look up widget error codes and dispatch responses, use your own OTP, and fix common problems.
Error Reference
Error Format
V2 Widget errors use four emission families. Shared-handler responses use ERROR_CODE_MAP for
their HTTP status, retryability, and customer-facing action. Controller ladders choose their
status in the endpoint handler and are listed separately. Direct middleware responses (rate
limits and the circuit breaker) answer before the handler and have their own body fields.
Zero-verify bans are NOT middleware — the handler raises them itself, after most of the checks in
request-otp and resend-otp, so any earlier error on those endpoints wins over a ban. The exact
order is under direct error responses. Dual-path codes document both the map-mediated passkey response and the controller-ladder
Widget response.
{
"status": "error",
"code": "ERROR_CODE",
"message": "Description",
"retryable": true,
"retryAfter": "ISO8601",
"cooldownSeconds": 60,
"details": {}
}
Optional fields: retryable (whether the user can retry), retryAfter (when to retry, for rate limits), cooldownSeconds (seconds until a retry is allowed) and details (additional context).
OTP dispatch responses
These dispatch responses apply to request-otp and resend-otp; OTP_NOT_REQUESTED also applies to verify-otp. The two 409 codes are returned when a send for that attempt is already in flight, or when the outcome of an earlier send for it is unknown. For resend-otp, the one resend is claimed just before dispatch, after the endpoint's own checks: your one resend is already consumed when a dispatch conflict is returned, so a retry returns 429 RESEND_LIMIT_EXCEEDED. Earlier endpoint checks can also return a different error. Dev-mode test pairs never return these 409s; they send no real code.
| Status | Error | Cause & Solution |
|---|---|---|
| 409 | OTP_DISPATCH_IN_PROGRESS | Another call holds this send: it is still in flight, or it finished but its result is not readable yet. For request-otp, retryable is true: read retryAfterSeconds (3) from the body (the response also sets Retry-After: 3, but browsers may not expose that header cross-origin) and retry the same call after that many seconds; expect 409 while in flight; the original request once the first send was accepted. If that result is not readable yet, the response remains 409. For resend-otp, retryable is false and no retry delay is sent: its one resend is already consumed, so a retry returns 429 RESEND_LIMIT_EXCEEDED. |
| 409 | OTP_DISPATCH_OUTCOME_UNKNOWN | The first send's outcome could not be confirmed. retryable is false; do not retry the same verification attempt. Start a new verification attempt. The code may have been delivered. For request-otp only, an optional redirectUrl points to the pipeline's frontendCallbackURL with status=failed, error=OTP_DISPATCH_OUTCOME_UNKNOWN, transactionId, attemptId, timestamp, and meta_<key> for each public metadata entry. No field is returned without a callback URL; older origins also omit it during rolling deploys, so treat it as optional. The hosted widget shows a return link to this URL on the terminal error and follows it automatically, except for a native app-scheme URL inside an iframe, which is shown as a link only. This is a user-experience hint; this path sends no failed webhook. resend-otp does not carry redirectUrl. |
| 500 | OTP_SEND_FAILED | The original request-otp caller's provider timeout remains a generic send failure, with retryable: true. It is not the unknown-outcome 409; a later call can return OTP_DISPATCH_OUTCOME_UNKNOWN. |
| 429 | RESEND_LIMIT_EXCEEDED | Your one resend is reserved before dispatch and stays consumed once a send was attempted or its outcome is unknown. Do not retry resend-otp automatically, including after a 500 OTP_RESEND_FAILED timeout or a 409 dispatch conflict: a retry returns 429 RESEND_LIMIT_EXCEEDED. A 500 body with OTP_RESEND_FAILED or RESEND_OTP_FAILED says retryable: true; for resend-otp ignore it and do not retry automatically: the one resend is normally already spent, although a failure that sent nothing may release it. Treat this response as final for that attempt and start a new verification attempt. |
| 403 | OTP_NOT_REQUESTED | Verification requires a prior successful OTP request. Request an OTP before calling verify; resend-otp also returns this code when no OTP was requested. |
The transactionId returned by request-otp identifies the current dispatch's transaction. Use the value from the latest successful response when verifying. Once a request-otp call has succeeded, repeating it returns the same request without sending again. A duplicate returns 409 while the first send is in flight; the original request once the first send was accepted. If that result is not readable yet, it returns 409 OTP_DISPATCH_IN_PROGRESS. This guarantee does not apply to resend-otp.
{
"status": "error",
"code": "OTP_DISPATCH_IN_PROGRESS",
"message": "Your verification code is still being sent. Please wait a few seconds and try again.",
"retryable": true,
"retryAfterSeconds": 3,
"reason": "dispatch_in_progress"
}
V2 Widget errors reach you by four different routes, and the route decides the shape of the response body — so check which table a code is in before writing a handler for it. Shared-handler responses carry retryable and action. Endpoint-handler responses list their endpoint-specific body fields. Middleware responses carry the fields shown in their row. Dual-path codes arrive both ways depending on the endpoint.
Shared error-handler responses
Returned by the passkey SDK endpoints and by middleware that hands the error to the shared handler. These bodies carry retryable and action alongside the code.
On create-attempt, authenticateCreateAttempt runs before the controller. Its MISSING_REQUIRED_FIELDS, INVALID_SIGNATURE, SIGNATURE_EXPIRED responses use status, code, message, retryable, action.
| Code | HTTP | Retryable | Action |
|---|---|---|---|
| INVALID_SIGNATURE | 401 | false | Check signature generation logic |
| SIGNATURE_EXPIRED | 401 | true | Generate new signature with current timestamp |
| ATTEMPT_NOT_FOUND | 404 | false | Create a new attempt |
| ATTEMPT_EXPIRED | 410 | false | Create a new attempt |
| CHALLENGE_EXPIRED | 410 | true | Request a new device challenge |
| CAPTCHA_NOT_VERIFIED | 403 | true | Complete captcha verification first |
| WIDGET_NOT_FOUND | 404 | false | Verify widget exists |
| MISSING_REQUIRED_FIELDS | 400 | false | Check request parameters |
| PASSKEY_DISABLED | 403 | false | Passkeys are not enabled for this account/pipeline; fall back to OTP |
| PASSKEY_NOT_VERIFIED | 403 | false | Complete OTP verification before enrolling a passkey |
| PASSKEY_AUTH_IN_PROGRESS | 409 | true | Finish the current passkey prompt or retry shortly. Raised by auth-options and auth-abandon. |
| PASSKEY_REGISTRATION_FAILED | 400 | true | Retry passkey enrollment |
| PASSKEY_AUTH_FAILED | 403 | true | Retry, or use a one-time code instead |
| NO_PASSKEY | 404 | false | No passkey on this device; use a one-time code |
| TEST_PAIR_RETIRED | 403 | false | Dev-mode test pair changed; re-open dev mode or use a real one-time code |
| INSUFFICIENT_QUOTA | 402 | false | Top up quota or upgrade the plan |
| BILLING_FAILED | 500 | true | Retry, or use a one-time code instead |
Error-handler fallbacks
| Code | HTTP | Retryable | Action |
|---|---|---|---|
| VALIDATION_ERROR | 400 | false | Check request parameters |
| INVALID_ID_FORMAT | 400 | false | Provide valid ID format |
| INTERNAL_SERVER_ERROR | 500 | false | Contact support if issue persists |
Endpoint-handler responses
The widget endpoints choose the status in the handler itself. These rows list the fields emitted by each endpoint. Some handlers add retryable or conditional redirect/quota fields.
The request-otp, verify-otp and resend-otp responses are answered to the hosted widget, which handles most of them itself, such as the duplicate-send and channel-choice rows. They are listed here so the contract is complete.
| Code | HTTP | Body fields | Meaning |
|---|---|---|---|
| INVALID_OTP_LANGUAGE | 400 | status, code, message | create-attempt: otpLanguage must be en or ar in any case, optionally followed by - or _ and two letters or three digits, without trimming; null or omission uses the pipeline default. |
| INVALID_API_KEY | 401 | status, code, message; conditional details, suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: The API key is invalid. |
| TEMPLATE_UNKNOWN | 400 | status, code, message; conditional details, suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: The OTP template id is malformed, missing, or not owned by the account. |
| TEMPLATE_ARCHIVED | 400 | status, code, message; conditional details, suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: The OTP template is archived. |
| TEMPLATE_WABA_REQUIRED | 400 | status, code, message; conditional details, suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: An active own WhatsApp Business Account or its readable token is required. |
| TEMPLATE_WABA_CHANGED | 400 | status, code, message; conditional details, suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: The OTP template belongs to a previous WhatsApp Business Account. |
| TEMPLATE_NOT_APPROVED | 400 | status, code, message; conditional details, suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: The OTP template is not approved for the OTP language. |
| TEMPLATE_REJECTED | 400 | status, code, message; conditional details, suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: Meta rejected the OTP template for the OTP language. |
| TEMPLATE_VARIABLE_MISSING | 400 | status, code, message; conditional details, suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: A required OTP template variable has no request value or stored default. |
| TEMPLATE_VARIABLE_INVALID | 400 | status, code, message; conditional details, suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: An OTP template variable or selection key is invalid; inspect details when present. |
| API_KEY_SCOPE_DENIED | 403 | status, code, message; conditional details, suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: The API key is limited to some pipelines and this widget’s pipeline is not one of them. |
| WIDGET_USER_MISMATCH | 403 | status, code, message; conditional details, suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: The widget does not belong to the API-key owner. |
| PIPELINE_OWNERSHIP_MISMATCH | 403 | status, code, message; conditional details, suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: The widget’s pipeline does not belong to the API-key owner. |
| WIDGET_INACTIVE | 400 | status, code, message; conditional details, suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: The widget is inactive. |
| PIPELINE_NOT_CONFIGURED | 400 | status, code, message; conditional details, suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: The widget has no pipeline configured. |
| PIPELINE_NOT_FOUND | 404 | status, code, message; conditional details, suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: The configured pipeline could not be found. |
| CIRCUIT_BREAKER_OPEN | 503 | status, code, message; conditional details, suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: The widget is temporarily suspended by its circuit breaker. |
| INSUFFICIENT_QUOTA | 402 | status, code, message; conditional details, suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: The account does not have enough quota for the widget attempt. |
| METADATA_SIZE_EXCEEDED | 400 | status, code, message; conditional details, suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: Combined public and private metadata exceeds 10 KB. |
| CUSTOM_HEADERS_SIZE_EXCEEDED | 400 | status, code, message; conditional details, suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: Custom headers exceed 4 KB. |
| NATIONAL_PIPELINE_RESTRICTION | 403 | status, code, message; conditional details, suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: The destination is restricted by the pipeline’s national policy. |
| DESTINATION_COUNTRY_BLOCKED | 403 | status, code, message; conditional details, suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: The destination country is blocked by pipeline policy. |
| INVALID_OTP_DIGITS | 400 | status, code, message; conditional details, suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: digits must be 4, 5, or 6. |
| OTP_DIGITS_MISMATCH | 400 | status, code, message; conditional details, suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: The custom OTP length does not match digits. |
| INVALID_TEST_PAIR | 400 | status, code, message; conditional details, suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: The stored dev-mode test pair has an unsupported OTP length. |
| CREATE_ATTEMPT_FAILED | 400 | status, code, message; conditional details, suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: The widget attempt could not be created. |
| API_KEY_INDEX_UNAVAILABLE | 503 | status, code, message, retryable | create-attempt: The API key could not be checked right now. retryable is true: try again shortly. |
| MISSING_REQUIRED_FIELDS | 400 | status, code, message | register-device: The attempt ID and fingerprint are required. |
| ATTEMPT_NOT_FOUND | 404 | status, code, message | register-device: The attempt could not be found. |
| ATTEMPT_EXPIRED | 410 | status, code, message | register-device: The attempt has expired. |
| FINGERPRINT_INCONSISTENT | 400 | status, code, message | register-device: The fingerprint is missing required fields or contains invalid screen data. |
| DEVICE_REGISTRATION_FAILED | 500 | status, code, message | register-device: The device could not be registered. |
| MISSING_REQUIRED_FIELDS | 400 | status, code, message | validate-attempt: The attempt ID, stable device ID, and fingerprint are required. |
| ATTEMPT_NOT_FOUND | 404 | status, code, message | validate-attempt: The attempt could not be found. |
| ATTEMPT_EXPIRED | 410 | status, code, message | validate-attempt: The attempt has expired. |
| INVALID_DEVICE_ID | 400 or 403 | status, code, message | validate-attempt: 400 when deviceId or fingerprint is absent from the request; 403 when the device ID does not match the fingerprint — either the device was never registered, or the recomputed ID differs. |
| FINGERPRINT_INCONSISTENT | 400 | status, code, message | validate-attempt: The submitted fingerprint is missing required fields or contains invalid screen data. Despite the name, this is a malformed-payload error, not a spoofing rejection. |
| VALIDATE_ATTEMPT_FAILED | 400 | status, code, message | validate-attempt: The attempt could not be validated. |
| MISSING_REQUIRED_FIELDS | 400 | status, code, message, retryable | verify-captcha: The attempt ID, device ID, and captcha token are required. |
| ATTEMPT_NOT_FOUND | 404 | status, code, message, retryable | verify-captcha: The attempt could not be found. |
| ATTEMPT_EXPIRED | 410 | status, code, message, retryable | verify-captcha: The attempt has expired. |
| POW_SOLUTION_MISSING | 400 | status, code, message, retryable | verify-captcha: A proof-of-work solution is required. |
| POW_INVALID_TOKEN | 400 | status, code, message, retryable | verify-captcha: The proof-of-work token is invalid. |
| POW_INVALID_SIGNATURE | 400 | status, code, message, retryable | verify-captcha: The proof-of-work signature is invalid. |
| POW_CHALLENGE_EXPIRED | 410 | status, code, message, retryable | verify-captcha: The proof-of-work challenge has expired. |
| POW_ATTEMPT_MISMATCH | 400 | status, code, message, retryable | verify-captcha: The proof-of-work challenge belongs to another attempt. |
| POW_CHALLENGE_REPLAYED | 409 | status, code, message, retryable | verify-captcha: The proof-of-work challenge was already used. |
| POW_INVALID_SOLUTION | 400 | status, code, message, retryable | verify-captcha: The proof-of-work solution is invalid. |
| POW_VERIFICATION_FAILED | 500 | status, code, message, retryable | verify-captcha: Proof-of-work verification failed unexpectedly. |
| CAPTCHA_ALREADY_USED | 409 | status, code, message, retryable | verify-captcha: The captcha token was already consumed. |
| CAPTCHA_VALIDATION_FAILED | 400 | status, code, message, retryable | verify-captcha: Cloudflare rejected the submitted Turnstile token. |
| CAPTCHA_API_ERROR | Cloudflare’s own status | status, code, message, retryable | verify-captcha: Cloudflare answered the Turnstile check with an HTTP error. The response carries that same HTTP status, so it is not a fixed 502. retryable is true. |
| CAPTCHA_VALIDATION_TIMEOUT | 400 | status, code, message, retryable | verify-captcha: Captcha validation timed out. retryable is true. |
| CAPTCHA_TOKEN_REUSED | 400 | status, code, message, retryable | verify-captcha: The same captcha token was submitted twice concurrently. |
| CAPTCHA_NETWORK_ERROR | 400 | status, code, message, retryable | verify-captcha: Captcha validation could not reach the provider. |
| CAPTCHA_VERIFICATION_FAILED | 400 | status, code, message, retryable | verify-captcha: Captcha verification failed unexpectedly. |
| MISSING_REQUIRED_FIELDS | 500 | status, code, message, retryable | request-otp: The attempt ID or device ID is missing. |
| ATTEMPT_NOT_FOUND | 404 | status, code, message, retryable | request-otp: The attempt could not be found. |
| ATTEMPT_EXPIRED | 410 | status, code, message, retryable | request-otp: The attempt has expired. |
| CAPTCHA_NOT_VERIFIED | 403 | status, code, message, retryable | request-otp: Captcha verification must be completed first. |
| TEST_PAIR_RETIRED | 403 | status, code, message, retryable | request-otp: The dev-mode test pair is no longer active. |
| DESTINATION_COUNTRY_BLOCKED | 403 | status, code, message, retryable | request-otp: The destination country is blocked by pipeline policy. |
| NATIONAL_PIPELINE_RESTRICTION | 403 | status, code, message, retryable | request-otp: The destination is restricted by the pipeline’s national policy. |
| TRANSACTION_NOT_FOUND | 500 | status, code, message, retryable | request-otp: The transaction could not be found while requesting an OTP. |
| TRANSACTION_CREATION_FAILED | 500 | status, code, message, retryable | request-otp: The underlying transaction could not be created. |
| API_KEY_SCOPE_DENIED | 403 | status, code, message, retryable | request-otp: The API key is limited to some pipelines and this widget’s pipeline is not one of them. |
| OTP_TEMPLATE_SELECTION_UNAVAILABLE | 500 | status, code, message, retryable | request-otp: The OTP template chosen for this transaction could not be loaded before sending. Nothing was sent. retryable is true. |
| OTP_SEND_FAILED | 500 | status, code, message, retryable | request-otp: OTP delivery failed while requesting an OTP. |
| REQUEST_OTP_FAILED | 500 | status, code, message, retryable | request-otp: The OTP request failed outside the named terminal cases. |
| CHANNEL_NOT_OFFERED | 400 | status, code, message, retryable; conditional channelChoice (the channels still on offer) | request-otp · resend-otp (calls the hosted widget makes): The channel sent is not on this attempt’s offer list, or it was already refused. channelChoice lists the channels still on offer. retryable is false. |
| CHANNEL_PROBE_UNAVAILABLE | 503 | status, code, message, retryable; conditional channelChoice (the channels still on offer) | request-otp · resend-otp (calls the hosted widget makes): Telegram was chosen and its reachability check could not answer. Nothing was sent. retryable is false. |
| CHANNEL_PRICING_UNAVAILABLE | 503 | status, code, message, retryable; conditional channelChoice (the channels still on offer) | request-otp · resend-otp (calls the hosted widget makes): The chosen channel could not be priced for the quota check. retryable is false. |
| CHANNEL_QUOTA_EXCEEDED | 402 | status, code, message, retryable; conditional channelChoice (the channels still on offer) | request-otp · resend-otp (calls the hosted widget makes): The account does not have enough quota to send on the chosen channel. retryable is false. |
| PRE_SEND_FAILURE | 422 | status, code, message, retryable; conditional channelChoice (the channels still on offer) | request-otp · resend-otp (calls the hosted widget makes): The chosen channel was refused before anything was sent, so nothing was spent. retryable is false. |
| OTP_DISPATCH_IN_PROGRESS | 409 | status, code, message, retryable, reason; conditional retryAfterSeconds (request-otp in-progress only), certainty (unknown outcome) | request-otp: Another call holds this send: it is still in flight, or it finished but its result is not readable yet. Retry after retryAfterSeconds (3): 409 while in flight; the original request once the first send was accepted. If that result is not readable yet, the response remains 409. |
| OTP_DISPATCH_OUTCOME_UNKNOWN | 409 | status, code, message, retryable, reason, certainty; conditional redirectUrl (when the pipeline has a frontendCallbackURL) | request-otp: The first send’s outcome could not be confirmed. Not retryable; start a new verification attempt. |
| MISSING_REQUIRED_FIELDS | 400 | status, code, message, retryable; conditional redirectUrl | verify-otp: Required verification fields are missing. |
| ATTEMPT_NOT_FOUND | 404 | status, code, message, retryable; conditional redirectUrl | verify-otp: The attempt could not be found. |
| OTP_NOT_REQUESTED | 403 | status, code, message, retryable; conditional redirectUrl | verify-otp: An OTP must be requested before it can be verified. |
| TRANSACTION_NOT_FOUND | 404 | status, code, message, retryable; conditional redirectUrl | verify-otp: The transaction could not be found. |
| TRANSACTION_EXPIRED | 410 | status, code, message, retryable; conditional redirectUrl | verify-otp: The transaction has expired. |
| INVALID_OTP | 403 | status, code, message, retryable; conditional redirectUrl | verify-otp: The submitted OTP is invalid. |
| MAX_ATTEMPTS_EXCEEDED | 429 | status, code, message, retryable; conditional redirectUrl | verify-otp: The transaction reached its failed-OTP limit; start a new attempt. |
| TEST_PAIR_RETIRED | 403 | status, code, message, retryable; conditional redirectUrl | verify-otp: The dev-mode test pair is no longer active. |
| BILLING_FAILED | 400 | status, code, message, retryable; conditional redirectUrl | verify-otp: Billing failed during widget OTP verification. |
| VERIFY_FAILED | 400 | status, code, message, retryable; conditional redirectUrl | verify-otp: The shared verification service returned an unhandled failure. |
| VERIFY_OTP_FAILED | 400 | status, code, message, retryable; conditional redirectUrl | verify-otp: OTP verification failed unexpectedly. |
| MISSING_REQUIRED_FIELDS | 500 | status, code, message, retryable | resend-otp: Required resend fields are missing. |
| ATTEMPT_NOT_FOUND | 404 | status, code, message, retryable | resend-otp: The attempt could not be found. |
| OTP_NOT_REQUESTED | 403 | status, code, message, retryable | resend-otp: An OTP must be requested before it can be resent. |
| RESEND_LIMIT_EXCEEDED | 429 | status, code, message, retryable | resend-otp: The attempt has reached its resend limit. |
| TRANSACTION_NOT_FOUND | 404 | status, code, message, retryable | resend-otp: The transaction could not be found. |
| TRANSACTION_EXPIRED | 410 | status, code, message, retryable | resend-otp: The transaction has expired. |
| TEST_PAIR_RETIRED | 403 | status, code, message, retryable | resend-otp: The dev-mode test pair is no longer active. |
| OTP_RESEND_FAILED | 500 | status, code, message, retryable | resend-otp: The OTP resend failed outside the named terminal cases. |
| OTP_TEMPLATE_SELECTION_UNAVAILABLE | 500 | status, code, message, retryable | resend-otp: The OTP template chosen for this transaction could not be loaded before sending. Nothing was sent. retryable is true. |
| RESEND_OTP_FAILED | 500 | status, code, message, retryable | resend-otp: The resend operation failed unexpectedly. |
| OTP_DISPATCH_IN_PROGRESS | 409 | status, code, message, retryable, reason; conditional retryAfterSeconds (request-otp in-progress only), certainty (unknown outcome) | resend-otp: Another call holds this send: it is still in flight, or it finished but its result is not readable yet. Not retryable; the one resend is spent. A retry returns 429 RESEND_LIMIT_EXCEEDED after earlier endpoint checks. |
| OTP_DISPATCH_OUTCOME_UNKNOWN | 409 | status, code, message, retryable, reason; conditional retryAfterSeconds (request-otp in-progress only), certainty (unknown outcome) | resend-otp: The first send’s outcome could not be confirmed. Not retryable; start a new verification attempt. |
| MISSING_PUBLIC_KEY | 400 | status, code, message | widget-branding: The widget branding request did not include a public key. |
| WIDGET_NOT_FOUND | 404 | status, code, message | widget-branding: The active widget could not be found for the public key. |
Direct error responses (rate limits, circuit breaker, bans)
Rate-limit and circuit-breaker rows are answered by middleware, before the endpoint handler runs at all — so they pre-empt that endpoint's own rows. The zero-verify ban is different: IDENTITY_TEMPORARILY_BLOCKED is returned by the request-otp and resend-otp handlers themselves, after most of their other checks — so an earlier error always wins over the ban. The exact order is below.
request-otp checks, in order: required fields, attempt lookup, attempt expiry, the stored channel (CHANNEL_NOT_OFFERED, only when a channel is sent), the live test-pair/widget load, the idempotent already-requested return, captcha state, the account lookup, then the ban. After the ban come the channel quota check (only when a channel is sent, and not for test pairs), and then every transaction and activation error. The idempotent step is the one worth planning for: a repeat of a request that already succeeded returns that success, so a caller retrying after a dropped response gets the original result rather than CAPTCHA_NOT_VERIFIED.
resend-otp has no captcha step. It checks required fields, attempt lookup, whether an OTP was requested at all, the live test pair, the stored channel, the transaction lookup and its ownership, transaction expiry, the ban, channel quota (when a channel is chosen, except for test pairs), then the resend claim just before dispatch.
The rows list the fields available on each response. Where present, retryAfter is an ISO timestamp and cooldownSeconds the equivalent in seconds. Wait for that schedule rather than retrying on a fixed interval.
| Code | HTTP | Extra body fields | Meaning |
|---|---|---|---|
| WIDGET_IDENTITY_INDEX_UNAVAILABLE | 503 | retryable | every widget and widget-passkey route except telemetry: Widget identity operations are temporarily unavailable. retryable is true: try again shortly. |
| RATE_LIMIT_WIDGET_ATTEMPTS | 429 | retryable, retryAfter, cooldownSeconds, details | create-attempt · validate-attempt: Too many attempts for this widget — the whole widget is over budget, not one end user. |
| RATE_LIMIT_PHONENUMBER_ATTEMPTS | 429 | retryable, retryAfter, cooldownSeconds, details | create-attempt · validate-attempt: Too many attempts for this phone number. |
| RATE_LIMIT_DEVICEID_ATTEMPTS | 429 | retryable, retryAfter, cooldownSeconds, details | create-attempt · validate-attempt: Too many attempts from this device. |
| CIRCUIT_BREAKER_OPEN | 503 | retryable, retryAfter, cooldownSeconds, details | create-attempt · register-device · validate-attempt · verify-captcha: The widget is already suspended by its circuit breaker. The suspension end is details.suspendedUntil, not a top-level field. |
| CIRCUIT_BREAKER_TRIGGERED | 503 | retryable, retryAfter, cooldownSeconds, details | create-attempt · register-device · validate-attempt · verify-captcha: The widget was flood-suspended by this request. retryAfter carries the suspension end. |
| MISSING_WIDGET_ID | 400 | message | create-attempt · register-device · validate-attempt · verify-captcha · request-otp · resend-otp: The request reached the rate-limit or circuit-breaker layer without a resolvable widget ID and is refused before the endpoint handler. On create-attempt this is what an unknown publicKey returns. On register-device, validate-attempt and verify-captcha it means an absent or unresolvable attemptId. On request-otp and resend-otp it means an absent or non-string attemptId; an unknown string attemptId reaches the handler and returns ATTEMPT_NOT_FOUND. |
| RATE_LIMIT_WIDGET_OTP | 429 | retryable, retryAfter, cooldownSeconds, details | request-otp · resend-otp: Too many OTP requests for this widget. Like its ATTEMPTS twin this is a widget-wide limit, so it fires for an end user who has done nothing wrong — surface a "try again shortly" state rather than blaming their number. |
| RATE_LIMIT_PHONENUMBER_OTP | 429 | retryable, retryAfter, cooldownSeconds, details | request-otp · resend-otp: Too many OTP requests for this phone number. |
| RATE_LIMIT_DEVICEID_OTP | 429 | retryable, retryAfter, cooldownSeconds, details | request-otp · resend-otp: Too many OTP requests from this device. |
| IDENTITY_TEMPORARILY_BLOCKED | 429 | retryable, retryAfter, cooldownSeconds | request-otp · resend-otp: The identity is under a zero-verify ban. retryable is false even though a schedule is supplied — the ban is terminal until it expires, so honour retryAfter (the ban expiry) and cooldownSeconds (the remaining wait) rather than branching on retryable. |
Dual-path errors
| Code | Shared formatter path | Direct response path |
|---|---|---|
| WIDGET_NOT_FOUND | When rate-limit lookup cannot find the widget, the error reaches the shared formatter: HTTP 404 with status, code, message, retryable, and action. | When the circuit breaker cannot find the widget, it responds directly with HTTP 404 and only status, code, and message. |
| BILLING_FAILED | POST /api/v1/widget-sdk/passkey/auth-verify: HTTP 500; the formatError body includes retryable and action. | POST /api/v1/widget-sdk/verify-otp: HTTP 400; the controller body carries status, code, message and retryable — but no action. The difference from the passkey path is the missing action, not the retryability signal. |
| INSUFFICIENT_QUOTA | POST /api/v1/widget-sdk/passkey/auth-verify: HTTP 402; the formatError body includes retryable and action. | POST /api/v1/widget-sdk/create-attempt: HTTP 402; the controller body uses quota fields instead of the map action. |
| TEST_PAIR_RETIRED | POST /api/v1/widget-sdk/passkey/{register-options, register-verify, auth-options, auth-abandon, auth-verify}: HTTP 403; the formatError body includes retryable and action. | POST /api/v1/widget-sdk/{request-otp, verify-otp, resend-otp}: HTTP 403; the controller body carries status, code, message and retryable (false at this status on all three endpoints) — but no action. |
Advanced Features
Bring Your Own OTP
If you want to generate your own OTP codes, pass a custom otp parameter:
const response = await axios.post(
'https://api.akedly.io/api/v1/widget-sdk/create-attempt',
{
apiKey: AKEDLY_API_KEY,
publicKey: WIDGET_PUBLIC_KEY,
signature,
timestamp,
verificationAddress: { phoneNumber: '+201234567890' },
otp: '123456', // Your custom OTP (4, 5 or 6 digits)
},
)
Billing Difference
- Standard (Akedly-generated OTP): Pay-per-successful-verification - Custom OTP: Pay-per-message (billed immediately when OTP is sent)
Troubleshooting
INVALID_SIGNATURE Error
Common causes:
- Incorrect HMAC algorithm (must be SHA256)
- Wrong JSON key order in signature message
- Extra spaces in JSON string
- Widget secret is incorrect
- Timestamp is outside the 60-second window (stale or ahead of server time)
Solutions:
- Verify you're using HMAC-SHA256
- Ensure the message format matches the attempt. A phone attempt signs
JSON.stringify({ apiKey, publicKey, timestamp, phoneNumber }). An email-only attempt signsJSON.stringify({ apiKey, publicKey, timestamp })—phoneNumberis omitted entirely, not sent asnullor"", because the server builds the same string from an absent value andJSON.stringifydrops an undefined property. Adding the key back is the usual cause of an email-only signature mismatch. - Check your widget secret matches the dashboard
- Synchronize server clock with NTP — a clock 60+ seconds fast fails as hard as one that is slow
- Generate a fresh timestamp and signature per request; never queue or replay a signed payload
Iframe Shows Blank Screen
Common causes:
- Invalid or malformed attemptId in URL
- Attempt has expired (5-minute lifetime)
- Parent page not served over HTTPS
- Browser blocking mixed content
sandboxattribute withoutallow-same-origin
Solutions:
- Check browser console for errors
- Verify iframeUrl contains a valid attemptId
- Ensure parent page uses HTTPS in production
- Confirm attempt hasn't expired
- If the iframe has a
sandboxattribute, includeallow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox
Widget Shows "Turn on cookies to continue"
Common causes:
- The browser or OS blocks cookies, for example Safari with "Block All Cookies" on
- Storage is blocked for the embedded frame only
- Android
WebViewwithout DOM storage
Solutions:
- Have the user follow the on-screen steps, then tap "I’ve turned it on — try again"
- Use "Open in a new tab", where the browser is more likely to allow storage
- On native apps, open the widget in a system browser instead of a WebView
- In an iframe, add
clipboard-writetoallowso Copy link works, and check the frame settings — see When the browser blocks cookies
PIPELINE_NOT_CONFIGURED Error
Solution:
- Go to Authentication → Setup → Widgets → [Your Widget]
- Edit the widget and open the Pipeline section
- Select a pipeline from the dropdown
- Save changes
OTP Not Received
Common causes:
- Phone number format is incorrect
- Rate limit exceeded for phone number
- Pipeline verification methods not configured
Solutions:
- Ensure phone number uses E.164 format with country code (e.g.,
+201234567890) - Check rate limit status in dashboard analytics
- Verify pipeline has at least one verification method enabled
