Akedly

Web / JavaScript Shield SDK

The @akedly/shield package provides Proof-of-Work solving, Turnstile helpers, and the passkey ceremony launcher for Akedly V1.2. Works in browsers (with Web Worker), React Native (batched main-thread), and Node.js (sync crypto).

Installation

Installation

BASH
npm install @akedly/shield

Or via CDN (no build step required):

<script src="https://unpkg.com/@akedly/shield/dist/akedly-shield.min.js"></script>

When loaded via CDN, all exports are available on the global AkedlyShield object:

const { solvePow, getTurnstileToken } = window.AkedlyShield;

Quick Start

The complete V1.2 flow using the Shield SDK:

  1. Your backend exposes a challenge proxy
  2. The client calls the proxy, then solves PoW with solvePow()
  3. If required, the client gets a Turnstile token via getTurnstileToken()
  4. The client posts the proof to the backend, which forwards it to Akedly
  5. The client submits the OTP via the backend verify proxy

Complete Flow

JS
// Express proxy — put this on your server, not in the browser bundle.
import express from 'express';

const app = express();
app.use(express.json());
// Optional — only needed if you want per-end-user-IP rate limiting.
// Drop this line and the x-end-user-ip header below if you don't.
// Makes req.ip the real client IP behind a reverse proxy (1 = trust first hop).
app.set('trust proxy', 1);

app.get('/auth/akedly/challenge', async (_req, res) => {
  const r = await fetch(
    `https://api.akedly.io/api/v1.2/transactions/challenge` +
    `?APIKey=${process.env.AKEDLY_API_KEY}` +
    `&pipelineID=${process.env.AKEDLY_PIPELINE_ID}`
  );
  res.status(r.status).json(await r.json());
});

app.post('/auth/akedly/send', async (req, res) => {
  const { phoneNumber, powSolution, turnstileToken } = req.body;
  const r = await fetch('https://api.akedly.io/api/v1.2/transactions/send', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-end-user-ip': req.ip,
    },
    body: JSON.stringify({
      APIKey: process.env.AKEDLY_API_KEY,
      pipelineID: process.env.AKEDLY_PIPELINE_ID,
      verificationAddress: { phoneNumber },
      powSolution,
      turnstileToken,
    }),
  });
  res.status(r.status).json(await r.json());
});

app.post('/auth/akedly/verify', async (req, res) => {
  const { transactionReqID, otp } = req.body;
  const r = await fetch('https://api.akedly.io/api/v1.2/transactions/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ transactionReqID, otp }),
  });
  res.status(r.status).json(await r.json());
});

API Methods

solvePow(challenge, difficulty, options?)

Convenience function that creates a solver, finds the nonce, and cleans up.

  • Name
    challenge
    Type
    string
    Description

    64-character hex string from the server challenge response.

  • Name
    difficulty
    Type
    number
    Description

    Number of leading hex zeros required in the hash.

  • Name
    options.onProgress
    Type
    function
    Description

    Callback receiving the number of hashes checked so far. Useful for progress indicators.

  • Name
    options.useWorker
    Type
    'auto' | true | false
    Description

    Controls Web Worker usage. 'auto' (default) detects availability. Set false for Node.js or React Native.

Returns Promise<{ nonce: number }>.

new AkedlyShield(options?)

Reusable solver instance. Use when making multiple solve calls to avoid re-creating Workers.

  • Name
    options.useWorker
    Type
    'auto' | true | false
    Description

    Controls Web Worker usage. Default: 'auto'.

Instance methods:

  • Name
    .solve(challenge, difficulty, options?)
    Type
    method
    Description

    Same as solvePow but reuses the Worker instance. Returns Promise<{ nonce: number }>.

  • Name
    .terminate()
    Type
    method
    Description

    Kills the active Web Worker and releases the Blob URL. Call when done with the instance.

getTurnstileToken(siteKey, options?)

Browser-only. Creates a hidden Cloudflare Turnstile widget, resolves with the token, and cleans up.

  • Name
    siteKey
    Type
    string
    Description

    Cloudflare Turnstile site key from the challenge response.

  • Name
    options.theme
    Type
    'light' | 'dark' | 'auto'
    Description

    Turnstile widget theme. Default: 'auto'.

  • Name
    options.size
    Type
    'normal' | 'compact'
    Description

    Widget size. Default: 'normal'.

