V2.0 Widgets - Drop-in Authentication
V2.0 Widgets provide a fully-managed authentication interface that runs in an iframe with zero frontend complexity. Unlike the standard API (v1.0), you get a complete OTP flow with built-in security and fraud detection.
Billing Is Unchanged by V2.0
Choosing V2.0 Widgets does not change how you are billed. Akedly resolves your
billing mode — pay-per-message (charged when the OTP is sent) or
pay-per-success (charged on successful verification) — from your account's
plan and billing mode. Neither mode is gated on the API version or on using a
widget. The one per-request override is supplying your own otp, which forces
pay-per-message for that transaction.
New: Passkeys for V2 Widgets
Your users can now verify instantly with a device passkey (Face ID, Touch ID,
Windows Hello) instead of waiting for a code — pricing depends on your account's
passkey configuration (the checked-in factor-mode default is 60% of the OTP anchor
rate). It's recommended and fully backward-compatible: do nothing and you
keep OTP. To enable it, add one
attribute to your iframe —
allow="publickey-credentials-get *; publickey-credentials-create *".
Read the passkeys guide →
Why Choose V2.0 Widgets
For a detailed comparison of V1.2, V2.0, and V1.0, see the Authentication Methods overview.
How V2.0 Widgets Work
The authentication flow is simple and secure:
Your User Initiates Authentication
User clicks "Login" or "Verify Phone" in your application.
Your Backend Creates Attempt
Your backend calls Akedly API with HMAC signature:
POST /api/v1/widget-sdk/create-attemptAkedly Returns Transaction URL
Response includes:
- Name
attemptId- Type
- string
- Description
Unique attempt identifier
- Name
iframeUrl- Type
- string
- Description
URL to open in iframe for authentication
- Name
expiresAt- Type
- string
- Description
ISO 8601 timestamp when attempt expires
Your Frontend Opens Widget
Display the widget in an iframe (web), or open it in a system browser (mobile native — see Step 3):
<iframe src={iframeUrl} />Your User Completes Authentication
Inside the widget (managed by Akedly):
- •Device fingerprinting and security checks
- •Bot protection
- •Rate limiting
- •Fraud detection
- •OTP delivery (WhatsApp → Telegram → SMS → Email) (depending on your pipeline setup)
- •User enters OTP code
Receive Verification Result 🎉
Akedly redirects user to your callback URL & sends webhook to your backend with verification status.
The entire authentication process (captcha, OTP, verification) is handled inside the widget. You only need to create the attempt and handle the success callback.
Step 1: Create Widget in Dashboard
Before integrating, create a widget in your Akedly dashboard.
Dashboard-Only Configuration
Widget creation is done entirely through the dashboard UI. There is no API for creating widgets. This ensures proper security configuration and prevents unauthorized widget creation.
Dashboard Navigation
- Log in to your Akedly dashboard at https://app.akedly.io
- In the left sidebar, open Authentication → Setup → Widgets
- Click Create Widget (the button reads Create Your First Widget on the empty state)
The sidebar is product-scoped — two items are named Pipelines
Pipelines appears twice: once under Authentication → Setup (OTP verification pipelines — the ones a widget binds to) and once under Utilities → Setup (a separate, unrelated product surface). Every reference to "Pipelines" on this page means the one under Authentication.
Basic Info
The widget form is split into collapsible sections, navigated from the Sections rail: Basic Info, Pipeline, Passkeys, Captcha, Rate Limiting, Circuit Breaker, and Developer.
- Name
Widget Name- Type
- string
- Description
Internal name for your reference (e.g., "Production Login Widget")
- Name
Description- Type
- string
- Description
Optional description of where this widget is used
Branding
Branding is not configured in the widget form. The form only carries a Branding & theme link card; the appearance of the hosted widget is edited in the dedicated Branding Studio at /admin/widgets/:id/branding, which has a live preview alongside its tabs.
- Name
Brand- Type
- tab
- Description
Logo, brand name, and core brand colors.
- Name
Theme- Type
- tab
- Description
Light/dark theme, surfaces, borders, and radii.
- Name
Typography- Type
- tab
- Description
Font family and type scale.
- Name
Layout- Type
- tab
- Description
Spacing, alignment, and overall widget composition.
- Name
Components- Type
- tab
- Description
Per-component styling (buttons, inputs, and other controls).
- Name
Content- Type
- tab
- Description
Widget copy — headings, labels, and helper text.
Rather than starting from a blank theme, the Brand tab offers presets — Akedly, Midnight, Minimal, Corporate and Playful. Pick one as a starting point and fine-tune from there; your custom copy is kept when you switch preset.
The preview beside the tabs is live and renders the real widget: loading, OTP entry, success and error always, plus the captcha and passkey screens when those are enabled on the widget. Toggle Show all screens to see them side by side, and switch the preview between desktop, tablet and mobile — each renders at that device's real width, so the hosted widget's own responsive behaviour is what you see. Edits are not applied until you press Save; Reset discards them and returns to the last saved branding.
Branding is a per-widget setting, so a widget must exist before you can open its Branding Studio. Create the widget first, then open it and follow the Branding & theme card.
Webhooks
V2.0 Widgets support two types of callbacks. See Pipeline Setup for detailed configuration, or navigate to Authentication → Setup → Pipelines → select your pipeline → Callback URLs.
Front-end Redirect URL (required)
Where to redirect the user after authentication completes.
Example:
https://yourapp.com/auth/callback
Success Redirect:
https://yourapp.com/auth/callback?status=success&transactionId=mtx_abc123&attemptId=attempt_xyz789×tamp=2025-01-16T12:00:00Z&meta_userId=user_12345&meta_orderId=order_abc789
Failure Redirect:
https://yourapp.com/auth/callback?status=failed&error=INVALID_OTP&transactionId=mtx_abc123&attemptId=attempt_xyz789×tamp=2025-01-16T12:00:00.000Z&meta_userId=user_12345&meta_orderId=order_abc789
Back-end Webhook URL (required)
Webhook endpoint to receive verification events. Akedly sends a POST request with complete verification details immediately after successful or failed verification.
Example:
https://yourapp.com/api/webhooks/akedly
Frontend Redirect Query Parameters:
- Name
status- Type
- string
- Description
"success" or "failed"
- Name
isTest- Type
- string
- Description
"true"on a successful test-pair redirect; omitted for production verifications.
- Name
transactionId- Type
- string
- Description
The transaction ID from the verification flow
- Name
attemptId- Type
- string
- Description
The original attemptId you created
- Name
timestamp- Type
- string
- Description
ISO 8601 timestamp of when authentication completed
- Name
error- Type
- string
- Description
Error code (only present if status=failed)
- Name
meta_*- Type
- string
- Description
Custom metadata fields from
publicMetadata. Each key is prefixed withmeta_. Object values are JSON stringified.
Captcha Settings
Protect your widget from automated attacks with built-in captcha verification.
- Name
Enable Captcha- Type
- boolean
- Description
Always enabled by default. Captcha verification is mandatory for all widget interactions and cannot be disabled.
- Name
Require Cloudflare Turnstile- Type
- boolean
- Description
Always enabled. Cloudflare Turnstile is required to protect your quotas and widgets from bot spam. This setting cannot be turned off to ensure maximum security against automated abuse.
Rate Limiting
Control authentication and OTP request limits to protect your widget from abuse. Rate limits are configured across three dimensions: per phone number, per device ID (fingerprinting), and per widget.
AI-Powered Rate Limit Analysis: If you have sufficient historical data, use the "Get Recommendations" feature to analyze your usage patterns and suggest optimal rate limits that balance security with user experience.
Default values are suitable for 95% of use cases. Only adjust if you have specific requirements. If you expect bursty traffic, focus on adjusting the per-minute value—the per-hour and per-day values will auto-calculate based on safe ratios.
Widget Attempts
Widget attempts track iframe loads and authentication attempts, regardless of whether they pass captcha or fingerprinting. A failed captcha still counts as an attempt, even if no OTP is sent.
OTP Requests
OTP requests track actual One-Time Password deliveries. These limits apply only when an OTP is successfully sent to the user.
Cooldown Duration
- Name
Cooldown duration- Type
- number
- Description
Default: 300000 (5 minutes). Stored on the widget, but the V2 rate limiter does not read it. When a V2 request is rate limited, the wait comes from the
retryAfterandcooldownSecondsfields in the 429 response, which are measured from the oldest counted attempt in the window that was breached.
No implicit per-request cooldown on V2
The platform-wide 60-second cooldown between OTP requests does not apply to V2 widget requests — this endpoint skips it deliberately, because the widget's own per-phone and per-device limits above already govern the pace. These limits REPLACE that cooldown; they are not the only guard a V2 integration meets. The resend limit always applies. Zero-verify bans apply on top only when BOTH are true: Akedly has enforcement switched on platform-wide (it is off by default, and its shadow setting records what it would have blocked without returning a refusal), AND the pipeline has not disabled them. Live test-pair attempts are exempt, so a request using the test pair's number will never show you a ban — check ban handling with any other number, which is treated normally even while dev mode is on. Treat a ban response as something to handle if it arrives, never as protection you can assume is active.
Circuit Breaker
The circuit breaker is your final layer of defense that automatically suspends your widget when abnormal traffic patterns are detected. It works alongside captcha and rate limiting to provide comprehensive protection.
Keep Circuit Breaker Enabled. Disabling the circuit breaker removes your last line of defense against sophisticated attacks. The circuit breaker is calibrated to only trigger during genuine flood attacks—if it activates, it means it protected you from a real threat.
Why Circuit Breaker Matters:
- Blocks Coordinated DDoS Attacks: Detects and stops distributed attacks from multiple sources attempting to overwhelm your widget
- Protects Your Quota: Prevents sophisticated attacks from draining your API quota and incurring unexpected costs
- Last Line of Defense: Catches threats that bypass captcha and rate limiting (while rare, it's possible with advanced attack techniques)
- Automatic Recovery: Temporarily suspends the widget during attacks and automatically resumes normal operation when the threat subsides
After Widget Creation
Once you create the widget, you'll receive credentials needed for API integration:
- Name
Widget ID- Type
- string
- Description
Internal identifier (e.g.,
widget_a1b2c3d4...)
- Name
Public Key- Type
- string
- Description
Used in API requests (e.g.,
pk_x1y2z3...)
- Name
Widget Secret (SAVE THIS NOW)- Type
- string
- Description
Used to sign API requests with HMAC-SHA256
The secret is shown once and cannot be rotated
The widget secret is displayed only at creation — copy it then, as shown in the screenshot. There is no regenerate or rotate action for a widget secret anywhere in the product, so losing it is not self-service recoverable: you would have to create a new widget (and repoint your integration to its new public key) or contact support@akedly.io.
Store your secret key in environment variables. Never commit it to version control or expose it to frontend code.
Step 2: Backend - Create Attempt
Your backend is responsible for initiating the authentication flow by creating an "attempt". This is a server-side operation that must never be done from the frontend to protect your widget secret.
Understanding the Flow
What happens when you create an attempt:
- User requests authentication - Your frontend collects the phone number and sends it to your backend
- Your backend creates a signature - Using your widget secret, you generate an HMAC-SHA256 signature to prove you own the widget
- Your backend calls Akedly API - Send the signed request to create an attempt
- Akedly returns an iframe URL - You receive a unique URL that opens the authentication widget
- Your backend sends URL to frontend - Pass the
iframeUrlto your frontend to display the widget
Security Rule: The widget secret must NEVER leave your server. All signature generation and API calls happen on your backend only.
Why HMAC Signatures?
HMAC-SHA256 signatures prove that the request came from someone who knows the widget secret, without exposing the secret itself. This prevents attackers from creating unauthorized authentication attempts even if they know your public key.
Signature Generation (Language-Agnostic)
The signature is the most critical part. Here's how to generate it in any language:
Step 1: Prepare the message
For a phone-bearing request, create a JSON string with these exact keys in this exact order:
apiKey, publicKey, timestamp, phoneNumber. Email-only signing is shown below; the
email address is not part of the HMAC message.
Message Format
{
"apiKey": "YOUR_API_KEY",
"publicKey": "YOUR_PUBLIC_KEY",
"timestamp": 1234567890123,
"phoneNumber": "+1234567890"
}
Step 2: Generate HMAC-SHA256
Use your widget secret as the key and the JSON string as the message:
Pseudocode
signature = HMAC-SHA256(secret, message)
output = hex_encode(signature)
Step 3: Include in request
Send the hex-encoded signature in the signature field of your API request.
Common Signature Mistakes
- Wrong JSON order: Keys must be in exact order:
apiKey,publicKey,timestamp,phoneNumberwhen a phone number is present - Extra spaces: Use compact JSON with no spaces after colons or commas
- Wrong encoding: Both secret and message must be UTF-8 encoded
- Stale timestamp: Timestamp must be within 60 seconds of server time, in either direction
The signature window is 60 seconds
A signed request is valid for 60 seconds only, measured as an absolute difference — a timestamp 60+ seconds in the future fails just as a stale one does, so unsynchronized clocks break signing in both directions. Sign immediately before sending. Do not queue, batch, or retry a previously-signed payload: any retry path must generate a fresh timestamp and a fresh signature.
API Reference
Required Parameters
- Name
apiKey- Type
- string
- Description
Your Akedly API key from Company Profile → View API Key in the dashboard
- Name
publicKey- Type
- string
- Description
The widget's public key (from widget creation)
- Name
signature- Type
- string
- Description
HMAC-SHA256 signature of the request payload
- Name
timestamp- Type
- number
- Description
Current Unix timestamp in milliseconds. Must be within 60 seconds of server time in either direction (absolute delta), so generate it immediately before signing.
- Name
verificationAddress- Type
- object
- Description
Contact information for OTP delivery. Include
phoneNumber(with country code) and/oremail.
- Name
digits- Type
- number
- Description
Optional: OTP length:
4,5or6. Defaults to6. Unlike V1.2, V2 rejects an out-of-range value withINVALID_OTP_DIGITS. The hosted widget renders whatever length the attempt carries — to try one without sending anything, see Dev Mode & Test Pairs.
- Name
otp- Type
- string
- Description
Optional: Bring-your-own OTP (4, 5 or 6 digits). When provided, billing switches to pay-per-message instead of pay-per-verification. If you send both
otpanddigits, the OTP's length must matchdigitsor the request fails withOTP_DIGITS_MISMATCH.
- Name
publicMetadata- Type
- object
- Description
Optional custom data returned in frontend redirect URL as
meta_*query parameters. Useful for tracking IDs, session references, or order context. Max combined size with privateMetadata: 10KB.
- Name
privateMetadata- Type
- object
- Description
Optional server-only custom data included only in backend webhooks. Never sent to browser. Useful for sensitive data, auth tokens, or internal state. Max combined size with publicMetadata: 10KB.
- Name
customHeaders- Type
- object
- Description
Optional key-value pairs of custom HTTP headers forwarded in webhook callbacks to your backend. Max 4KB size limit. Headers
content-type,user-agent,host,content-lengthand anything beginningsvix-are blacklisted, silently filtered out before dispatch, and the filtered result is persisted with the attempt. Thesvix-prefix is reserved because that is where Akedly puts the callback's own signature headers — supplying your own would let a caller overwrite them.
Ensure that the phone number is in E.164 format with country code (e.g.
+20155664423). Make sure it's normalized (no spaces, dashes, or
parentheses). For a list of country codes, see E.164 Country
Codes.
Response
- Name
status- Type
- string
- Description
"success" or "error"
- Name
data.attemptId- Type
- string
- Description
Unique attempt identifier (e.g.,
attempt_a1b2c3d4e5f6...)
- Name
data.iframeUrl- Type
- string
- Description
Full URL to open in iframe (e.g.,
https://auth.akedly.io/auth?attemptId=xxx)
- Name
data.expiresAt- Type
- string
- Description
ISO 8601 timestamp when attempt expires (5 minutes from creation)
Request Body
{
"apiKey": "61b7fgxxxxxxxxxxxx", //Account API key
"publicKey": "pk_xxxxxxxxxxxx", //Widget's public key
"signature": "a1b2c3d4e5f6789...", //Secret key
"timestamp": 170155235400,
"verificationAddress": {
"phoneNumber": "+201556645234", // MUST have country code
"email": "user@example.com" //optional
},
"digits": 6, //choose between 4, 5 or 6
"publicMetadata": {
//optional
"userId": "user_12345",
"orderId": "order_abc789"
},
"privateMetadata": {
//optional - server-only
"internalUserId": "internal_xyz"
},
"customHeaders": {
//optional - forwarded in webhooks
"X-Correlation-ID": "corr_abc123",
"X-Internal-Source": "checkout-flow"
}
}
Response
{
"status": "success",
"data": {
"attemptId": "attempt_a1b2c3d4e5f6...",
"iframeUrl": "https://auth.akedly.io/auth?attemptId=...",
"expiresAt": "2025-01-16T12:05:00.000Z"
}
}
Custom Metadata
Attach custom data to verification attempts that gets returned in callbacks. This is useful for tracking users, orders, or sessions through the authentication flow.
Two Types of Metadata:
- Name
publicMetadata- Type
- object
- Description
Data returned in the frontend redirect URL as query parameters prefixed with
meta_. Also included in backend webhooks.Use for: User IDs, order IDs, session references, analytics tracking, non-sensitive context.
Example redirect:
?status=success&meta_userId=12345&meta_orderId=order_abc
- Name
privateMetadata- Type
- object
- Description
Data returned only in backend webhooks. Never sent to the browser or included in redirect URLs.
Use for: Internal user IDs, session tokens, sensitive business data, server-side state.
Size Limit
Combined publicMetadata + privateMetadata must not exceed 10KB (10,240
bytes). Requests exceeding this limit will receive a METADATA_SIZE_EXCEEDED
error.
Request with Metadata
{
.........
"publicMetadata": { //example of data sent to frontend
"userId": "user_12345",
"orderId": "order_abc789",
"registerSource": "landing_page",
"preferences": {
"language": "en",
"timezone": "UTC+2"
}
},
"privateMetadata": { //example of server-only data
"internalId": "internal_xyz",
"sessionToken": "sess_abc123",
"experimentGroup": "A",
"sessionData": {
"cartItems": 3,
"lastLogin": "2025-01-15T10:00:00Z"
}
}
}
Implementation Examples
Complete code examples for creating an authentication attempt in popular backend languages.
Backend Implementation
const crypto = require('crypto')
const axios = require('axios')
// Environment variables (NEVER expose these in frontend)
const AKEDLY_API_KEY = process.env.AKEDLY_API_KEY
const WIDGET_PUBLIC_KEY = process.env.WIDGET_PUBLIC_KEY
const WIDGET_SECRET = process.env.WIDGET_SECRET
function generateSignature(apiKey, publicKey, secret, timestamp, phoneNumber) {
const message = JSON.stringify({
apiKey,
publicKey,
timestamp,
phoneNumber,
})
return crypto.createHmac('sha256', secret).update(message).digest('hex')
}
async function createAuthAttempt(phoneNumber, email = null, metadata = {}) {
const { publicMetadata, privateMetadata } = metadata
const timestamp = Date.now()
const signature = generateSignature(
AKEDLY_API_KEY,
WIDGET_PUBLIC_KEY,
WIDGET_SECRET,
timestamp,
phoneNumber,
)
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,
email,
},
digits: 6,
publicMetadata,
privateMetadata,
},
)
return response.data.data // { attemptId, iframeUrl, expiresAt }
}
// Usage in Express route
app.post('/api/auth/start', async (req, res) => {
try {
const { phoneNumber, email, userId, orderId } = req.body
const attempt = await createAuthAttempt(phoneNumber, email, {
publicMetadata: { userId, orderId },
privateMetadata: { internalId: req.session.internalId },
})
res.json({
success: true,
attemptId: attempt.attemptId,
iframeUrl: attempt.iframeUrl,
})
} catch (error) {
res.status(500).json({
success: false,
error: error.response?.data || error.message,
})
}
})
Step 3: Frontend - Open Widget
Once you receive the attemptId and iframeUrl from your backend, open the Akedly widget — in an iframe on the web, or in a system browser in a mobile native app. Do not load it in a raw WebView (see Mobile native apps below).
Implementation Options
Web — iframe. Embed the widget in an iframe, in a modal/dialog (recommended) or inline in your page. It fits a 500px × 700px iframe. For passkeys, add the allow attribute — see Passkeys.
Web — full-page redirect (no iframe). Prefer not to embed? Send the whole browser tab to the iframeUrl. When the user finishes, the widget redirects back to your pipeline's Front-end Redirect URL with the same query parameters — so there's nothing to wire up beyond that one pipeline setting. A top-level page also has WebAuthn permission natively, so passkeys work with nothing to add (the allow attribute is iframe-only). Good when an iframe is awkward (CSP, small screens) or you just prefer a hosted page. See the snippet below.
Mobile native apps — a system browser (required). Open the widget in ASWebAuthenticationSession or SFSafariViewController (iOS), or Chrome Custom Tabs (Android) — never a raw WKWebView / Android WebView. See the Mobile native apps section below for why and how.
Handling Results
How the result reaches you depends on the surface:
- Web (iframe) — listen for the
postMessageAUTH_SUCCESSevent, and/or use the redirect to your pipeline's Front-end Redirect URL. - Web (full-page redirect) — no iframe means no
postMessage; the result comes purely from the redirect to your pipeline's Front-end Redirect URL. - Mobile native (system browser) — there is no parent window, so
postMessagedoes not apply. Use the redirect to your pipeline's Front-end Redirect URL (a deep link your app intercepts).
The redirect carries status, transactionId, attemptId, timestamp, and any meta_* parameters in the query string. A successful test-pair redirect also carries isTest=true (configured per pipeline — see Pipeline Setup).
Frontend Implementation
import { useState, useEffect } from 'react'
export default function AuthModal() {
const [isOpen, setIsOpen] = useState(false)
const [iframeUrl, setIframeUrl] = useState('')
const [loading, setLoading] = useState(false)
const startAuth = async (phoneNumber, email = null) => {
setLoading(true)
try {
const response = await fetch('/api/auth/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phoneNumber, email }),
})
const data = await response.json()
if (!data.success) {
throw new Error(data.error)
}
setIframeUrl(data.iframeUrl)
setIsOpen(true)
} catch (error) {
alert('Failed to start authentication: ' + error.message)
} finally {
setLoading(false)
}
}
// Listen for postMessage from iframe
useEffect(() => {
const handleMessage = (event) => {
if (event.origin !== 'https://auth.akedly.io') return
if (event.data.type === 'AUTH_SUCCESS') {
console.log('Authentication successful!', event.data)
setIsOpen(false)
onAuthSuccess(event.data)
} else if (event.data.type === 'AUTH_FAILED') {
alert('Authentication failed')
}
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [])
const onAuthSuccess = (data) => {
// Your success logic
window.location.href = '/dashboard'
}
return (
<>
<button
onClick={() => startAuth('+201234567890', 'user@example.com')}
disabled={loading}
>
{loading ? 'Loading...' : 'Login with Phone'}
</button>
{isOpen && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
}}
onClick={(e) => {
if (e.target === e.currentTarget) setIsOpen(false)
}}
>
<iframe
src={iframeUrl}
// Optional: enable passkeys (see /authentication/passkeys)
allow="publickey-credentials-get *; publickey-credentials-create *"
style={{
width: '500px',
height: '700px',
border: 'none',
borderRadius: '12px',
background: 'white',
boxShadow: '0 10px 40px rgba(0, 0, 0, 0.3)',
}}
/>
</div>
)}
</>
)
}
Mobile native apps — open in a system browser
Native apps MUST use a system browser, not a WebView
In a mobile native app, open the widget in the OS's system browser —
ASWebAuthenticationSession or SFSafariViewController on iOS, Chrome Custom Tabs on
Android. Do not load it in a raw WKWebView / Android WebView. Akedly is an authentication
provider: the widget runs Cloudflare Turnstile and WebAuthn (passkeys) on the akedly.io origin in
a real browser, and only a system browser gives it that browser's trust model, cookies, and
credential store. A raw WebView has none of that — passkeys can't run and the captcha is often
degraded.
Why a system browser is required
- Name
Passkeys (WebAuthn)- Type
- required
- Description
The widget invokes WebAuthn on the
akedly.ioorigin. That runs only in a real browser; a raw WebView can't, so passkeys silently fall back to OTP at best. A system browser gives the user the platform's native passkey UI — with no Associated Domains /apple-app-site-association, no Digital Asset Links, and no entitlements on your side, because the credential is bound to Akedly's web origin, not your app.
- Name
Cloudflare Turnstile- Type
- required
- Description
The mandatory captcha and Akedly's bot/fraud checks expect a real browser environment. WebViews are routinely flagged or challenged.
- Name
Cookies & session- Type
- required
- Description
A system browser shares the device browser's cookies and keychain, so returning-user and device-trust signals persist. A WebView is an isolated, cookie-jarred container.
- Name
Minimal setup- Type
- benefit
- Description
The passkey credential is bound to Akedly's web origin, not your app, so it needs no Associated Domains /
apple-app-site-association, no Digital Asset Links, and no passkey entitlements on your side. The only thing you register is your callback, and the simplest one needs no domain association at all: a custom scheme (a one-lineInfo.plistCFBundleURLTypesentry on iOS, or a manifestintent-filteron Android, both shown below). AnhttpsApp Link / Universal Link callback works too, but — like any HTTPS deep link — it requires its own OS association setup (AASA on iOS, Digital Asset Links on Android), so reach for a custom scheme unless you specifically need one.
Which API to use
| Platform | Use | Result delivery |
|---|---|---|
| iOS | ASWebAuthenticationSession (preferred) or SFSafariViewController | Custom-scheme deep link / Universal Link |
| Android | Chrome Custom Tabs (androidx.browser) | Deep link / App Link back into your app |
| React Native | expo-web-browser openAuthSessionAsync (wraps both natives) | Return URL from the resolved promise |
| Flutter | flutter_web_auth_2 (wraps both natives) | Callback URL from authenticate(...) |
| Any other framework | The framework's system-browser / web-auth API (never its WebView component) | Deep-link callback URL |
Whatever stack you're on, it's the same two OS APIs underneath — so the rule never changes: open auth.akedly.io in the framework's system-browser / auth-session API (not its embedded WebView), and read the result from your callback. See Other frameworks below for Capacitor/Ionic, Cordova, .NET MAUI, and the fallback for anything not listed.
Result handling on native
There is no parent window in a system browser, so postMessage does not apply. Instead, set your
pipeline's Frontend Callback URL (Authentication → Setup → Pipelines → your pipeline → Callback URLs) to a URL your app
intercepts — typically a custom-scheme deep link such as myapp://akedly/callback. When
verification completes, the widget redirects the browser to that URL with the usual query parameters
(status, transactionId, attemptId, timestamp, and any meta_*); the OS hands the URL back
to your app and closes the browser. A custom scheme needs no domain association; an https
Universal Link / App Link works too but requires its own OS association (AASA on iOS, Digital Asset
Links on Android).
iOS — ASWebAuthenticationSession (preferred)
import AuthenticationServices
import UIKit // for the presentationAnchor (UIApplication / UIWindowScene)
final class AkedlyAuth: NSObject, ASWebAuthenticationPresentationContextProviding {
private var session: ASWebAuthenticationSession?
/// `iframeUrl` is the URL your backend returned from create-attempt.
/// `callbackScheme` is your app's custom scheme, e.g. "myapp".
/// Set your pipeline's Front-end Redirect URL to "<scheme>://akedly/callback".
func start(iframeUrl: URL,
callbackScheme: String,
onResult: @escaping (Result<URL, Error>) -> Void) {
let session = ASWebAuthenticationSession(
url: iframeUrl,
callbackURLScheme: callbackScheme
) { callbackURL, error in
if let callbackURL { onResult(.success(callbackURL)) }
else if let error { onResult(.failure(error)) }
}
session.presentationContextProvider = self
session.prefersEphemeralWebBrowserSession = false // share cookies + passkeys
self.session = session
session.start()
}
func presentationAnchor(for s: ASWebAuthenticationSession) -> ASPresentationAnchor {
UIApplication.shared.connectedScenes
.compactMap { ($0 as? UIWindowScene)?.keyWindow }.first ?? ASPresentationAnchor()
}
}
// Usage — retain the helper for the session's lifetime. A temporary would be
// deallocated when this function returns, cancelling the flow; store it (e.g. a
// `var akedlyAuth: AkedlyAuth?` property on your view controller):
self.akedlyAuth = AkedlyAuth()
self.akedlyAuth?.start(iframeUrl: iframeUrl, callbackScheme: "myapp") { result in
switch result {
case .success(let url):
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems
let status = items?.first { $0.name == "status" }?.value // "success" / "failed"
// read transactionId, attemptId, timestamp, meta_* the same way
case .failure(let error):
print("Auth cancelled or failed: \(error)")
}
}
Migrating from a WebView (WKWebView / Android WebView)?
Already shipping the widget inside a WebView? Move it to a system browser. Your backend
create-attempt call is unchanged — same iframeUrl. Swap the WebView for the native API above,
point your pipeline's Front-end Redirect URL at a deep link, and read the result from that callback
instead of intercepting WebView navigation. Passkeys then light up automatically and OTP keeps
working — no other changes.
Step 4: Handle Callbacks
Backend Webhook Payload
If you configure a backendCallbackURL in your pipeline settings, Akedly sends a POST request to your server with complete verification details.
Widget webhooks are sent once and are not retried
This is a different contract from Utility webhooks, and weaker in three ways worth designing around:
- One attempt. If your server is down, times out, or returns a
500, the result is not re-sent — and there is no delivery record to inspect afterwards. - No event envelope. There is no
eventIdoreventType; the payload is the flat object below, identified by itsstatusfield. - Signing does not fail closed. The request is normally signed with the pipeline's
whsec_secret, but unlike utility webhooks the widget still sends if signing fails. Treat a missing signature as "unverifiable", not as proof of forgery.
Because nothing is retried, do not make this your only record of a verification — confirm state from the API for anything that matters. See Webhooks for how the three systems compare.
Webhook Timing:
The webhook is sent:
- Immediately after successful verification (OTP verified)
- Immediately after failed verification (invalid OTP, expired, etc.)
Webhook payload contract:
V2 Widget callbacks are signed
The V2 Widget callback is an envelope with top-level status and timestamp, plus attempt, transaction, metadata, and optional error data.
- Name
Payload- Type
- object
- Description
- The envelope contains
status,timestamp,widgetAttempt,transaction,transactionReq,publicMetadata, andprivateMetadata; failed callbacks may also includeerror.
- Name
Dev-mode marker- Type
- widgetAttempt.isTest
- Description
- A test-pair attempt is marked by
widgetAttempt.isTest. It is not a top-level webhook field.
- Name
Signature- Type
- svix headers
- Description
- The widget success path supplies the pipeline signing secret to the common dispatcher, which generates
svix-id,svix-timestamp, andsvix-signature.
Webhook Payloads
{
"status": "success",
"timestamp": "2025-01-16T12:05:30.123Z",
"widgetAttempt": {
"attemptId": "attempt_a1b2c3d4e5f67890",
"widgetId": "widget_x1y2z3",
"userId": "67890abcdef12345",
"status": "verified",
"verificationAddress": {
"phoneNumber": "+20****7890",
"email": "user@example.com"
},
"otpConfig": {
"digits": 6,
"resendCount": 0
},
"createdAt": "2025-01-16T12:00:00.000Z",
"expiresAt": "2025-01-16T12:05:00.000Z",
"captchaVerifiedAt": "2025-01-16T12:01:15.500Z",
"otpRequestedAt": "2025-01-16T12:01:30.200Z",
"completedAt": "2025-01-16T12:05:30.123Z"
},
"transaction": {
"transactionID": "ae2eacaebe3ed78b105498d5d0cfe54f",
"status": "Successful",
"verificationAddress": {
"phoneNumber": "+201234567890",
"email": "user@example.com"
},
"OTP": "123456",
"creationDate": "2025-01-16T12:01:25.000Z",
"expirationDate": "2025-01-16T12:04:25.000Z",
"updateDate": "2025-01-16T12:05:30.123Z",
"userID": "67890abcdef12345",
"pipelineID": "abc123pipeline"
},
"transactionReq": {
"_id": "req_9876543210",
"status": "Successful",
"mainTransactionID": "ae2eacaebe3ed78b105498d5d0cfe54f",
"sentVerification": true,
"creationDate": "2025-01-16T12:01:30.000Z",
"expirationDate": "2025-01-16T12:04:30.000Z",
"sentVerificationDate": "2025-01-16T12:01:31.500Z",
"inputOTP": "123456",
"verificationDate": "2025-01-16T12:05:30.123Z"
},
"publicMetadata": {
"userId": "user_12345",
"orderId": "order_abc789"
},
"privateMetadata": {
"internalUserId": "internal_xyz",
"sessionToken": "sess_secret_token"
}
}
The top-level failure status and attempt status differ
A failed OTP verification is sent with top-level status: "failed", but the
widgetAttempt.status can still be "otp_requested" because an invalid OTP does not
terminalize the attempt. Branch on the top-level status and inspect error; do not assume the
nested attempt status is always "failed".
Verifying Webhook Signatures
When signature headers are present, the JSON payload above is what gets signed — verify the signature before parsing the body, on the raw request bytes.
Letting Users Choose Their Channel
Let the person receiving the code decide where it arrives — WhatsApp, Telegram or SMS — instead of the pipeline deciding for them. Like passkeys, this layers onto the existing widget: same attempt lifecycle, same result contracts, and no change to your integration at all.
Opt-in, and backward-compatible
There are no new API routes, parameters, or SDK upgrades — the widget runs the picker itself. Change nothing and the widget keeps routing exactly as it does today. Switched on per account by Akedly, and per pipeline by you in Dashboard > Pipelines.
How it works
The picker only ever appears after the user has passed the standard checks — it is never the first thing they see.
- Captcha. The user passes the Cloudflare Turnstile check, exactly as today.
- The choice appears, in one of two shapes depending on the pipeline's mode:
- Ask first — nothing has been sent yet, and the user is asked up front where they want the code.
- Send first — the code goes out on the pipeline's normal channel immediately, and the other channels are offered under the OTP input, for when it has not arrived.
- They pick, and the code is sent on that channel. From there the flow is the ordinary OTP flow.
- Success is identical — same redirect, same signed webhook, same postMessage. Nothing about your callback handling changes.
Only phone channels can be chosen
whatsapp, telegram and sms are pickable. Email is not — if your
pipeline sends email, it is still sent alongside the phone channel when that
phone channel succeeds; if the chosen phone channel fails, the attempt ends
there and no email goes out. A pipeline with only one eligible phone channel
shows no picker at all; the user simply gets their code as before.
One resend per attempt
This is the part integrators most often get wrong: an attempt allows exactly one resend in total — and choosing a different channel is that resend.
- The first send goes out on the channel the user picked (or, in send-first mode, on the pipeline's own first channel).
- They then get one more send. They may spend it on the same channel or on a different one; either way it is the same single resend.
- After that, further resends are refused with
RESEND_LIMIT_EXCEEDEDand the user must start a new attempt. This cap is not new and is not specific to channel choice — it is the widget's existing resend limit.
Do not promise your users unlimited retries
A second resend fails, whichever channel it names. If your interface offers a "didn't get it? send again" affordance, disable it once the one resend is spent, rather than letting the user tap into an error.
When a channel cannot be reached at all
telegram is the only channel whose reachability we can test before spending a send. If the number is not on Telegram, the user is returned to the picker with Telegram removed and their resend is not consumed — nothing was sent, so nothing was spent.
whatsapp and sms cannot be tested up front. A number with no WhatsApp is only discovered after the message is accepted by the provider and later reported undelivered, which is a delivery failure rather than a bounce — so it is handled by the settings below, not by returning the user to the picker.
When a chosen channel fails to deliver
Two settings, chosen per pipeline. Both concern a send the provider accepted and later reported as failed:
- Fallback (default) — the pipeline's normal fallback order takes over, so the user still gets their code, just not where they asked.
- Hard pin — no fallback is sent; the attempt is left to fail rather than arriving somewhere the user did not choose. Choose this when "this channel or nothing" matters more than delivery.
An immediately-rejected channel never falls back
These settings govern late failures only. If the provider rejects the send outright, the request fails then and there under either setting — the fallback chain is not consulted for a channel the user chose explicitly.
Billing
Each channel bills at its own rate, exactly as sending does today — a user choosing SMS costs what SMS costs on that country's rate. The feature adds no charge of its own, and a bounce that sent nothing bills nothing. Your own per-country rates are in Dashboard > Cost.
Passkeys
Let returning users verify with the biometric that already unlocks their device — Face ID, Touch ID, Windows Hello, or a screen lock — instead of waiting for an OTP. Passkeys layer directly onto the V2 Widget: same attempt lifecycle, same result contracts, one optional change to your embed.
Recommended, opt-in, and backward-compatible
Successful passkey authentications use your account's passkey pricing configuration. In factor mode, the checked-in default is 60% of the end user's frozen OTP anchor rate; fixed-rate or plan-specific configuration can differ. Enrollment is free. They're fully backward-compatible: change nothing and the widget keeps running OTP exactly as today. The complete reference lives on the Passkeys guide.
How passkeys work
Passkeys are woven into the existing widget flow — the user is never stranded on a passkey screen.
- Captcha. The user passes the standard Cloudflare Turnstile check, exactly as today.
- Returning user with a passkey on this device. If the bound phone already has a passkey on this device, the widget offers an optional "Use passkey" button. "Use a code instead" is always shown alongside it.
- Everyone else goes straight to normal OTP — no extra screens.
- Success is identical to OTP — same redirect, same webhook contract, same postMessage. A passkey success may add
verificationMethod: "passkey". - After an OTP success, the widget may show an optional, skippable "Enable passkey" prompt so the next verification is instant.
Nobody is ever trapped
If passkeys are unsupported on the device, the user cancels, or the account isn't entitled, the widget silently falls back to OTP — no dead end, no error shown. Adding passkey support can never break an existing integration.
What to add per surface
What you change depends only on how you embed the widget. There are no new API routes, parameters, or SDK upgrades — the widget runs the passkey ceremonies itself.
Add one attribute to your iframe so the embedded widget is permitted to invoke WebAuthn:
allow="publickey-credentials-get *; publickey-credentials-create *"
Add the allow attribute
<!-- Before -->
<iframe src="https://auth.akedly.io/auth?attemptId=..."></iframe>
<!-- After -->
<iframe
src="https://auth.akedly.io/auth?attemptId=..."
allow="publickey-credentials-get *; publickey-credentials-create *"
></iframe>
Prefer to scope the permission to Akedly's origin instead of the * wildcard:
allow="publickey-credentials-get https://auth.akedly.io; publickey-credentials-create https://auth.akedly.io"
Nothing else moves: the iframe URL stays …/auth?attemptId=…, and auth.akedly.io already serves the required Permissions-Policy and CSP headers — you only ever touch your own <iframe> tag.
Enabling passkeys for your account
Passkeys appear only when all three of these are true:
- Name
Global rollout- Type
- Akedly-controlled
- Description
- Passkeys are generally available. Akedly controls the global rollout switch.
- Name
Account entitlement- Type
- account access
- Description
- Granted automatically on the unverified-to-verified transition, so a verified account normally has it. The grant is transition-only: an account that has never verified receives nothing automatically, and one whose entitlement an admin revoked does not get it back by verifying again. An admin can also grant or revoke it directly, independently of verification.
- Name
Passkeys- Type
- pipeline toggle
- Description
- The dashboard exposes the pipeline setting as
Passkeys. New pipelines are created with passkeys on; existing pipelines keep their current setting.
Add the allow attribute now, then — once the pipeline's Passkeys setting is on (stored as
pipeline.passkeyEnabled), the current production rollout is on, and the account is entitled —
passkeys light up with no further code changes.
Debugging tips
Security Features
V2.0 Widgets include enterprise-grade security features at no additional cost. Our multi-layered approach combines visible and invisible protection mechanisms.
Proprietary Security Layers
In addition to the security features documented below, V2.0 Widgets include 3 additional proprietary security layers that operate behind the scenes. These undisclosed mechanisms handle a significant portion of fraud prevention and are kept confidential for security reasons.
Bot Protection
Invisible bot protection runs automatically before any OTP delivery, blocking automated attacks without user friction.
What It Does:
- Validates request authenticity using behavioral analysis
- Blocks bot traffic before OTP delivery
- Reduces fraudulent authentication attempts
- Saves costs by preventing fake verification requests
Device Intelligence
Advanced device analysis creates risk profiles to enable fraud detection and enforce security policies.
Capabilities:
- Generates unique device identifiers for tracking
- Detects suspicious behavior patterns across sessions
- Enables device-based rate limiting and analytics
- Supports fraud metrics in your dashboard
Akedly does not disclose every attribution signal or risk heuristic to prevent
reverse-engineering attempts. The required validation fields are documented in
the FINGERPRINT_INCONSISTENT entry below.
Circuit Breaker
Automatic flood protection suspends widgets under attack with progressive suspension durations.
How It Works:
- Monitors traffic patterns across multiple time windows
- Triggers automatically when abnormal activity is detected
- Applies progressive suspension durations
- Resumes automatically when threat subsides
Rate Limiting
Multi-dimensional rate limiting protects at phone number, device, and widget levels.
Configurable Limits:
- Per phone number limits (attempts and OTP requests)
- Per device limits (attempts and OTP requests)
- Per widget global limits
Default values are suitable for most use cases. Configure custom limits under Authentication → Setup → Widgets → edit your widget → the Rate Limiting section.
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, near the end of
request-otp and resend-otp, so any earlier error on those endpoints wins over a ban. Dual-path codes document both the map-mediated passkey response and the controller-ladder
Widget response.
{
"status": "error",
"code": "ERROR_CODE",
"message": "Human-readable description",
"retryable": true, // Optional: whether user can retry
"retryAfter": "ISO8601", // Optional: when to retry (for rate limits)
"cooldownSeconds": 60, // Optional: seconds until retry allowed
"details": {} // Optional: additional context
}
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_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.
| Code | HTTP | Body fields | Meaning |
|---|---|---|---|
| INVALID_API_KEY | 401 | status, code, message; conditional suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: The API key is invalid. |
| WIDGET_USER_MISMATCH | 403 | status, code, message; conditional suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: The widget does not belong to the API-key owner. |
| WIDGET_INACTIVE | 400 | status, code, message; conditional suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: The widget is inactive. |
| PIPELINE_NOT_CONFIGURED | 400 | status, code, message; conditional suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: The widget has no pipeline configured. |
| PIPELINE_NOT_FOUND | 404 | status, code, message; conditional suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: The configured pipeline could not be found. |
| CIRCUIT_BREAKER_OPEN | 503 | status, code, message; conditional suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: The widget is temporarily suspended by its circuit breaker. |
| INSUFFICIENT_QUOTA | 402 | status, code, message; conditional 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 suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: Combined public and private metadata exceeds 10 KB. |
| CUSTOM_HEADERS_SIZE_EXCEEDED | 400 | status, code, message; conditional suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: Custom headers exceed 4 KB. |
| NATIONAL_PIPELINE_RESTRICTION | 403 | status, code, message; conditional suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: The destination is restricted by the pipeline’s national policy. |
| DESTINATION_COUNTRY_BLOCKED | 403 | status, code, message; conditional suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: The destination country is blocked by pipeline policy. |
| INVALID_OTP_DIGITS | 400 | status, code, message; conditional suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: `digits` must be 4, 5, or 6. |
| OTP_DIGITS_MISMATCH | 400 | status, code, message; conditional suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: The custom OTP length does not match `digits`. |
| INVALID_TEST_PAIR | 400 | status, code, message; conditional 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 suspendedUntil, retryable, requiredQuota, remainingQuota | create-attempt: The widget attempt could not be created. |
| 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 and device ID 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 | 502 | status, code, message, retryable | verify-captcha: The captcha provider returned an API error. |
| CAPTCHA_VALIDATION_TIMEOUT | 400 | status, code, message, retryable | verify-captcha: Captcha validation timed out. |
| 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. |
| 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. |
| 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. |
| RESEND_OTP_FAILED | 500 | status, code, message, retryable | resend-otp: The resend operation failed unexpectedly. |
| 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, and it is close to last in both — so an earlier error always wins over the ban.
request-otp checks, in order: required fields, attempt lookup, attempt expiry, the live test-pair/widget load, the idempotent already-requested return, captcha state, the account lookup, then the ban. 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 resend limit, the transaction lookup and its ownership, transaction expiry, then the ban.
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 |
|---|---|---|---|
| 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: The request reached the rate-limit or circuit-breaker layer without a resolvable widget ID, and is refused BEFORE the endpoint handler — so this pre-empts that endpoint's own MISSING_REQUIRED_FIELDS and ATTEMPT_NOT_FOUND rows. On create-attempt it is what an UNKNOWN publicKey returns; on the others it is what an absent or unresolvable attemptId returns. |
| RATE_LIMIT_WIDGET_OTP | 429 | retryable, retryAfter, cooldownSeconds, details | request-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: Too many OTP requests for this phone number. |
| RATE_LIMIT_DEVICEID_OTP | 429 | retryable, retryAfter, cooldownSeconds, details | request-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-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
Solutions:
- Check browser console for errors
- Verify iframeUrl contains a valid attemptId
- Ensure parent page uses HTTPS in production
- Confirm attempt hasn't expired
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
Support & Resources
Getting Help
- Dashboard: https://app.akedly.io
- Support: support@akedly.io
- Co-founders: muhad@akedly.io, hana@akedly.io
Additional Resources
Version: 2.0.0
Last Updated: August 4th, 2026
API Base URL: https://api.akedly.io/api/v1/widget-sdk
