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.


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:

1

Your User Initiates Authentication

User clicks "Login" or "Verify Phone" in your application.

2

Your Backend Creates Attempt

Your backend calls Akedly API with HMAC signature:

POST /api/v1/widget-sdk/create-attempt
3

Akedly 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

4

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} />
5

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
6

Receive Verification Result 🎉

Akedly redirects user to your callback URL & sends webhook to your backend with verification status.


Step 1: Create Widget in Dashboard

Before integrating, create a widget in your Akedly dashboard.

Dashboard Navigation

  1. Log in to your Akedly dashboard at https://app.akedly.io
  2. Navigate to Widgets in the left sidebar
  3. 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 with meta_. 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.

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.

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.

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


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:

  1. User requests authentication - Your frontend collects the phone number and sends it to your backend
  2. Your backend creates a signature - Using your widget secret, you generate an HMAC-SHA256 signature to prove you own the widget
  3. Your backend calls Akedly API - Send the signed request to create an attempt
  4. Akedly returns an iframe URL - You receive a unique URL that opens the authentication widget
  5. Your backend sends URL to frontend - Pass the iframeUrl to your frontend to display the widget

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.


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/or email.

  • Name
    digits
    Type
    number
    Description

    Optional: OTP length: 4, 5 or 6. Defaults to 6. Unlike V1.2, V2 rejects an out-of-range value with INVALID_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 otp and digits, the OTP's length must match digits or the request fails with OTP_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, and content-length are blacklisted and will be rejected.

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

POST
api.akedly.io/api/v1/widget-sdk/create-attempt
{
  "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.

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

POST
api.akedly.io/api/v1/widget-sdk/create-attempt
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:

  1. Web (iframe) — listen for the postMessage AUTH_SUCCESS event, and/or use the redirect to your pipeline's Frontend Callback URL.
  2. Web (full-page redirect) — no iframe means no postMessage; the result comes purely from the redirect to your pipeline's Frontend Callback URL.
  3. Mobile native (system browser) — there is no parent window, so postMessage does 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

Why a system browser is required

  • Name
    Passkeys (WebAuthn)
    Type
    required
    Description

    The widget invokes WebAuthn on the akedly.io origin. 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-line Info.plist CFBundleURLTypes entry on iOS, or a manifest intent-filter on Android, both shown below). An https App 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

PlatformUseResult delivery
iOSASWebAuthenticationSession (preferred) or SFSafariViewControllerCustom-scheme deep link / Universal Link
AndroidChrome Custom Tabs (androidx.browser)Deep link / App Link back into your app
React Nativeexpo-web-browser openAuthSessionAsync (wraps both natives)Return URL from the resolved promise
Flutterflutter_web_auth_2 (wraps both natives)Callback URL from authenticate(...)
Any other frameworkThe 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)")
  }
}

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.


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.

How passkeys work

Passkeys are woven into the existing widget flow — the user is never stranded on a passkey screen.

  1. Captcha. The user passes the standard Cloudflare Turnstile check, exactly as today.
  2. 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.
  3. Everyone else goes straight to normal OTP — no extra screens.
  4. Success is identical to OTP — same redirect, same signed webhook, same postMessage. A passkey success may add verificationMethod: "passkey".
  5. After an OTP success, the widget may show an optional, skippable "Enable passkey" prompt so the next verification is instant.

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 passkeyEnabled toggle (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.

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

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

HTTP Status Codes

Status CodeDescription
400Invalid request parameters, missing fields
401Invalid credentials, expired signature
403

Permission denied, inactive widget, verification not complete

404Resource not found (widget, attempt, transaction)
409Resource already used (captcha token reused)
402Insufficient account quota for the transaction
410Resource expired (attempt or transaction expired)
429Rate limit exceeded
500Server-side error during processing
502External service error (Cloudflare Turnstile API)
503Circuit 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 publicMetadata and privateMetadata exceeds 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 cooldownSeconds expires. 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.

  • 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 suspendedUntil timestamp 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.

Billing Errors

  • Name
    INSUFFICIENT_QUOTA
    Description

    Account balance is too low for the estimated transaction cost. The response includes requiredQuota (EGP needed) and remainingQuota (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)
  },
)

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

Additional Resources


Version: 2.0.0 Last Updated: January 11th, 2026 API Base URL: https://api.akedly.io/api/v1/widget-sdk

Was this page helpful?