V1.2 REST API Authentication

V1.2 wraps the familiar V1 REST API with Akedly Shield -- a client-side security layer combining Proof-of-Work and Cloudflare Turnstile. Same 3-step flow, full UI control, built-in protection.


What is Akedly Shield?

Traditional OTP services are plain REST APIs -- any bot can call them, draining your SMS budget with fake verification requests. Akedly Shield adds a client-side proof layer: before your user receives an OTP, the client must:

  1. Solve a Proof-of-Work challenge -- computational cost deters automated abuse
  2. Pass Cloudflare Turnstile verification -- bot detection without user friction

This "shield" sits between attackers and your OTP pipeline. Unlike other services where you bolt on captcha yourself, Shield is built into the API flow. No iframe required (unlike V2 widgets) -- you keep full UI control.

FeatureV1.0V1.2 (Shield)V2.0
Integration StyleREST APIREST API + Shield SDKIframe Widget
UI ControlFull (you build it)Full (you build it)Managed by Akedly
Proof-of-WorkNoneBuilt-in (Shield SDK)N/A (widget-managed)
Captcha ProtectionImplement your ownBuilt-in TurnstileBuilt-in (widget)
Rate LimitingImplement your ownPipeline-levelWidget-level
Circuit BreakerImplement your ownPipeline-levelWidget-level
Device FingerprintingImplement your ownPipeline-levelWidget-level
PPSA PricingNoNoEligible
Security CoverageDIY~80% of V2Full

Authentication Flow

V1.2 uses a 4-step flow. The first two steps happen client-side via Shield SDKs, then two server calls complete the OTP lifecycle.

  • Name
    1. Get Challenge
    Type
    GET
    Description

    Request a Proof-of-Work challenge and Turnstile configuration from the server.

  • Name
    2. Solve Challenge
    Type
    client-side
    Description

    Use a Shield SDK to solve the PoW challenge and obtain a Turnstile token. This happens entirely on the client.

  • Name
    3. Send OTP
    Type
    POST
    Description

    Submit the PoW solution and Turnstile token along with the user's phone number. The server validates both proofs and sends the OTP.

  • Name
    4. Verify OTP
    Type
    POST
    Description

    Submit the user's OTP input for verification. Identical to V1.0 Step 3.

Complete Flow

JS
@akedly/shield
// Express proxy. AKEDLY_API_KEY and AKEDLY_PIPELINE_ID live in env — never
// ship them to the client.
import express from 'express';

const app = express();
app.use(express.json());

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

Step 1: Get Challenge

Request a Proof-of-Work challenge and Turnstile configuration for the given pipeline. The response tells your client what work is required before sending an OTP.

Query Parameters

  • Name
    APIKey
    Type
    string
    Description

    Your API key from the Akedly dashboard API section.

  • Name
    pipelineID
    Type
    string
    Description

    The unique identifier of your pipeline, found in the pipeline's basic details.

Response Fields

  • Name
    challenge
    Type
    string
    Description

    64-character hex string. The PoW challenge to solve.

  • Name
    difficulty
    Type
    number
    Description

    Number of leading hex zeros required in the hash. Higher = harder.

  • Name
    challengeToken
    Type
    string
    Description

    Server-signed token binding this challenge to your request. Pass it back in Step 2.

  • Name
    challengeRequired
    Type
    boolean
    Description

    Whether PoW is required for this pipeline. When false, skip PoW solving.

  • Name
    turnstile.required
    Type
    boolean
    Description

    Whether Turnstile verification is required for this pipeline.

  • Name
    turnstile.siteKey
    Type
    string
    Description

    Cloudflare Turnstile site key. Use this with the Shield SDK to generate a token.

Request

GET
https://api.akedly.io/api/v1.2/transactions/challenge
curl "https://api.akedly.io/api/v1.2/transactions/challenge?APIKey=YOUR_API_KEY&pipelineID=YOUR_PIPELINE_ID"

Response

JSON
200 Status
{
  "status": "success",
  "data": {
    "challenge": "a1b2c3d4e5f6...64 hex chars",
    "difficulty": 4,
    "challengeToken": "eyJhbGciOiJIUzI1NiIs...",
    "challengeRequired": true,
    "turnstile": {
      "required": true,
      "siteKey": "0x4AAAAAAB87rUwOea8lABKZ"
    }
  }
}

Solving the Challenge

Before calling Step 2, your client must solve the PoW challenge and (optionally) obtain a Turnstile token. Shield SDKs handle this for you across all platforms.

Solve PoW

SDK
import { solvePow, getTurnstileToken } from '@akedly/shield';

