Akedly

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. In the left sidebar, open Authentication → Setup → Widgets
  3. Click Create Widget (the button reads Create Your First Widget on the empty state)

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.

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 with meta_. 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.

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 retryAfter and cooldownSeconds fields in the 429 response, which are measured from the oldest counted attempt in the window that was breached.

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
    Widget Secret (SAVE THIS NOW)
    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

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.


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/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, content-length and anything beginning svix- are blacklisted, silently filtered out before dispatch, and the filtered result is persisted with the attempt. The svix- prefix is reserved because that is where Akedly puts the callback's own signature headers — supplying your own would let a caller overwrite them.

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"
  }
}

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

// 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:

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

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 (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)")
  }
}

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.)

Webhook payload contract:

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

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.

How it works

The picker only ever appears after the user has passed the standard checks — it is never the first thing they see.

  1. Captcha. The user passes the Cloudflare Turnstile check, exactly as today.
  2. 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.
  3. They pick, and the code is sent on that channel. From there the flow is the ordinary OTP flow.
  4. Success is identical — same redirect, same signed webhook, same postMessage. Nothing about your callback handling changes.

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_EXCEEDED and 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.

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.

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.

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 webhook contract, 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 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.

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 under Authentication → Setup → Widgets → edit your widget → the Rate Limiting section.


Error Reference

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.

CodeHTTPRetryableAction
INVALID_SIGNATURE401falseCheck signature generation logic
SIGNATURE_EXPIRED401trueGenerate new signature with current timestamp
ATTEMPT_NOT_FOUND404falseCreate a new attempt
ATTEMPT_EXPIRED410falseCreate a new attempt
CHALLENGE_EXPIRED410trueRequest a new device challenge
CAPTCHA_NOT_VERIFIED403trueComplete captcha verification first
WIDGET_NOT_FOUND404falseVerify widget exists
MISSING_REQUIRED_FIELDS400falseCheck request parameters
PASSKEY_DISABLED403falsePasskeys are not enabled for this account/pipeline; fall back to OTP
PASSKEY_NOT_VERIFIED403falseComplete OTP verification before enrolling a passkey
PASSKEY_REGISTRATION_FAILED400trueRetry passkey enrollment
PASSKEY_AUTH_FAILED403trueRetry, or use a one-time code instead
NO_PASSKEY404falseNo passkey on this device; use a one-time code
TEST_PAIR_RETIRED403falseDev-mode test pair changed; re-open dev mode or use a real one-time code
INSUFFICIENT_QUOTA402falseTop up quota or upgrade the plan
BILLING_FAILED500trueRetry, or use a one-time code instead

Error-handler fallbacks

CodeHTTPRetryableAction
VALIDATION_ERROR400falseCheck request parameters
INVALID_ID_FORMAT400falseProvide valid ID format
INTERNAL_SERVER_ERROR500falseContact 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.

