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.
Recommended REST API
V1.2 is our recommended REST API. It supports pipeline-level Proof-of-Work, Cloudflare Turnstile, rate limiting, and circuit breaking — no iframe required. The controls are evaluated from your pipeline configuration. For the maximum security tier (and PPSA eligibility), see V2.0 Widget SDK.
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:
- Solve a Proof-of-Work challenge — computational cost deters automated abuse
- 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.
| Feature | V1.0 | V1.2 (Shield) | V2.0 |
|---|---|---|---|
| Integration Style | REST API | REST API + Shield SDK | Iframe Widget |
| UI Control | Full (you build it) | Full (you build it) | Managed by Akedly |
| Proof-of-Work | None | Configurable per pipeline (Shield SDK-supported) | N/A (widget-managed) |
| Captcha Protection | Implement your own | Configurable per pipeline | Built-in (widget) |
| Rate Limiting | Implement your own | Pipeline-level | Widget-level |
| Circuit Breaker | Implement your own | Pipeline-level | Widget-level |
| Device Fingerprinting | Implement your own | Not provided by this REST route | Widget-level |
| PPSA Pricing | No | No | Eligible |
| Security Coverage | DIY | ~80% of V2 | Full |
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.
Never ship your API key to the client
APIKey and pipelineID are credentials. Keep them on your backend and expose thin proxy endpoints (e.g. /auth/akedly/challenge, /auth/akedly/send, /auth/akedly/verify) that your frontend calls. The backend tab below shows the minimal Node.js proxy; the frontend tab shows the matching client code. All SDK pages follow the same split.
- 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
challengeRequiredistrue, use a Shield SDK to solve the PoW challenge. Obtain a Turnstile token only whenturnstile.requiredistrue.
- 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
// 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
falsethe response contains nochallenge,difficulty,challengeToken,expiresAt,algorithmorinstructionsat all — skip PoW entirely and omitpowSolutionfrom Step 2.
- Name
challenge- Type
- string (when challengeRequired is true)
- Description
64-character hex string. The PoW challenge to solve. Present only when
challengeRequiredistrueand 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 whenturnstile.requiredistrue.
Request
curl "https://api.akedly.io/api/v1.2/transactions/challenge?APIKey=YOUR_API_KEY&pipelineID=YOUR_PIPELINE_ID"
Response
{
"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"
}
}
}
PoW-disabled response
When PoW is disabled for the pipeline, the response contains challengeRequired: false and does not include challenge, difficulty, or challengeToken. Do not call the solver in that case. The turnstile object still reports whether Turnstile is required.
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
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;
Shield SDK Documentation
Each platform has a dedicated SDK page with full API reference, installation guides, and integration examples. View Shield SDKs
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:
| Difficulty | Challenge TTL |
|---|---|
| 2 | 90 seconds |
| 3 (default) | 90 seconds |
| 4 | 120 seconds |
| 5 | 240 seconds |
| 6 | 300 seconds |
Most pipelines expire a challenge in 90 seconds, not 5 minutes
Because the default base difficulty is 3, the typical real-world window is 90 seconds — five minutes only ever applies at difficulty 6. Do not size your solve-and-retry budget on a constant. Use the expiresAt timestamp returned in Step 1, and request a fresh challenge once it passes rather than sending a stale one and handling POW_CHALLENGE_EXPIRED.
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) andnonce(from the PoW solver). Include it only whenchallengeRequiredistrue.
- Name
turnstileToken- Type
- string
- Description
Turnstile verification token. Required when
turnstile.requiredwastruein 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,5or6. Defaults to6. Out-of-range values are ignored and fall back to6— V1.2 never rejects a request overdigits. 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,telegramorsms. RequiresverificationAddress.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.
If you opt in, forward the user's IP — not your server's
Per-end-user-IP rate limiting is off by default for backend-proxy setups and only activates when you send x-end-user-ip. The usual mistake is sending your load balancer's IP instead of the user's, which quietly caps every user against a single identifier. Below are safe extraction patterns per framework — skip this section entirely if you don't need per-IP rate limiting (phone and pipeline limits still apply).
Extracting the end-user IP on your 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());
});
Three independent rate-limit dimensions
V1.2 supports three rate-limit dimensions: per phone number and per pipeline (always on), and per end-user IP (opt-in via x-end-user-ip). If you do opt in from a mobile or native app that proxies through your backend, pass the IP of the device that called you — do not read the device's IP on the device itself; clients cannot reliably know their own public IP and can lie.
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".
Multi-Channel Delivery
OTP delivery follows smart channel routing: WhatsApp (preferred), Telegram (if available), SMS (fallback), and email (always sent when address provided). The channels array in the response tells you exactly which channels delivered successfully.
Request
{
"APIKey": "6e1d6585bbe17f6abc80cf10a1********",
"pipelineID": "6748*******f948b29ef",
"verificationAddress": {
"phoneNumber": "+20155****2491",
"email": "user@example.com"
},
"powSolution": {
"challengeToken": "eyJhbGciOiJIUzI1NiIs...",
"nonce": 48291
},
"turnstileToken": "0.AqW3x9...",
"digits": 6
}
Response
{
"status": "success",
"data": {
"transactionID": "a77549536888557729a0e4cd...",
"transactionReqID": "66726b726cdd6713",
"channels": ["whatsapp", "email"],
"expiresAt": "2026-03-25T12:03:00.000Z"
},
"message": "OTP sent successfully"
}
Response
{
"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.
Only Telegram can be checked before sending
telegram is the only channel whose reachability we can test up front, so it is the only one
that can be refused with pre_send_failure for "this number cannot receive here" — and in the
hosted widget the person is returned to the picker with Telegram removed and their one resend
intact, because nothing was sent. A number with no WhatsApp is only discovered after the
provider accepts the message and later reports it undelivered, which is the second case above.
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
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
{
"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 intodata.enrollmentToken.
OTP length is variable — do not hardcode 6
A pipeline may issue 4, 5 or 6 digit codes (digits in Step 2 — default 6). Size your OTP input from the pipeline's configured length or accept a range; a UI hardwired to six boxes silently breaks the moment a pipeline is switched to 4 or 5, and auto-submit-on-full never fires.
Success Response
- Name
status- Type
- string
- Description
"success"on valid OTP.
- Name
data.verified- Type
- boolean
- Description
truewhen 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
{
"transactionReqID": "68b4a1e8d686446a498008bd",
"otp": "<user-entered-otp>",
"returnTarget": {
"origin": "https://your-site.example"
}
}
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
{
"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.
V1.2 callbacks are delivered unsigned — confirm the result
The direct V1.2 API callback body contains mainTransaction and transactionReq. Dev-mode state is carried on those transaction objects. Treat delivery as the signal and confirm the outcome with a server-side check on transactionID — never grant access off an unauthenticated POST to your callback URL alone.
- Name
Payload- Type
- object
- Description
- The callback is an object with sanitized
mainTransactionand thetransactionReqobject; it has no top-level status envelope.
- Name
Dev-mode marker- Type
- boolean fields
- Description
- For a test-pair transaction,
mainTransaction.isTestandtransactionReq.isTestare true.
- Name
Signature- Type
- no generated svix headers
- Description
- The direct API call sites omit
signingSecretwhen invokingsendBackendCallback, so this path does not generatesvix-*headers.
The payload contains the verification code
The mainTransaction object includes an OTP field holding the actual code, and — as above — the request is unsigned. Treat these callbacks as sensitive: deliver them over HTTPS, keep the raw body out of your logs and error trackers, and do not forward it to third-party tooling.
There is also no retry and no delivery log on this path. A callback your server misses is not re-sent and leaves no record, so reconcile important state from the API rather than depending on the callback arriving.
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.
The same SDKs also launch passkeys
Beyond PoW and Turnstile, each Shield SDK can launch the hosted V1.2 passkey ceremony.
React Native uses /pk in a react-native-webview <WebView> or in-app browser instead
of a native launcher. See each SDK page for platform setup, and use the
V1.2 Passkeys guide for the shared endpoint,
NO_PASSKEY, proof, and callback contracts. Use the
V1.2 passkey prompts to generate a
greenfield integration or add passkeys to an existing V1.2 OTP flow.
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)
- POST
/transactions-- create transaction - POST
/transactions/activate/{id}-- send OTP - POST
/transactions/verify/{id}-- verify OTP
V1.2 Flow (2 server calls + 1 client step)
- GET
/v1.2/transactions/challenge-- get PoW challenge - Client: solve PoW + get Turnstile token (Shield SDK)
- POST
/v1.2/transactions/send-- send OTP with proofs - POST
/v1.2/transactions/verify-- verify OTP (transactionReqIDin 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
| Limit | Value |
|---|---|
| Challenge expiry | Scales 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 expiry | 2 minutes |
| Transaction expiry | 3 minutes. Use data.expiresAt from the send response when deciding whether to request a new OTP. |
V1.2 has no resend and no request signing
The core OTP flow is three endpoints — /challenge, /send and /verify. There is no resend endpoint: to give a user another code, run the flow again from /challenge. (V1.2 also mounts a separate passkey ceremony under /api/v1.2/transactions/passkey, gated independently of the OTP flow — see Passkeys.) V1.2 also has no request-signing scheme — your requests are authenticated by APIKey plus the PoW and Turnstile proofs, not by a signature you compute. (Request signing on outbound webhooks is separate and covered in Webhook Callbacks; the usk_ signing scheme belongs to Utilities, and widget-level signing to V2.)
Authentication Errors
| Status | Code | Cause and Solution |
|---|---|---|
| 400 | MISSING_REQUIRED_FIELDS | APIKey and pipelineID are required for the challenge request; transactionReqID and otp are required for verification. |
| 401 | INVALID_API_KEY | API key is invalid or does not match any account. Verify in your dashboard. |
Pipeline and Configuration Errors
| Status | Code | Cause and Solution |
|---|---|---|
| 403 | PIPELINE_OWNERSHIP_MISMATCH | The pipeline belongs to a different account. Use a pipeline owned by the account identified by APIKey. |
| 403 | INVALID_API_VERSION | The pipeline is not configured for V1.2. |
| 403 | PIPELINE_INACTIVE | The pipeline is inactive. Activate it in the pipeline editor. |
| 403 | UTILITY_PIPELINE_ON_OTP_ENDPOINT | The selected pipeline is a Utilities pipeline. Use the Utilities API instead. |
| 404 | PIPELINE_NOT_FOUND | The pipelineID does not identify an existing pipeline. |
| 500 | PIPELINE_NOT_LOADED | The 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.
| Status | Code | Cause and Solution |
|---|---|---|
| 400 | invalid_channel | channel 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. |
| 400 | channel_choice_not_enabled | Not 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. |
| 400 | channel_not_eligible | A 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. |
| 422 | pre_send_failure | The 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. |
| 500 | ACTIVATION_FAILED | The 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
| Status | Code | Cause and Solution |
|---|---|---|
| 400 | POW_SOLUTION_MISSING | The request omitted the PoW solution while PoW was required. Solve the current challenge and send its challengeToken and nonce. |
| 400 | POW_INVALID_TOKEN, POW_INVALID_SIGNATURE, POW_PIPELINE_MISMATCH, POW_INVALID_SOLUTION | The submitted PoW proof is invalid or bound to another pipeline. Fetch a new challenge and solve it for this pipeline. |
| 409 | POW_CHALLENGE_REPLAYED, POW_DIFFICULTY_STALE | The proof was already consumed or was minted below the current difficulty. Fetch a new challenge and retry. |
| 410 | POW_CHALLENGE_EXPIRED | The 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. |
| 500 | POW_VERIFICATION_FAILED, POW_DIFFICULTY_CALCULATION_FAILED | The server could not complete a PoW operation. Retry the request. |
| 400 | TURNSTILE_TOKEN_MISSING | Turnstile is enabled but no token was provided. Obtain a token before sending. |
| 403 | TURNSTILE_VERIFICATION_FAILED | Cloudflare rejected the Turnstile token. Obtain a fresh token and retry. |
| 500 | TURNSTILE_SECRET_KEY_MISSING | The pipeline's Turnstile secret is not configured. Contact the pipeline owner. |
| 502 | TURNSTILE_API_ERROR | Turnstile's API returned an error. Retry the request. |
| 504 | TURNSTILE_VALIDATION_TIMEOUT | Turnstile 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.
| Status | Code | Cause and Solution |
|---|---|---|
| 429 | RATE_LIMIT_PHONENUMBER_PERMINUTE, RATE_LIMIT_PHONENUMBER_PERHOUR, RATE_LIMIT_PHONENUMBER_PERDAY | The same phone number has requested too many OTPs in this window. Wait for retryAfter before retrying. |
| 429 | RATE_LIMIT_ENDUSERIP_PERMINUTE, RATE_LIMIT_ENDUSERIP_PERHOUR, RATE_LIMIT_ENDUSERIP_PERDAY | Too 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. |
| 429 | RATE_LIMIT_PIPELINE_PERMINUTE, RATE_LIMIT_PIPELINE_PERHOUR, RATE_LIMIT_PIPELINE_PERDAY | Pipeline-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.
| Status | Code | Cause and Solution |
|---|---|---|
| 503 | CIRCUIT_BREAKER_OPEN | Pipeline circuit breaker is open due to flood detection. Wait for cooldownSeconds. |
| 503 | CIRCUIT_BREAKER_TRIGGERED | Pipeline suspended due to sustained high traffic. Automatic recovery after cooldown. |
Transaction and Verification Errors
| Status | Code | Cause and Solution |
|---|---|---|
| 400 | MISSING_REQUIRED_FIELDS | Required fields are missing from the request. For send, provide APIKey and pipelineID; for verify, provide transactionReqID and otp. |
| 400 | MISSING_VERIFICATION_ADDRESS | The send request did not include a verificationAddress with a phoneNumber or email. |
| 500 | TRANSACTION_NOT_FOUND | The transaction created for the send request could not be retrieved. Retry the request. |
| 410 | TRANSACTION_EXPIRED | Transaction expired. Use the expiresAt value returned by send and create a new transaction when it has passed. |
| 403 | INVALID_OTP | OTP entered by user does not match. Prompt them to re-enter. |
| 409 | ALREADY_VERIFIED | The transaction request was already verified. Do not submit the same request again. |
| 429 | MAX_ATTEMPTS_EXCEEDED | 10th wrong OTP on this transaction. The transaction is force-expired — create a new one; further attempts return TRANSACTION_EXPIRED. |
| 500 | SEND_OTP_FAILED | The V1.2 send operation failed outside the handled transaction paths. Retry the request. |
| Propagated status (default 500) | ACTIVATION_FAILED | The transaction was created but could not be activated. Retry according to the returned status. |
| 402 | BILLING_FAILED | Billing failed while verifying the OTP. Resolve the billing issue before retrying. |
| 500 | VERIFY_OTP_FAILED | The verify operation failed outside the handled verification outcomes. Retry the request. |
| Propagated from transaction creation (default 400) | TRANSACTION_CREATION_FAILED | The 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.
| Status | Code | Cause and Solution |
|---|---|---|
| 500 | CHALLENGE_GENERATION_FAILED, POW_CHALLENGE_GENERATION_FAILED | The server could not create a PoW challenge. Retry the challenge request. |
Validation Errors
| Status | Code | Cause and Solution |
|---|---|---|
| 400 | VALIDATION_ERROR | Request body failed validation. Check the details field for specific issues. |
| 413 | PAYLOAD_TOO_LARGE | Request body exceeds size limit. |
Server Errors
| Status | Code | Cause and Solution |
|---|---|---|
| 500 | INTERNAL_SERVER_ERROR | Unexpected server error. Retry the request. If persistent, contact support at support@akedly.io. |
Stay Updated
Questions?
Reach out to the team at support@akedly.io or contact the co-founders directly at muhad@akedly.io or hana@akedly.io.
