Akedly

V1.2 REST API Authentication

V1.2 wraps the familiar V1 REST API with Akedly Shield — a client-side security layer that can enforce Proof-of-Work and Cloudflare Turnstile according to the pipeline configuration. Same REST model, full UI control.


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 when the corresponding pipeline controls are enabled: before your user receives an OTP, the client may need to:

  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-WorkNoneConfigurable per pipeline (Shield SDK-supported)N/A (widget-managed)
Captcha ProtectionImplement your ownConfigurable per pipelineBuilt-in (widget)
Rate LimitingImplement your ownPipeline-levelWidget-level
Circuit BreakerImplement your ownPipeline-levelWidget-level
Device FingerprintingImplement your ownNot provided by this REST routeWidget-level
PPSA PricingNoNoEligible
Security CoverageDIY~80% of V2Full

Authentication Flow

V1.2 uses a 4-step flow. The challenge response tells the client whether PoW is required; Turnstile is likewise conditional. The client then makes two server calls to complete the OTP lifecycle.

  • Name
    1. Get Challenge
    Type
    GET
    Description

    Request the challenge and Turnstile configuration from the server. PoW may be disabled for the pipeline.

  • Name
    2. Solve Challenge
    Type
    client-side
    Description

    If challengeRequired is true, use a Shield SDK to solve the PoW challenge. Obtain a Turnstile token only when turnstile.required is true.

  • Name
    3. Send OTP
    Type
    POST
    Description

    Submit the applicable proofs along with the user's phone number or email address. The server validates enabled 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, email, 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, email },
      ...(powSolution ? { powSolution } : {}),
      ...(turnstileToken ? { 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 the challenge and Turnstile configuration for the given pipeline. The response tells your client which work is required before sending an OTP.

Where the credentials come from

APIKey is issued once per account in the dashboard's API section. pipelineID is minted when you create a pipeline. Set the pipeline's API Version to V1.2 (Recommended) — it is the default for new pipelines, and a pipeline on another version rejects these calls with INVALID_API_VERSION.

Query Parameters

  • Name
    APIKey
    Type
    string
    Description

    Your API key from the dashboard's View API Key action.

  • Name
    pipelineID
    Type
    string
    Description

    The unique identifier of your pipeline, found in Basic Details.

Response Fields

  • Name
    challengeRequired
    Type
    boolean
    Description

    Whether PoW is required for this pipeline. Branch on this field first. When it is false the response contains no challenge, difficulty, challengeToken, expiresAt, algorithm or instructions at all — skip PoW entirely and omit powSolution from Step 2.

  • Name
    challenge
    Type
    string (when challengeRequired is true)
    Description

    64-character hex string. The PoW challenge to solve. Present only when challengeRequired is true and omitted when PoW is disabled for the pipeline.

  • Name
    difficulty
    Type
    number (when challengeRequired is true)
    Description

    Number of leading hex zeros required in the hash. Higher = harder. Set by the server per request and adaptive to traffic — see Solving the Challenge. Never hardcode it.

  • Name
    challengeToken
    Type
    string (when challengeRequired is true)
    Description

    Server-signed token binding this challenge to your request. Pass it back in Step 2. Omitted when PoW is disabled for the pipeline.

  • Name
    expiresAt
    Type
    string
    Description

    ISO 8601 timestamp for when this challenge stops being accepted. The TTL is not constant — it scales with difficulty, so read this field rather than assuming a fixed window. Budget your solve-and-retry against it; once it passes, request a fresh challenge.

  • Name
    algorithm
    Type
    string
    Description

    Hash algorithm for the challenge. Currently always "sha256".

  • Name
    instructions
    Type
    string
    Description

    Human-readable description of the work required, for logging and debugging. Do not parse it.

  • Name
    turnstile.required
    Type
    boolean
    Description

    Whether Turnstile verification is required for this pipeline. Off by default.

  • Name
    turnstile.siteKey
    Type
    string | null
    Description

    Cloudflare Turnstile site key when configured; otherwise null. Use it with the Shield SDK only when turnstile.required is true.

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": {
    "challengeRequired": true,
    "challengeToken": "eyJhbGciOiJIUzI1NiIs...",
    "challenge": "a1b2c3d4e5f6...64 hex chars",
    "difficulty": 3,
    "expiresAt": "2026-03-25T12:01:30.000Z",
    "algorithm": "sha256",
    "instructions": "Find nonce where SHA256(challenge + ':' + nonce) starts with <difficulty> zeros",
    "turnstile": {
      "required": true,
      "siteKey": "0x4AAAAAAB87rUwOea8lABKZ"
    }
  }
}