// Solve PoW (uses Web Worker automatically)
const { nonce } = await solvePow(data.challenge, data.difficulty);

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

Algorithm Reference

The PoW solver computes SHA256(challenge + ":" + nonce) and checks if the resulting hex digest starts with difficulty leading zeros. The nonce increments from 0 until a valid hash is found.

hash = SHA256("a1b2c3d4...:" + "42")   // hex digest
valid = hash.startsWith("0000")          // difficulty = 4

Step 2: Send OTP

Submit the solved PoW challenge and Turnstile token along with the user's phone number. The server validates both proofs, then creates and sends the OTP in a single call.

Request Body

  • Name
    APIKey
    Type
    string
    Description

    Your API key from the Akedly dashboard.

  • Name
    pipelineID
    Type
    string
    Description

    Your pipeline ID.

  • Name
    verificationAddress
    Type
    JSON
    Description

    JSON object with phoneNumber (with country code) and optionally email.

  • Name
    powSolution
    Type
    JSON
    Description

    Object containing challengeToken (from Step 1) and nonce (from PoW solver).

  • Name
    turnstileToken
    Type
    string
    Description

    Turnstile verification token. Required when turnstile.required was true in Step 1.

  • Name
    otp
    Type
    string
    Description

    Optional: Bring your own 4 or 6 digit OTP code. When provided, billing switches to pay-per-message upon send.

  • Name
    digits
    Type
    number
    Description

    Optional: OTP length, 4 or 6. Defaults to 6.

Request Headers

  • Name
    Content-Type
    Type
    string
    Description

    Must be application/json.

  • Name
    x-end-user-ip
    Type
    string
    Description

    Optional. Enables the per-end-user-IP rate-limit dimension (defaults: 5/min, 20/hour, 50/day per pipeline). If you send it, it must be the real end user's IPv4 or IPv6 address — the one that hit your backend from the browser or mobile device, not your backend server's IP. When omitted, Akedly falls back to the TCP connection IP; for a backend-to-backend call that's your server, so Akedly detects loopback and private IPs (127.0.0.1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, ::1, CGNAT, link-local) and simply skips the per-IP check for them. Per-phone-number and per-pipeline limits always apply regardless.

Extracting the end-user IP on your backend

Backend
// Put this once, before any routes. Tells Express to trust X-Forwarded-For
// from your reverse proxy (Cloudflare, AWS ALB, Nginx, Fly.io, etc.).
app.set('trust proxy', 1);

app.post('/auth/akedly/send', async (req, res) => {
  // Now req.ip is the REAL end-user IP, not your LB's.
  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({ /* ... */ }),
  });
  res.status(r.status).json(await r.json());
});

Response Fields

  • Name
    status
    Type
    string
    Description

    "success" or "error".

  • Name
    data.transactionID
    Type
    string
    Description

    The main transaction ID for tracking.

  • Name
    data.transactionReqID
    Type
    string
    Description

    The transaction request ID. Save this for Step 3 verification.

  • Name
    data.channels
    Type
    string[]
    Description

    Array of channels that successfully delivered the OTP (e.g., ["whatsapp", "email"]). Possible values: "whatsapp", "telegram", "sms", "email".

  • Name
    data.expiresAt
    Type
    string
    Description

    ISO 8601 timestamp indicating when the transaction expires.

  • Name
    message
    Type
    string
    Description

    "OTP sent successfully".

Request

POST
https://api.akedly.io/api/v1.2/transactions/send
{
  "APIKey": "6e1d6585bbe17f6abc80cf10a1********",
  "pipelineID": "6748*******f948b29ef",
  "verificationAddress": {
    "phoneNumber": "+20155****2491",
    "email": "user@example.com"
  },
  "powSolution": {
    "challengeToken": "eyJhbGciOiJIUzI1NiIs...",
    "nonce": 48291
  },
  "turnstileToken": "0.AqW3x9...",
  "digits": 6
}

Response

JSON
200 Status
{
  "status": "success",
  "data": {
    "transactionID": "a77549536888557729a0e4cd...",
    "transactionReqID": "66726b726cdd6713",
    "channels": ["whatsapp", "email"],
    "expiresAt": "2026-03-25T12:03:00.000Z"
  },
  "message": "OTP sent successfully"
}

Response

JSON
429 Rate Limit
{
  "status": "error",
  "code": "RATE_LIMIT_PHONENUMBER_ATTEMPTS",
  "message": "Rate limit exceeded. Try again in 47 seconds",
  "retryable": true,
  "retryAfter": "2026-03-25T12:00:47.000Z"
}

Step 3: Verify OTP

Submit the user's OTP input for verification using the transactionReqID from Step 2.