CodeHTTPBody fieldsMeaning
INVALID_API_KEY401status, code, message; conditional suspendedUntil, retryable, requiredQuota, remainingQuotacreate-attempt: The API key is invalid.
WIDGET_USER_MISMATCH403status, code, message; conditional suspendedUntil, retryable, requiredQuota, remainingQuotacreate-attempt: The widget does not belong to the API-key owner.
WIDGET_INACTIVE400status, code, message; conditional suspendedUntil, retryable, requiredQuota, remainingQuotacreate-attempt: The widget is inactive.
PIPELINE_NOT_CONFIGURED400status, code, message; conditional suspendedUntil, retryable, requiredQuota, remainingQuotacreate-attempt: The widget has no pipeline configured.
PIPELINE_NOT_FOUND404status, code, message; conditional suspendedUntil, retryable, requiredQuota, remainingQuotacreate-attempt: The configured pipeline could not be found.
CIRCUIT_BREAKER_OPEN503status, code, message; conditional suspendedUntil, retryable, requiredQuota, remainingQuotacreate-attempt: The widget is temporarily suspended by its circuit breaker.
INSUFFICIENT_QUOTA402status, code, message; conditional suspendedUntil, retryable, requiredQuota, remainingQuotacreate-attempt: The account does not have enough quota for the widget attempt.
METADATA_SIZE_EXCEEDED400status, code, message; conditional suspendedUntil, retryable, requiredQuota, remainingQuotacreate-attempt: Combined public and private metadata exceeds 10 KB.
CUSTOM_HEADERS_SIZE_EXCEEDED400status, code, message; conditional suspendedUntil, retryable, requiredQuota, remainingQuotacreate-attempt: Custom headers exceed 4 KB.
NATIONAL_PIPELINE_RESTRICTION403status, code, message; conditional suspendedUntil, retryable, requiredQuota, remainingQuotacreate-attempt: The destination is restricted by the pipeline’s national policy.
DESTINATION_COUNTRY_BLOCKED403status, code, message; conditional suspendedUntil, retryable, requiredQuota, remainingQuotacreate-attempt: The destination country is blocked by pipeline policy.
INVALID_OTP_DIGITS400status, code, message; conditional suspendedUntil, retryable, requiredQuota, remainingQuotacreate-attempt: `digits` must be 4, 5, or 6.
OTP_DIGITS_MISMATCH400status, code, message; conditional suspendedUntil, retryable, requiredQuota, remainingQuotacreate-attempt: The custom OTP length does not match `digits`.
INVALID_TEST_PAIR400status, code, message; conditional suspendedUntil, retryable, requiredQuota, remainingQuotacreate-attempt: The stored dev-mode test pair has an unsupported OTP length.
CREATE_ATTEMPT_FAILED400status, code, message; conditional suspendedUntil, retryable, requiredQuota, remainingQuotacreate-attempt: The widget attempt could not be created.
MISSING_REQUIRED_FIELDS400status, code, messageregister-device: The attempt ID and fingerprint are required.
ATTEMPT_NOT_FOUND404status, code, messageregister-device: The attempt could not be found.
ATTEMPT_EXPIRED410status, code, messageregister-device: The attempt has expired.
FINGERPRINT_INCONSISTENT400status, code, messageregister-device: The fingerprint is missing required fields or contains invalid screen data.
DEVICE_REGISTRATION_FAILED500status, code, messageregister-device: The device could not be registered.
MISSING_REQUIRED_FIELDS400status, code, messagevalidate-attempt: The attempt ID and device ID are required.
ATTEMPT_NOT_FOUND404status, code, messagevalidate-attempt: The attempt could not be found.
ATTEMPT_EXPIRED410status, code, messagevalidate-attempt: The attempt has expired.
INVALID_DEVICE_ID400 or 403status, code, messagevalidate-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_INCONSISTENT400status, code, messagevalidate-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_FAILED400status, code, messagevalidate-attempt: The attempt could not be validated.
MISSING_REQUIRED_FIELDS400status, code, message, retryableverify-captcha: The attempt ID, device ID, and captcha token are required.
ATTEMPT_NOT_FOUND404status, code, message, retryableverify-captcha: The attempt could not be found.
ATTEMPT_EXPIRED410status, code, message, retryableverify-captcha: The attempt has expired.
POW_SOLUTION_MISSING400status, code, message, retryableverify-captcha: A proof-of-work solution is required.
POW_INVALID_TOKEN400status, code, message, retryableverify-captcha: The proof-of-work token is invalid.
POW_INVALID_SIGNATURE400status, code, message, retryableverify-captcha: The proof-of-work signature is invalid.
POW_CHALLENGE_EXPIRED410status, code, message, retryableverify-captcha: The proof-of-work challenge has expired.
POW_ATTEMPT_MISMATCH400status, code, message, retryableverify-captcha: The proof-of-work challenge belongs to another attempt.
POW_CHALLENGE_REPLAYED409status, code, message, retryableverify-captcha: The proof-of-work challenge was already used.
POW_INVALID_SOLUTION400status, code, message, retryableverify-captcha: The proof-of-work solution is invalid.
POW_VERIFICATION_FAILED500status, code, message, retryableverify-captcha: Proof-of-work verification failed unexpectedly.
CAPTCHA_ALREADY_USED409status, code, message, retryableverify-captcha: The captcha token was already consumed.
CAPTCHA_VALIDATION_FAILED400status, code, message, retryableverify-captcha: Cloudflare rejected the submitted Turnstile token.
CAPTCHA_API_ERROR502status, code, message, retryableverify-captcha: The captcha provider returned an API error.
CAPTCHA_VALIDATION_TIMEOUT400status, code, message, retryableverify-captcha: Captcha validation timed out.
CAPTCHA_TOKEN_REUSED400status, code, message, retryableverify-captcha: The same captcha token was submitted twice concurrently.
CAPTCHA_NETWORK_ERROR400status, code, message, retryableverify-captcha: Captcha validation could not reach the provider.
CAPTCHA_VERIFICATION_FAILED400status, code, message, retryableverify-captcha: Captcha verification failed unexpectedly.
MISSING_REQUIRED_FIELDS500status, code, message, retryablerequest-otp: The attempt ID or device ID is missing.
ATTEMPT_NOT_FOUND404status, code, message, retryablerequest-otp: The attempt could not be found.
ATTEMPT_EXPIRED410status, code, message, retryablerequest-otp: The attempt has expired.
CAPTCHA_NOT_VERIFIED403status, code, message, retryablerequest-otp: Captcha verification must be completed first.
TEST_PAIR_RETIRED403status, code, message, retryablerequest-otp: The dev-mode test pair is no longer active.
DESTINATION_COUNTRY_BLOCKED403status, code, message, retryablerequest-otp: The destination country is blocked by pipeline policy.
NATIONAL_PIPELINE_RESTRICTION403status, code, message, retryablerequest-otp: The destination is restricted by the pipeline’s national policy.
TRANSACTION_NOT_FOUND500status, code, message, retryablerequest-otp: The transaction could not be found while requesting an OTP.
TRANSACTION_CREATION_FAILED500status, code, message, retryablerequest-otp: The underlying transaction could not be created.
OTP_SEND_FAILED500status, code, message, retryablerequest-otp: OTP delivery failed while requesting an OTP.
REQUEST_OTP_FAILED500status, code, message, retryablerequest-otp: The OTP request failed outside the named terminal cases.
MISSING_REQUIRED_FIELDS400status, code, message, retryable; conditional redirectUrlverify-otp: Required verification fields are missing.
ATTEMPT_NOT_FOUND404status, code, message, retryable; conditional redirectUrlverify-otp: The attempt could not be found.
OTP_NOT_REQUESTED403status, code, message, retryable; conditional redirectUrlverify-otp: An OTP must be requested before it can be verified.
TRANSACTION_NOT_FOUND404status, code, message, retryable; conditional redirectUrlverify-otp: The transaction could not be found.
TRANSACTION_EXPIRED410status, code, message, retryable; conditional redirectUrlverify-otp: The transaction has expired.
INVALID_OTP403status, code, message, retryable; conditional redirectUrlverify-otp: The submitted OTP is invalid.
MAX_ATTEMPTS_EXCEEDED429status, code, message, retryable; conditional redirectUrlverify-otp: The transaction reached its failed-OTP limit; start a new attempt.
TEST_PAIR_RETIRED403status, code, message, retryable; conditional redirectUrlverify-otp: The dev-mode test pair is no longer active.
BILLING_FAILED400status, code, message, retryable; conditional redirectUrlverify-otp: Billing failed during widget OTP verification.
VERIFY_FAILED400status, code, message, retryable; conditional redirectUrlverify-otp: The shared verification service returned an unhandled failure.
VERIFY_OTP_FAILED400status, code, message, retryable; conditional redirectUrlverify-otp: OTP verification failed unexpectedly.
MISSING_REQUIRED_FIELDS500status, code, message, retryableresend-otp: Required resend fields are missing.
ATTEMPT_NOT_FOUND404status, code, message, retryableresend-otp: The attempt could not be found.
OTP_NOT_REQUESTED403status, code, message, retryableresend-otp: An OTP must be requested before it can be resent.
RESEND_LIMIT_EXCEEDED429status, code, message, retryableresend-otp: The attempt has reached its resend limit.
TRANSACTION_NOT_FOUND404status, code, message, retryableresend-otp: The transaction could not be found.
TRANSACTION_EXPIRED410status, code, message, retryableresend-otp: The transaction has expired.
TEST_PAIR_RETIRED403status, code, message, retryableresend-otp: The dev-mode test pair is no longer active.
OTP_RESEND_FAILED500status, code, message, retryableresend-otp: The OTP resend failed outside the named terminal cases.
RESEND_OTP_FAILED500status, code, message, retryableresend-otp: The resend operation failed unexpectedly.
MISSING_PUBLIC_KEY400status, code, messagewidget-branding: The widget branding request did not include a public key.
WIDGET_NOT_FOUND404status, code, messagewidget-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.