Solving the Challenge

Before calling Step 2, your client solves PoW only when challengeRequired is true and obtains a Turnstile token only when turnstile.required is true. Shield SDKs handle the client-side work across all platforms.

Always branch on challengeRequired before you solve. A pipeline with PoW switched off — or one in dev mode with bypassPoW — returns no challenge, difficulty or challengeToken, so calling the solver unconditionally throws on undefined.

Solve PoW

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

// Solve PoW (uses Web Worker automatically) — only when the server asks for it
let powSolution;
if (data.challengeRequired) {
  const { nonce } = await solvePow(data.challenge, data.difficulty);
  powSolution = { challengeToken: data.challengeToken, nonce };
}

// 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("000")           // difficulty = 3

Difficulty is adaptive

Difficulty is not a static number you set once. On a V1.2 pipeline the Enable Proof-of-Work toggle is rendered locked on, so you cannot switch PoW off from the dashboard, and Adaptive Difficulty is enabled by default, so the ramp is always running.

That is a dashboard constraint rather than a promise that a challenge is always required: challengeRequired still comes back false when dev mode bypasses PoW. Branch on the field — never assume it is always true.

The server scales difficulty linearly from the pipeline's base difficulty up to a maximum, driven by how much of that pipeline's own rate-cap the current traffic is consuming. A quiet pipeline sits at its base; a pipeline under a flood climbs automatically, and eases back down as traffic subsides. Pipelines that also enforce Turnstile start ramping later, since Turnstile already filters headless scripts.

The default base difficulty is 3. Read difficulty from the challenge response on every request — never hardcode a value or cache one across requests.

  • Name
    Enable Proof-of-Work
    Type
    boolean
    Description

    Shown locked on for V1.2 pipelines — the dashboard offers no way to turn it off.

  • Name
    Adaptive Difficulty (Recommended)
    Type
    boolean
    Description

    On by default. The system automatically adjusts the difficulty based on traffic patterns.

Challenge expiry scales with difficulty

A challenge does not live for a flat five minutes. Its TTL is derived from the difficulty the server issued, so a harder challenge is given proportionally more time to solve:

DifficultyChallenge TTL
290 seconds
3 (default)90 seconds
4120 seconds
5240 seconds
6300 seconds

Turnstile is opt-in

Unlike Proof-of-Work, Turnstile is off by default. Enable it per pipeline with the Enable Turnstile toggle; switching it on provisions a Cloudflare widget for that pipeline. While it is on, Step 1 returns turnstile.required: true with a siteKey, and Step 2 rejects requests without a token. While it is off, siteKey is null and you omit turnstileToken entirely.


Step 2: Send OTP

Submit the applicable proofs along with the user's phone number or email address. The server validates enabled 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, email, or both. At least one address is required; each supplied address must have a matching verification method in the pipeline.

  • Name
    powSolution
    Type
    JSON
    Description

    Object containing challengeToken (from Step 1) and nonce (from the PoW solver). Include it only when challengeRequired is true.

  • 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, 5 or 6 digit OTP code. When provided, billing switches to pay-per-message upon send.

  • Name
    digits
    Type
    number
    Description

    Optional: OTP length, 4, 5 or 6. Defaults to 6. Out-of-range values are ignored and fall back to 6 — V1.2 never rejects a request over digits. To try a non-default length without sending anything, see Dev Mode & Test Pairs.

  • Name
    channel
    Type
    string
    Description

    Optional: send this code on a specific channel — whatsapp, telegram or sms. Requires verificationAddress.phoneNumber. Omit it and the pipeline routes as it always has. Must be switched on for your account and pipeline first — see Letting Users Choose Their Channel.

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