Returns Promise<string> (the Turnstile token).

renderTurnstile(siteKey, container, options?)

Renders a Turnstile widget into a specific DOM element. Use when you want visible Turnstile placement.

  • Name
    siteKey
    Type
    string
    Description

    Cloudflare Turnstile site key.

  • Name
    container
    Type
    HTMLElement
    Description

    DOM element to render the widget into.

Returns Promise<string> (the Turnstile token).

openPasskeyCeremony(token, options?)

Browser-only. Opens the hosted V1.2 passkey ceremony (auth.akedly.io/pk) as a popup and resolves with the result the page relays back. Use it to enroll (pass the enrollmentToken from a successful /verify) and to authenticate (pass the ceremonyToken your backend gets from /auth-options).

  • Name
    token
    Type
    string
    Description

    A ceremony token minted by your backend — the enrollmentToken to enroll, or the ceremonyToken to authenticate.

  • Name
    options.ceremonyOrigin
    Type
    string
    Description

    Override the hosted origin. Default https://auth.akedly.io.

  • Name
    options.popup
    Type
    Window
    Description

    A window you opened synchronously in the click; the SDK navigates it instead of opening its own.

  • Name
    options.onResult
    Type
    function
    Description

    Side-channel callback receiving the same payload as the resolved value.

  • Name
    options.onTelemetry
    Type
    function
    Description

    Fire-and-forget funnel events (passkey_opened, passkey_verified, passkey_cancelled, …).

  • Name
    options.windowFeatures
    Type
    string
    Description

    window.open feature string for the popup. Default width=480,height=680,menubar=no,toolbar=no,location=no,status=no.

  • Name
    options.windowName
    Type
    string
    Description

    Target name for the popup window. Default akedly_passkey.

  • Name
    options.timeoutMs
    Type
    number
    Description

    Auto-resolve with reason: 'timeout' after N ms. Default 0 (no timeout).

Returns Promise<PasskeyCeremonyResult>:

{
  verified: boolean        // true only on a completed, successful ceremony
  supported: boolean       // false only when WebAuthn is unavailable
  purpose: string | null   // 'enroll' | 'auth'
  transactionId: string | null
  resultToken: string | null // signed proof of a verified outcome — verify on YOUR backend
  code: string | null      // reserved — currently always null; branch on `verified`/`reason`
  reason: string | null    // null when verified; else 'closed' | 'popup_blocked'
                           //   | 'unsupported' | 'timeout' | 'no_proof' | 'failed'
}

The promise never rejects on a normal outcome — it resolves with a reason so you can fall back to OTP. It rejects only on a missing token.

isPasskeySupported()

Synchronous booleantrue when the browser exposes window.PublicKeyCredential. Use it to skip the passkey offer on unsupported browsers and fall straight through to OTP.


Passkeys

openPasskeyCeremony is a stateless launcher — it never calls /challenge, /send, or /verify; you own those. The hosted auth.akedly.io/pk page runs the WebAuthn ceremony (branded from your pipeline) and relays the result back. Always confirm server-side.

Full endpoint contract: V1.2 Passkeys.

Enroll after a verify

A successful /verify returns an additive enrollmentToken. Offer enrollment immediately:

Enroll a passkey

JS
import { openPasskeyCeremony, isPasskeySupported } from '@akedly/shield'

if (verify.data.enrollmentToken && isPasskeySupported()) {
  // For result.verified to be true here, the OTP /verify that minted enrollmentToken must
  // also have passed returnTarget: { origin: 'https://your-site.example' }. Without it
  // enrollment still succeeds, but this resolves verified:false / no_proof — treat it as advisory.
  // In the click handler of an "Enable Face ID / fingerprint" button:
  const result = await openPasskeyCeremony(verify.data.enrollmentToken)
  // result.verified === true => the passkey now exists (proven on next sign-in).
}

Authenticate a returning user