CodeHTTPExtra body fieldsMeaning
RATE_LIMIT_WIDGET_ATTEMPTS429retryable, retryAfter, cooldownSeconds, detailscreate-attempt · validate-attempt: Too many attempts for this widget — the whole widget is over budget, not one end user.
RATE_LIMIT_PHONENUMBER_ATTEMPTS429retryable, retryAfter, cooldownSeconds, detailscreate-attempt · validate-attempt: Too many attempts for this phone number.
RATE_LIMIT_DEVICEID_ATTEMPTS429retryable, retryAfter, cooldownSeconds, detailscreate-attempt · validate-attempt: Too many attempts from this device.
CIRCUIT_BREAKER_OPEN503retryable, retryAfter, cooldownSeconds, detailscreate-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_TRIGGERED503retryable, retryAfter, cooldownSeconds, detailscreate-attempt · register-device · validate-attempt · verify-captcha: The widget was flood-suspended by this request. retryAfter carries the suspension end.
MISSING_WIDGET_ID400messagecreate-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_OTP429retryable, retryAfter, cooldownSeconds, detailsrequest-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_OTP429retryable, retryAfter, cooldownSeconds, detailsrequest-otp: Too many OTP requests for this phone number.
RATE_LIMIT_DEVICEID_OTP429retryable, retryAfter, cooldownSeconds, detailsrequest-otp: Too many OTP requests from this device.
IDENTITY_TEMPORARILY_BLOCKED429retryable, retryAfter, cooldownSecondsrequest-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

CodeShared formatter pathDirect response path
WIDGET_NOT_FOUNDWhen 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_FAILEDPOST /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_QUOTAPOST /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_RETIREDPOST /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)
  },
)

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 signs JSON.stringify({ apiKey, publicKey, timestamp })phoneNumber is omitted entirely, not sent as null or "", because the server builds the same string from an absent value and JSON.stringify drops 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

Additional Resources


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

Was this page helpful?