All three sets of caps are configured on the pipeline itself, per minute, per hour and per day. The per-pipeline caps do double duty: they are also the denominator the adaptive PoW ramp measures utilisation against, so raising them raises the traffic level at which difficulty starts climbing.

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_PERMINUTE",
  "message": "Rate limit exceeded for phoneNumber per minute",
  "retryable": true,
  "retryAfter": "2026-03-25T12:00:47.000Z",
  "cooldownSeconds": 47,
  "details": {
    "type": "phoneNumber",
    "window": "perMinute",
    "max": 3,
    "current": 3
  }
}

Letting Users Choose Their Channel

By default a pipeline decides where a code goes. You can instead let the person receiving the code choose, either by passing channel yourself or by letting the hosted V2 widget ask them.

Phone channels only. whatsapp, telegram and sms can be chosen. Email cannot be picked — if your verificationAddress includes an email, it is still sent alongside the phone channel when that phone channel succeeds; if the chosen phone channel fails, the request ends there and no email goes out. Passing channel without a phoneNumber is rejected, and so is passing it as null.

What has to be switched on

Two switches are yours to think about, and they are controlled by different people:

  • Your account is entitled — held by Akedly staff. If your calls are refused and your pipeline switch is on, this is the one to ask us about.
  • The pipeline's channel-choice switch is on — yours, in Dashboard > Pipelines.

Both must be on. Beyond those, the feature is also unavailable on legacy (non-granular) pricing and on accounts not billed per message; all of these refuse with the same channel_choice_not_enabled code, so treat that code as "not available for this account and pipeline" rather than as a statement about any one switch.

A utility pipeline is a different case: it is rejected earlier, by the endpoint itself, with 403 UTILITY_PIPELINE_ON_OTP_ENDPOINT — you never reach channel validation at all.

What happens when a chosen channel fails

There are two different failures here, and they behave differently.

The send is rejected immediately. The request fails there and then, and returns 422 with pre_send_failure when we knew before sending that the channel could not be used, or 500 with ACTIVATION_FAILED otherwise. A channel you asked for explicitly never falls back, whatever the pipeline's enforcement setting says — that is the point of naming a channel.

The send is accepted and later reported undelivered. Only this case is governed by the per-pipeline enforcement setting:

  • Fallback (default) — the pipeline's normal fallback order takes over, so the person still gets their code, just not on the channel they asked for.
  • Hard pin — no fallback is sent; the attempt is left to fail rather than arriving somewhere the person did not choose. Choose this when "it must be this channel or not at all" matters more than delivery.

Billing

Each channel bills at its own rate, exactly as sending does today — choosing SMS costs what SMS costs. Nothing about this feature adds a charge of its own. Your own per-country rates are in Dashboard > Cost.

Request

POST
/api/v1.2/transactions/send
curl -X POST https://api.akedly.io/api/v1.2/transactions/send \
  -H "Content-Type: application/json" \
  -d '{
    "APIKey": "your-api-key",
    "pipelineID": "your-pipeline-id",
    "verificationAddress": { "phoneNumber": "+15555550123" },
    "powSolution": { "challengeToken": "...", "nonce": 51234 },
    "turnstileToken": "0.abc...",
    "channel": "sms"
  }'

Response

JSON
400 Not Eligible
{
  "status": "error",
  "code": "channel_not_eligible",
  "message": "Requested channel is not an Eligible Phone Channel for this pipeline",
  "eligibleChannels": ["whatsapp", "sms"]
}

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

  • Name
    returnTarget
    Type
    object
    Description

    Optional. Pass this when you plan to offer passkey enrollment and need a proven SDK result. Use {"origin":"https://your-site.example"} for web or {"url":"myapp://akedly-passkey"} for a native deep link. Akedly signs the sanitized target into data.enrollmentToken.

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
    data.enrollmentToken
    Type
    string
    Description

    Optional. Present only when passkeys are enabled for both the account and the pipeline. A single-use, short-lived (~2 min) token you hand to the passkey enroll ceremony to offer the user a passkey right after this successful verify. Absent otherwise — treat its presence as "you may offer enrollment now." See V1.2 Passkeys.

  • 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), "MAX_ATTEMPTS_EXCEEDED" (429), "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": "<user-entered-otp>",
  "returnTarget": {
    "origin": "https://your-site.example"
  }
}

Response

JSON
Success Response
{
  "status": "success",
  "data": {
    "verified": true,
    "transactionID": "ae2eacaebe3ed78b105498d5...",
    "frontendCallbackURL": "https://yourapp.com/auth/callback?transactionID=ae2eac...&status=Successful",
    "enrollmentToken": "pk1e.<base64url(iv)>.<base64url(ciphertext)>"
  },
  "message": "OTP verified successfully"
}