Your backend calls POST /transactions/passkey/auth-options for a ceremonyToken and requestID. A 404 with code: "NO_PASSKEY" is the availability check — fall back to OTP. A 403 with code: "PASSKEY_DISABLED" takes the same OTP fallback, but means passkeys are off for this account or this pipeline (the response does not say which) — see V1.2 Passkeys. For the browser flow, include returnTarget: { origin: "<your site origin>" }. Because auth-options is a round-trip, open the popup in the gesture and hand it over:

Authenticate with a passkey

JS
// Your backend proxy → POST /transactions/passkey/auth-options with:
//   { APIKey, pipelineID, verificationAddress: { phoneNumber },
//     powSolution, turnstileToken,  // <-- same bot proofs as /send (see note above)
//     returnTarget: { origin: 'https://your-site.example' } } // <-- REQUIRED for the web resultToken

// In the sign-in click handler:
const popup = window.open('about:blank', 'akedly_passkey', 'width=480,height=680')
const { ceremonyToken, requestID } = await startAuth(phoneNumber) // keep requestID for proof binding
if (!ceremonyToken) { popup?.close(); return fallbackToOtp() } // 404 NO_PASSKEY

const result = await openPasskeyCeremony(ceremonyToken, { popup })
if (result.verified) {
  await completeSignIn(result.resultToken, requestID) // backend verifies + binds the proof
} else if (result.reason === 'no_proof') {
  // The authentication may have SUCCEEDED and been billed — the relay just carried no proof.
  // Reconcile via GET /result or the backend callback BEFORE starting OTP, or you charge twice.
  await reconcileThenMaybeOtp(requestID)
} else {
  await fallbackToOtp()
}

Fall back to OTP

Every non-verified outcome resolves a reasonclosed, popup_blocked, unsupported, timeout, no_proof, or failed. no_proof means the ceremony claimed success but the relay did not carry a non-blank resultToken. Deny access from that relay. For authentication, reconcile /result or the correlated backend callback before starting OTP because the passkey verification may already be settled. Other failures can branch straight to your OTP flow.

Verify the result

A verified: true message only unblocks your UI — forward result.resultToken to your backend and verify it there before creating a session. Use the hub verifier for token format, HMAC validation, callback behavior, and replay rules.

A complete, runnable reference app (Node backend + browser front end exercising enroll → authenticate → OTP fallback, verifying the resultToken offline) lives in the Akedly backend repo (private) — contact support@akedly.io if you'd like a copy.


Web Worker Behavior

When useWorker is 'auto' (default), the library:

  1. Checks if Worker, Blob, and URL.createObjectURL are available
  2. If yes, bundles the solver into a Blob URL Worker (no separate file needed)
  3. If no (e.g., React Native), falls back to batched main-thread solving with setTimeout(fn, 0) between batches to keep the UI responsive

The Web Worker approach runs PoW solving entirely off the main thread, preventing UI freezes during computation.


Platform Support

PlatformPoW SolverTurnstile
Browser (Worker)Web Worker (off-thread)getTurnstileToken()
Browser (no Worker)Batched main-threadgetTurnstileToken()
React NativeBatched main-threadUse bridge page
Node.jsSync with native cryptoN/A

Framework Examples

React

React Component

JS
components/OTPAuth.jsx
import { useState } from 'react';
import { solvePow, getTurnstileToken } from '@akedly/shield';

// This React component calls YOUR backend proxy (see Quick Start for the Node.js server).
// Never put AKEDLY_API_KEY or AKEDLY_PIPELINE_ID in REACT_APP_* — those ship to the browser.