Request Body

  • Name
    transactionReqID
    Type
    string
    Description

    The transaction request ID returned in Step 2's data.transactionReqID.

  • Name
    otp
    Type
    string
    Description

    The OTP code entered by the user (must be sent as a string).

Success Response

  • Name
    status
    Type
    string
    Description

    "success" on valid OTP.

  • Name
    data.verified
    Type
    boolean
    Description

    true when the OTP is verified successfully.

  • Name
    data.transactionID
    Type
    string
    Description

    The main transaction ID.

  • Name
    data.frontendCallbackURL
    Type
    string
    Description

    Callback URL if configured on the pipeline.

  • Name
    message
    Type
    string
    Description

    "OTP verified successfully".

Failed Response

  • Name
    status
    Type
    string
    Description

    "error".

  • Name
    code
    Type
    string
    Description

    Error code: "INVALID_OTP" (403), "TRANSACTION_EXPIRED" (410), or "ALREADY_VERIFIED" (409).

  • Name
    message
    Type
    string
    Description

    Error description.

Request

POST
https://api.akedly.io/api/v1.2/transactions/verify
{
  "transactionReqID": "68b4a1e8d686446a498008bd",
  "otp": "123456"
}

Response

JSON
Success Response
{
  "status": "success",
  "data": {
    "verified": true,
    "transactionID": "ae2eacaebe3ed78b105498d5...",
    "frontendCallbackURL": "https://yourapp.com/auth/callback?transactionID=ae2eac...&status=Successful"
  },
  "message": "OTP verified successfully"
}

Response

JSON
Failure Response
{
  "status": "error",
  "code": "INVALID_OTP",
  "message": "Invalid OTP",
  "data": {
    "frontendCallbackURL": "https://yourapp.com/auth/callback"
  }
}

Webhook Callbacks

When a transaction completes — successfully or not — Akedly POSTs a JSON payload to the backendCallbackURL configured on your pipeline. This is how your server learns the result without polling.


Shield SDKs

Official Shield SDKs handle Proof-of-Work solving and Turnstile token retrieval across all platforms. Each SDK manages threading, Web Workers, or isolates automatically.

PlatformPackageInstallRepository
Web / JavaScript@akedly/shieldnpm / CDNNPM
Flutter / Dartakedly_shieldGitHub (pubspec git:)GitHub
iOS / SwiftAkedlyShieldSPM (GitHub URL)GitHub
Android / Kotlinakedly-shield-kotlinGitHub (JitPack)GitHub
React Native@akedly/shieldnpmNPM

Migration from V1.0

Upgrading from V1.0 to V1.2 preserves the REST API approach while adding Shield security. The key difference: V1.2 merges "create" and "activate" into a single "send" call, but adds a prerequisite "get challenge" step.

V1.0 Flow (3 server calls)

  1. POST /transactions -- create transaction
  2. POST /transactions/activate/{id} -- send OTP
  3. POST /transactions/verify/{id} -- verify OTP

V1.2 Flow (2 server calls + 1 client step)

  1. GET /v1.2/transactions/challenge -- get PoW challenge
  2. Client: solve PoW + get Turnstile token (Shield SDK)
  3. POST /v1.2/transactions/send -- send OTP with proofs
  4. POST /v1.2/transactions/verify -- verify OTP (transactionReqID in body)

Migration

// Step 1: Create transaction
const tx = await fetch('https://api.akedly.io/api/v1/transactions', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    APIKey: apiKey,
    pipelineID,
    verificationAddress: { phoneNumber }
  })
});
const { data: { transactionID } } = await tx.json();

// Step 2: Activate and send OTP
const activated = await fetch(
  `https://api.akedly.io/api/v1/transactions/activate/${transactionID}`,
  { method: 'POST' }
);
const { data: { _id } } = await activated.json();

// Step 3: Verify
await fetch(`https://api.akedly.io/api/v1/transactions/verify/${_id}`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ otp: userInput })
});

Error Reference

All V1.2 errors follow a standard structure:

{
  "status": "error",
  "code": "ERROR_CODE",
  "message": "Human-readable description",
  "retryable": true,
  "action": "Suggested fix",
  "requestId": "uuid",
  "retryAfter": "2026-03-25T12:01:00.000Z",
  "cooldownSeconds": 3600,
  "details": {}
}

Key Limits

LimitValue
Challenge expiry5 minutes
Captcha token expiry2 minutes
Transaction expiry3 minutes
Max resends1
Signature validity60 seconds

Authentication Errors