enrollmentToken is opaque and encrypted. It appears only when passkeys are enabled; do not decode it client-side, and offer enrollment immediately while its short-lived window remains open.

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.

The callback destination lives in the pipeline's Webhooks section, alongside the signing secret used by those other surfaces.


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

V1.2 errors use status, code, and message. Middleware-generated errors may also include retryable, retryAfter, cooldownSeconds, and details; the response fields depend on the endpoint and error path.

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

Expiration fields

LimitValue
Challenge expiryScales with difficulty — 90s at difficulty 2–3, 120s at 4, 240s at 5, 300s at 6. The default difficulty is 3, so most pipelines get 90 seconds. Read expiresAt from the challenge response; see Solving the Challenge.
Captcha token expiry2 minutes
Transaction expiry3 minutes. Use data.expiresAt from the send response when deciding whether to request a new OTP.

Authentication Errors

StatusCodeCause and Solution
400MISSING_REQUIRED_FIELDSAPIKey and pipelineID are required for the challenge request; transactionReqID and otp are required for verification.
401INVALID_API_KEYAPI key is invalid or does not match any account. Verify in your dashboard.

Pipeline and Configuration Errors

StatusCodeCause and Solution
403PIPELINE_OWNERSHIP_MISMATCHThe pipeline belongs to a different account. Use a pipeline owned by the account identified by APIKey.
403INVALID_API_VERSIONThe pipeline is not configured for V1.2.
403PIPELINE_INACTIVEThe pipeline is inactive. Activate it in the pipeline editor.
403UTILITY_PIPELINE_ON_OTP_ENDPOINTThe selected pipeline is a Utilities pipeline. Use the Utilities API instead.
404PIPELINE_NOT_FOUNDThe pipelineID does not identify an existing pipeline.
500PIPELINE_NOT_LOADEDThe server could not load pipeline data for a security check. Retry the request.

Channel Choice Errors

Returned only when you pass channel. The three validation codes are lower-case, unlike the rest of this reference — that is what the API actually returns. Validation runs before the availability check, so an unrecognised channel value is reported as invalid_channel even on a pipeline where channel choice is switched off.

StatusCodeCause and Solution
400invalid_channelchannel was not one of whatsapp, telegram, sms — or you passed it without a verificationAddress.phoneNumber. Sending channel: null counts as passing it: omit the field entirely to use the pipeline's normal routing.
400channel_choice_not_enabledNot available for this account and pipeline. Covers the account entitlement, the pipeline switch, the platform-wide switch, legacy (non-granular) pricing and accounts not billed per message. Check the pipeline switch in Dashboard > Pipelines first; if that is on, ask Akedly.
400channel_not_eligibleA valid channel, but not one this pipeline sends on. The response carries eligibleChannels, in the pipeline's own configured order — offer the user those. The order reflects your pipeline configuration, not any ranking by us.
422pre_send_failureThe channel was refused before anything was sent, so nothing was spent — today this means a telegram number that is not reachable on Telegram. Offer the user another channel.
500ACTIVATION_FAILEDThe send failed for any other reason. Because you named a channel explicitly, no fallback was attempted. Retry, or send again without channel to use the pipeline's normal routing.

Challenge and Captcha Errors

