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.
V2.0 Widgets Enable PPSA Eligibility
Pay-Per-Successful-Authentication (PPSA) is only available with V2.0 Widgets for qualifying startups. Requirements will be published by end of December 2025. Eligible users can save 20-40% by only paying for verified users, not failed attempts or bot traffic.
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 — billed at 60% of your OTP rate,
so you save up to 40% per authentication. 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
- Navigate to Widgets in the left sidebar
- Click Create New Widget
Basic Settings
- 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 Customization:
- Name
Company Logo- Type
- file
- Description
Upload your logo (displayed at top of widget)
- Name
Company Name- Type
- string
- Description
Your company name (shown in widget header)
- Name
Primary Color- Type
- string
- Description
Main brand color for buttons and accents
- Name
Secondary Color- Type
- string
- Description
Background and secondary UI elements
Callback URLs
V2.0 Widgets support two types of callbacks. See Pipeline Setup for detailed configuration, or navigate to Pipelines → Select your pipeline → Callback URLs.
Frontend Callback URL (Must)
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
Backend Callback URL (Must)
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
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.
Security Settings
Configure captcha, rate limiting, and fraud protection in the widget security settings panel.
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 Recommendations: 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
Duration (milliseconds)- Type
- number
- Description
Default: 300000 (5 minutes). Time period a user must wait after hitting rate limits before they can retry.
Keep in mind that there is an implicit cooldown of 1 minute each time a user can request an OTP. Regardless of the rate limiting. A user can not request OTP within 60 seconds of the last OTP request.
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
Secret Key- Type
- string
- Description
Used to sign API requests with HMAC-SHA256
The widget secret will only be displayed once at creation. Make sure you copy it as seen in the screenshot. If you lose it, you'll need to regenerate a new secret from the dashboard. 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
Create a JSON string with these exact keys in this exact order:
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,phoneNumber - 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 5 minutes of server time
API Reference
Required Parameters
- Name
apiKey- Type
- string
- Description
Your Akedly API key from the dashboard API section
- 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 5 minutes of server time.
- 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, andcontent-lengthare blacklisted and will be rejected.
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",
"publicMetadata": {
"userId": "user_12345",
"orderId": "order_abc789"
}
}
}
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, publicMetadata }
}
// 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 Frontend Callback 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 Frontend Callback URL. - Web (full-page redirect) — no iframe means no
postMessage; the result comes purely from the redirect to your pipeline's Frontend Callback URL. - Mobile native (system browser) — there is no parent window, so
postMessagedoes not apply. Use the redirect to your pipeline's Frontend Callback URL (a deep link your app intercepts).
The redirect carries status, transactionId, attemptId, timestamp, and any meta_* parameters in the query string (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 (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 Frontend Callback 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 Frontend Callback 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.
Webhook Timing:
The webhook is sent:
- Immediately after successful verification (OTP verified)
- Immediately after failed verification (invalid OTP, expired, etc.)
Success Webhook Structure:
- Name
status- Type
- string
- Description
"success"
- Name
timestamp- Type
- string
- Description
ISO 8601 timestamp of event
- Name
widgetAttempt- Type
- object
- Description
Complete widget attempt details
- Name
transaction- Type
- object
- Description
MainTransaction object with verification details
- Name
transactionReq- Type
- object
- Description
TransactionReq object with OTP delivery details
- Name
publicMetadata- Type
- object
- Description
Custom public metadata attached during attempt creation (if provided)
- Name
privateMetadata- Type
- object
- Description
Custom private metadata attached during attempt creation (if provided). Only available in webhooks.
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"
}
}
Verifying Webhook Signatures
The JSON payload above is what gets signed — verify the signature before parsing the body, on the raw request bytes.
Backend webhooks are signed
Every callback request ships with HMAC-SHA256 svix-id, svix-timestamp, and svix-signature headers, signed with a per-pipeline secret only you and Akedly know. Verify the signature on your side before trusting the payload — without it, anyone who learns your callback URL can forge a successful authentication. See the webhook signing guide →
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 are billed at 60% of your OTP rate — you save up to 40% per authentication — and 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 signed webhook, 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 both of these are true:
- Name
Account enabled- Type
- entitlement
- Description
Akedly enables passkeys per account during rollout. Ask us to turn it on, or flip the toggle in your dashboard if it's available to you.
- Name
Pipeline passkeys on- Type
- toggle
- Description
Each pipeline carries a
passkeyEnabledtoggle (default: on). Leave it on to allow passkeys, or turn it off to keep a pipeline OTP-only.
Add the allow attribute now and passkeys light up the moment your account is enabled — 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
Specific fingerprinting attributes are intentionally not disclosed to prevent reverse-engineering attempts.
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 in Dashboard → Widgets → Security Settings.
Error Reference
Error Format
All errors follow this format:
{
"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
}
HTTP Status Codes
| Status Code | Description |
|---|---|
| 400 | Invalid request parameters, missing fields |
| 401 | Invalid credentials, expired signature |
| 403 | Permission denied, inactive widget, verification not complete |
| 404 | Resource not found (widget, attempt, transaction) |
| 409 | Resource already used (captcha token reused) |
| 402 | Insufficient account quota for the transaction |
| 410 | Resource expired (attempt or transaction expired) |
| 429 | Rate limit exceeded |
| 500 | Server-side error during processing |
| 502 | External service error (Cloudflare Turnstile API) |
| 503 | Circuit breaker triggered (flood protection) |
Authentication & Security Errors
- Name
INVALID_API_KEY- Description
The provided API key is invalid or doesn't exist.
Solution: Verify your API key in the dashboard at Settings → API.
- Name
INVALID_PUBLIC_KEY- Description
The widget public key doesn't exist.
Solution: Check your widget public key in the dashboard under Widgets.
- Name
INVALID_SIGNATURE- Description
HMAC signature validation failed.
Solution: Ensure you're generating the signature correctly using the widget secret. Verify the message payload matches exactly (JSON stringify with no extra spaces).
- Name
SIGNATURE_EXPIRED- Description
The timestamp in the signature is too old (>5 minutes) or in the future.
Solution: Ensure your server clock is synchronized (use NTP). Generate timestamp immediately before creating signature.
- Name
WIDGET_USER_MISMATCH- Description
The widget doesn't belong to the user associated with the API key.
Solution: Ensure you're using the correct API key and public key pair.
- Name
WIDGET_INACTIVE- Description
The widget status is set to "inactive" or "suspended".
Solution: Activate the widget in the dashboard under Widgets → [Your Widget] → Status.
- Name
METADATA_SIZE_EXCEEDED- Description
Combined
publicMetadataandprivateMetadataexceeds 10KB limit.Solution: Reduce the size of your metadata objects. Consider storing large data server-side and only passing references.
- Name
WIDGET_NOT_FOUND- Description
Widget with the provided public key doesn't exist.
Solution: Verify your public key is correct.
- Name
ATTEMPT_NOT_FOUND- Description
Authentication attempt not found or doesn't match device.
Solution: Ensure you're using the correct attemptId. User may need to restart authentication.
- Name
ATTEMPT_EXPIRED- Description
Authentication attempt has expired (>5 minutes old).
Solution: User must start a new authentication attempt. Show "Session expired" message.
- Name
TRANSACTION_EXPIRED- Description
OTP transaction has expired (>3 minutes since OTP was sent).
Solution: User can request a new OTP (if resend limit not exceeded) or start over.
- Name
MAX_ATTEMPTS_EXCEEDED- Description
10 wrong OTPs were entered for this transaction (HTTP 429, not retryable). The transaction is force-expired; the hosted widget shows a terminal "Too many incorrect attempts" state and then redirects to your failure callback with
error=MAX_ATTEMPTS_EXCEEDED.Solution: User must start a new authentication attempt.
Rate Limiting Errors
All rate limit errors include retryAfter (ISO8601 timestamp) and cooldownSeconds (integer).
- Name
RATE_LIMIT_PHONENUMBER_ATTEMPTS- Description
Too many authentication attempts for this phone number.
Solution: User must wait until
cooldownSecondsexpires. Show countdown timer.
- Name
RATE_LIMIT_PHONENUMBER_OTP- Description
Too many OTP requests for this phone number.
Solution: User must wait before requesting another OTP.
- Name
RATE_LIMIT_DEVICEID_ATTEMPTS- Description
Too many authentication attempts from this device.
Solution: Device-based rate limit. User must wait or try from different device.
- Name
RATE_LIMIT_WIDGET_ATTEMPTS- Description
Too many authentication attempts for this widget (global).
Solution: Your widget is receiving high traffic. Contact support to increase limits.
Rate limits are configurable per widget in the dashboard. Default values are calibrated for typical use cases and provide strong protection against abuse.
- Name
RESEND_LIMIT_EXCEEDED- Description
Too many OTP resend requests for this attempt.
Solution: Each attempt allows a limited number of resends. Start a new authentication attempt if the user needs another OTP.
Circuit Breaker Errors
- Name
CIRCUIT_BREAKER_OPEN- Description
Widget is currently suspended due to previous flood detection.
Solution: Wait until suspension expires. Check
suspendedUntiltimestamp in the error response.
- Name
CIRCUIT_BREAKER_TRIGGERED- Description
Widget triggered circuit breaker due to flood threshold exceeded.
Solution: Your widget is experiencing abnormal traffic. Monitor your traffic patterns and contact support if this persists.
Circuit breaker thresholds and suspension durations are configurable in Dashboard → Widgets → Security Settings. The system applies progressive suspension periods for repeated violations.
Billing Errors
- Name
INSUFFICIENT_QUOTA- Description
Account balance is too low for the estimated transaction cost. The response includes
requiredQuota(EGP needed) andremainingQuota(EGP available).Solution: Top up your account balance or upgrade your plan. The attempt will not be created until sufficient quota is available.
External Service Errors
- Name
CAPTCHA_API_ERROR- Description
Cloudflare Turnstile API returned an error during captcha verification.
Solution: This is a temporary external service issue. Retry the request. If persistent, check Cloudflare Status.
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 stale (>5 minutes old)
Solutions:
- Verify you're using HMAC-SHA256
- Ensure message format is
JSON.stringify({ apiKey, publicKey, timestamp, phoneNumber }) - Check your widget secret matches the dashboard
- Synchronize server clock with NTP
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 Dashboard → Widgets → [Your Widget]
- Edit widget settings
- 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: January 11th, 2026
API Base URL: https://api.akedly.io/api/v1/widget-sdk