StatusCodeCause and Solution
400MISSING_PUBLIC_KEYAPI key not provided. Include APIKey in your request.
401INVALID_API_KEYAPI key is invalid or does not match any account. Verify in your dashboard.
403IP_BLACKLISTEDYour IP has been blocked. Contact support.

Pipeline and Configuration Errors

StatusCodeCause and Solution
400PIPELINE_NOT_CONFIGUREDPipeline is missing required settings. Check pipeline configuration in dashboard.
403WIDGET_DISABLEDThe pipeline is disabled. Activate it in Dashboard > Pipelines.
403WIDGET_SUSPENDEDPipeline suspended due to abuse detection. Contact support.
404WIDGET_NOT_FOUNDPipeline ID does not exist. Verify the pipelineID in your dashboard.

Challenge and Captcha Errors

StatusCodeCause and Solution
400CAPTCHA_TOKEN_MISSINGTurnstile token not provided but required. Call getTurnstileToken() before sending.
400CAPTCHA_INVALID_TURNSTILETurnstile token is malformed. Regenerate using Shield SDK.
403CAPTCHA_NOT_VERIFIEDTurnstile verification failed. The token was rejected by Cloudflare.
409CAPTCHA_ALREADY_USEDTurnstile token was already consumed. Generate a new token for each request.
410CHALLENGE_EXPIREDThe PoW challenge has expired (5-minute TTL). Request a new challenge from Step 1.
500CHALLENGE_GENERATION_FAILEDServer failed to generate a challenge. Retry the request.
502CAPTCHA_VALIDATION_FAILEDCloudflare Turnstile validation service returned an error. Retry.
504CAPTCHA_VALIDATION_TIMEOUTTurnstile validation timed out. Retry the request.

Rate Limiting Errors

Each 429 response includes retryAfter (ISO timestamp) and cooldownSeconds (number) so you can back off precisely. The error code follows the pattern RATE_LIMIT_<DIMENSION>_<WINDOW>, where dimension is PHONENUMBER, ENDUSERIP, or PIPELINE, and window is PERMINUTE, PERHOUR, or PERDAY.

StatusCodeCause and Solution
429RATE_LIMIT_PHONENUMBER_PERMINUTE / PERHOUR / PERDAYThe same phone number has requested too many OTPs in this window. Wait for retryAfter before retrying.
429RATE_LIMIT_ENDUSERIP_PERMINUTE / PERHOUR / PERDAYToo many requests from the same end-user IP. Only fires when x-end-user-ip is provided or the connection originates from a public IP — private and loopback IPs are excluded from this check.
429RATE_LIMIT_PIPELINE_PERMINUTE / PERHOUR / PERDAYPipeline-wide cap reached across all end users. Consider raising the pipeline rate limits in the dashboard.
429RESEND_LIMIT_EXCEEDEDMaximum 1 resend per transaction. Create a new transaction instead.

Circuit Breaker Errors

StatusCodeCause and Solution
429CIRCUIT_BREAKER_OPENPipeline circuit breaker is open due to flood detection. Wait for cooldownSeconds.
503CIRCUIT_BREAKER_TRIGGEREDPipeline suspended due to sustained high traffic. Automatic recovery after cooldown.

Security and Threat Detection

StatusCodeCause and Solution
403SECURITY_BLOCKEDRequest blocked by threat detection. Response includes riskScore, threatLevel, and verdict. Threat levels: LEVEL_1 (warning), LEVEL_2 (enhanced throttling), LEVEL_3 (suspension).
400INVALID_DEVICE_IDDevice ID format is invalid.
403FINGERPRINT_INCONSISTENTDevice fingerprint changed between requests. Possible spoofing detected.

Transaction and Verification Errors

StatusCodeCause and Solution
400MISSING_REQUIRED_FIELDSRequired fields missing from request body. Check APIKey, pipelineID, verificationAddress.
404TRANSACTION_NOT_FOUNDTransaction ID does not exist. Verify the transactionReqID from Step 2.
410TRANSACTION_EXPIREDTransaction expired (3-minute TTL). Create a new transaction.
403INVALID_OTPOTP entered by user does not match. Prompt them to re-enter.
502OTP_SEND_FAILEDOTP delivery failed across all channels. Retry the request.
500TRANSACTION_CREATION_FAILEDInternal error creating transaction. Retry. If persistent, contact support.

Validation Errors

StatusCodeCause and Solution
400VALIDATION_ERRORRequest body failed validation. Check the details field for specific issues.
413PAYLOAD_TOO_LARGERequest body exceeds size limit.

Server Errors

StatusCodeCause and Solution
500INTERNAL_SERVER_ERRORUnexpected server error. Retry the request. If persistent, contact support at support@akedly.io.

Stay Updated

Was this page helpful?