StatusCodeCause and Solution
400POW_SOLUTION_MISSINGThe request omitted the PoW solution while PoW was required. Solve the current challenge and send its challengeToken and nonce.
400POW_INVALID_TOKEN, POW_INVALID_SIGNATURE, POW_PIPELINE_MISMATCH, POW_INVALID_SOLUTIONThe submitted PoW proof is invalid or bound to another pipeline. Fetch a new challenge and solve it for this pipeline.
409POW_CHALLENGE_REPLAYED, POW_DIFFICULTY_STALEThe proof was already consumed or was minted below the current difficulty. Fetch a new challenge and retry.
410POW_CHALLENGE_EXPIREDThe PoW challenge has expired. The TTL scales with difficulty (90s at the default difficulty 3, up to 300s at difficulty 6) — do not assume 5 minutes. Request a new challenge from Step 1.
500POW_VERIFICATION_FAILED, POW_DIFFICULTY_CALCULATION_FAILEDThe server could not complete a PoW operation. Retry the request.
400TURNSTILE_TOKEN_MISSINGTurnstile is enabled but no token was provided. Obtain a token before sending.
403TURNSTILE_VERIFICATION_FAILEDCloudflare rejected the Turnstile token. Obtain a fresh token and retry.
500TURNSTILE_SECRET_KEY_MISSINGThe pipeline's Turnstile secret is not configured. Contact the pipeline owner.
502TURNSTILE_API_ERRORTurnstile's API returned an error. Retry the request.
504TURNSTILE_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. V1.2 emits these explicit codes: RATE_LIMIT_PHONENUMBER_PERMINUTE, RATE_LIMIT_PHONENUMBER_PERHOUR, RATE_LIMIT_PHONENUMBER_PERDAY, RATE_LIMIT_ENDUSERIP_PERMINUTE, RATE_LIMIT_ENDUSERIP_PERHOUR, RATE_LIMIT_ENDUSERIP_PERDAY, RATE_LIMIT_PIPELINE_PERMINUTE, RATE_LIMIT_PIPELINE_PERHOUR, and RATE_LIMIT_PIPELINE_PERDAY.

StatusCodeCause and Solution
429RATE_LIMIT_PHONENUMBER_PERMINUTE, RATE_LIMIT_PHONENUMBER_PERHOUR, RATE_LIMIT_PHONENUMBER_PERDAYThe same phone number has requested too many OTPs in this window. Wait for retryAfter before retrying.
429RATE_LIMIT_ENDUSERIP_PERMINUTE, RATE_LIMIT_ENDUSERIP_PERHOUR, RATE_LIMIT_ENDUSERIP_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, RATE_LIMIT_PIPELINE_PERHOUR, RATE_LIMIT_PIPELINE_PERDAYPipeline-wide cap reached across all end users. Consider raising the pipeline rate limits in the dashboard.

Circuit Breaker Errors

The circuit breaker is a pipeline-level flood guard: when a pipeline's traffic looks like an attack it trips, and every send is refused for a cooldown rather than burning your OTP budget. Its thresholds and cooldown are configured on the pipeline.

StatusCodeCause and Solution
503CIRCUIT_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.

Transaction and Verification Errors

StatusCodeCause and Solution
400MISSING_REQUIRED_FIELDSRequired fields are missing from the request. For send, provide APIKey and pipelineID; for verify, provide transactionReqID and otp.
400MISSING_VERIFICATION_ADDRESSThe send request did not include a verificationAddress with a phoneNumber or email.
500TRANSACTION_NOT_FOUNDThe transaction created for the send request could not be retrieved. Retry the request.
410TRANSACTION_EXPIREDTransaction expired. Use the expiresAt value returned by send and create a new transaction when it has passed.
403INVALID_OTPOTP entered by user does not match. Prompt them to re-enter.
409ALREADY_VERIFIEDThe transaction request was already verified. Do not submit the same request again.
429MAX_ATTEMPTS_EXCEEDED10th wrong OTP on this transaction. The transaction is force-expired — create a new one; further attempts return TRANSACTION_EXPIRED.
500SEND_OTP_FAILEDThe V1.2 send operation failed outside the handled transaction paths. Retry the request.
Propagated status (default 500)ACTIVATION_FAILEDThe transaction was created but could not be activated. Retry according to the returned status.
402BILLING_FAILEDBilling failed while verifying the OTP. Resolve the billing issue before retrying.
500VERIFY_OTP_FAILEDThe verify operation failed outside the handled verification outcomes. Retry the request.
Propagated from transaction creation (default 400)TRANSACTION_CREATION_FAILEDThe underlying transaction could not be created. The response preserves its status when available; retry or inspect the message.

Challenge-generation errors

The challenge endpoint returns 500 for an unhandled challenge-generation failure. The PoW service may identify the failure as POW_CHALLENGE_GENERATION_FAILED; the controller fallback is CHALLENGE_GENERATION_FAILED.

StatusCodeCause and Solution
500CHALLENGE_GENERATION_FAILED, POW_CHALLENGE_GENERATION_FAILEDThe server could not create a PoW challenge. Retry the challenge request.

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?