// Pass the same 4, 5, or 6 value that your backend requests from Akedly.
// The UI length is not sent to Akedly and must not be hardcoded independently.
function OTPAuth({ phoneNumber, otpLength, onSuccess }) {
  const [transactionReqID, setTransactionReqID] = useState(null);
  const [otp, setOtp] = useState('');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  const handleSendOTP = async () => {
    setLoading(true);
    setError(null);
    try {
      // Get challenge from YOUR backend
      const challengeRes = await fetch('/auth/akedly/challenge');
      const { data } = await challengeRes.json();

      // Solve PoW only when the pipeline requires it.
      let powSolution;
      if (data.challengeRequired) {
        const { nonce } = await solvePow(data.challenge, data.difficulty);
        powSolution = { challengeToken: data.challengeToken, nonce };
      }

      // Get Turnstile token
      const turnstileToken = data.turnstile?.required
        ? await getTurnstileToken(data.turnstile.siteKey)
        : undefined;

      // Send proof via YOUR backend
      const sendRes = await fetch('/auth/akedly/send', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          phoneNumber,
          ...(powSolution ? { powSolution } : {}),
          turnstileToken,
        }),
      });
      const result = await sendRes.json();
      setTransactionReqID(result.data.transactionReqID);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

  const handleVerify = async () => {
    setLoading(true);
    setError(null);
    try {
      const res = await fetch('/auth/akedly/verify', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ transactionReqID, otp }),
      });
      const result = await res.json();
      if (result.status === 'success') onSuccess(result);
      else setError('Invalid OTP');
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

  if (!transactionReqID) {
    return (
      <div>
        <button onClick={handleSendOTP} disabled={loading}>
          {loading ? 'Sending...' : 'Send OTP'}
        </button>
        {error && <p>{error}</p>}
      </div>
    );
  }

  return (
    <div>
      <input
        value={otp}
        onChange={(e) => setOtp(e.target.value)}
        placeholder={`Enter ${otpLength}-digit OTP`}
        maxLength={otpLength}
      />
      <button onClick={handleVerify} disabled={loading || otp.length < otpLength}>
        {loading ? 'Verifying...' : 'Verify'}
      </button>
      {error && <p>{error}</p>}
    </div>
  );
}

export default OTPAuth;

Next.js (Server Action + Client)

Next.js

JS
'use server';
import { headers } from 'next/headers';

export async function getChallenge() {
  const res = await fetch(
    `https://api.akedly.io/api/v1.2/transactions/challenge?APIKey=${process.env.AKEDLY_API_KEY}&pipelineID=${process.env.AKEDLY_PIPELINE_ID}`
  );
  return res.json();
}

export async function sendOTP(phoneNumber, powSolution, turnstileToken) {
  // Extract the END USER's IP (not this server's). Cloudflare sets
  // cf-connecting-ip; Vercel/ALBs set x-forwarded-for (first entry is the client).
  const h = await headers();
  const endUserIP =
    h.get('cf-connecting-ip') ||
    h.get('x-real-ip') ||
    h.get('x-forwarded-for')?.split(',')[0].trim();

  const res = await fetch('https://api.akedly.io/api/v1.2/transactions/send', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      ...(endUserIP && { 'x-end-user-ip': endUserIP }),
    },
    body: JSON.stringify({
      APIKey: process.env.AKEDLY_API_KEY,
      pipelineID: process.env.AKEDLY_PIPELINE_ID,
      verificationAddress: { phoneNumber },
      powSolution,
      turnstileToken
    })
  });
  return res.json();
}

export async function verifyOTP(transactionReqID, otp) {
  const res = await fetch('https://api.akedly.io/api/v1.2/transactions/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ transactionReqID, otp }),
  });
  return res.json();
}

Vanilla JavaScript (CDN)

Still calls your backend proxy — only the Shield SDK is loaded from the CDN. Do not put your API key in a <script> tag.

CDN Usage

HTML
<script src="https://unpkg.com/@akedly/shield/dist/akedly-shield.min.js"></script>
<script>
  const { solvePow, getTurnstileToken } = window.AkedlyShield;

  async function authenticate(phone) {
    // 1. Get challenge from YOUR backend
    const res = await fetch('/auth/akedly/challenge');
    const { data } = await res.json();

    // 2. Solve PoW + (optional) Turnstile in the browser
    let powSolution;
    if (data.challengeRequired) {
      const { nonce } = await solvePow(data.challenge, data.difficulty);
      powSolution = { challengeToken: data.challengeToken, nonce };
    }
    const turnstileToken = data.turnstile?.required
      ? await getTurnstileToken(data.turnstile.siteKey)
      : undefined;

    // 3. Send proof via YOUR backend
    const sendRes = await fetch('/auth/akedly/send', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        phoneNumber: phone,
        ...(powSolution ? { powSolution } : {}),
        turnstileToken,
      }),
    });
    return sendRes.json();
  }
</script>

Was this page helpful?