Skip to content
Akedly
V2.0 Widgets

Open the widget

Open the widget in an iframe on the web or in a system browser on mobile, and handle browsers that block cookies.

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. For browsers that block cookies, add clipboard-write and check any sandbox attribute — see When the browser blocks cookies.

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) — AUTH_SUCCESS is an optional hint. With a web Front-end Redirect URL, the widget sends the user to that URL from inside the widget frame shortly after success and does not post AUTH_SUCCESS, so your callback page can load inside the iframe. The backend webhook, or a status lookup, is the source of truth. A user who finishes in a new tab is reported through the redirect and the backend webhook, not postMessage — see When the browser blocks cookies.
  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)
      }
    }

    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 *; clipboard-write"
            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)")
  }
}

If you do load the widget in an Android WebView anyway, DOM storage must be on: settings.domStorageEnabled = true in Kotlin, setDomStorageEnabled(true) in Java, or the domStorageEnabled prop in react-native-webview. Android turns it off by default, and the widget then stops on the cookie screen described in When the browser blocks cookies. The real fix is the system browser.


When the browser blocks cookies

The widget keeps a stable device ID in the browser's website storage (localStorage). Some browsers block it: iPhone Safari with "Block All Cookies" on, a browser that blocks cookies for the site, an Android WebView without DOM storage, or a browser that blocks storage only inside embedded frames. Before it makes any verification request, the widget checks that it can write and read storage. If it cannot, the user sees a "Turn on cookies to continue" screen instead of an error.

While storage is blocked, the widget:

  • Makes no verification requests and never continues without storage.
  • Shows step-by-step instructions, with small drawings, for the detected browser. The user can pick another browser from an "other browsers" list if detection is wrong.
  • Uses English or Arabic (right to left) from the device language, and follows the device's light or dark setting. It uses Akedly's default look, not your widget branding.
  • Rechecks when the user taps "I’ve turned it on — try again" and when they return to the page. When storage works, the same attempt continues without a page reload. The attempt keeps its 5-minute lifetime. After that, your backend creates a new attempt.
  • Inside an iframe, offers "Open in a new tab". This opens the same widget URL as a top-level page, where the browser is more likely to allow storage. If the browser blocks the popup, the widget says so and offers Copy link.
  • In in-app browsers, iPhone browsers other than Safari, and WebViews, offers "Copy link" so the user can paste the address into Safari or Chrome. If copying fails, the address appears in a read-only field.

What you need to do

iframes. Add clipboard-write to the allow attribute so Copy link works. Without it, the widget falls back to the read-only address field.

html
<iframe
  src="https://auth.akedly.io/auth?attemptId=..."
  allow="publickey-credentials-get *; publickey-credentials-create *; clipboard-write"
></iframe>

If you add a sandbox attribute, it must include all of these tokens:

html
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"

Without allow-same-origin, the frame renders blank, because the widget's own files refuse to load for a sandboxed origin. Without allow-popups and allow-popups-to-escape-sandbox, "Open in a new tab" cannot open a working page.

WebViews. A raw WebView is still not supported — see Mobile native apps. If you load it in an Android WebView anyway, DOM storage must be on — see the note at the end of that section.