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.
Recommended REST API
V1.2 is our recommended REST API. Built-in Proof-of-Work, Cloudflare Turnstile, pipeline-level rate limiting, and circuit breaking — no iframe required. Production-ready and the default choice for REST integrations. 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: before your user receives an OTP, the client must:
- 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 | Built-in (Shield SDK) | N/A (widget-managed) |
| Captcha Protection | Implement your own | Built-in Turnstile | 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 | Pipeline-level | Widget-level |
| PPSA Pricing | No | No | Eligible |
| Security Coverage | DIY | ~80% of V2 | Full |
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.
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 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
// 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
curl "https://api.akedly.io/api/v1.2/transactions/challenge?APIKey=YOUR_API_KEY&pipelineID=YOUR_PIPELINE_ID"
Response
{
"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
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;
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("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 optionallyemail.
- Name
powSolution- Type
- JSON
- Description
Object containing
challengeToken(from Step 1) andnonce(from PoW solver).
- Name
turnstileToken- Type
- string
- Description
Turnstile verification token. Required when
turnstile.requiredwastruein 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,
4or6. Defaults to6.
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.
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_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
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
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
{
"transactionReqID": "68b4a1e8d686446a498008bd",
"otp": "123456"
}
Response
{
"status": "success",
"data": {
"verified": true,
"transactionID": "ae2eacaebe3ed78b105498d5...",
"frontendCallbackURL": "https://yourapp.com/auth/callback?transactionID=ae2eac...&status=Successful"
},
"message": "OTP verified successfully"
}
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.
Backend webhooks are signed
Every callback request ships with HMAC-SHA256 svix-id, svix-timestamp, and svix-signature headers, signed with a per-pipeline secret only you and Akedly know. Verify the signature on your side before trusting the payload — without it, anyone who learns your callback URL can forge results. See the webhook signing guide →
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.
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
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
| Limit | Value |
|---|---|
| Challenge expiry | 5 minutes |
| Captcha token expiry | 2 minutes |
| Transaction expiry | 3 minutes |
| Max resends | 1 |
| Signature validity | 60 seconds |
Authentication Errors
| Status | Code | Cause and Solution |
|---|---|---|
| 400 | MISSING_PUBLIC_KEY | API key not provided. Include APIKey in your request. |
| 401 | INVALID_API_KEY | API key is invalid or does not match any account. Verify in your dashboard. |
| 403 | IP_BLACKLISTED | Your IP has been blocked. Contact support. |
Pipeline and Configuration Errors
| Status | Code | Cause and Solution |
|---|---|---|
| 400 | PIPELINE_NOT_CONFIGURED | Pipeline is missing required settings. Check pipeline configuration in dashboard. |
| 403 | WIDGET_DISABLED | The pipeline is disabled. Activate it in Dashboard > Pipelines. |
| 403 | WIDGET_SUSPENDED | Pipeline suspended due to abuse detection. Contact support. |
| 404 | WIDGET_NOT_FOUND | Pipeline ID does not exist. Verify the pipelineID in your dashboard. |
Challenge and Captcha Errors
| Status | Code | Cause and Solution |
|---|---|---|
| 400 | CAPTCHA_TOKEN_MISSING | Turnstile token not provided but required. Call getTurnstileToken() before sending. |
| 400 | CAPTCHA_INVALID_TURNSTILE | Turnstile token is malformed. Regenerate using Shield SDK. |
| 403 | CAPTCHA_NOT_VERIFIED | Turnstile verification failed. The token was rejected by Cloudflare. |
| 409 | CAPTCHA_ALREADY_USED | Turnstile token was already consumed. Generate a new token for each request. |
| 410 | CHALLENGE_EXPIRED | The PoW challenge has expired (5-minute TTL). Request a new challenge from Step 1. |
| 500 | CHALLENGE_GENERATION_FAILED | Server failed to generate a challenge. Retry the request. |
| 502 | CAPTCHA_VALIDATION_FAILED | Cloudflare Turnstile validation service returned an error. Retry. |
| 504 | CAPTCHA_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. The error code follows the pattern RATE_LIMIT_<DIMENSION>_<WINDOW>, where dimension is PHONENUMBER, ENDUSERIP, or PIPELINE, and window is PERMINUTE, PERHOUR, or PERDAY.
| Status | Code | Cause and Solution |
|---|---|---|
| 429 | RATE_LIMIT_PHONENUMBER_PERMINUTE / PERHOUR / PERDAY | The same phone number has requested too many OTPs in this window. Wait for retryAfter before retrying. |
| 429 | RATE_LIMIT_ENDUSERIP_PERMINUTE / PERHOUR / 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 / PERHOUR / PERDAY | Pipeline-wide cap reached across all end users. Consider raising the pipeline rate limits in the dashboard. |
| 429 | RESEND_LIMIT_EXCEEDED | Maximum 1 resend per transaction. Create a new transaction instead. |
Circuit Breaker Errors
| Status | Code | Cause and Solution |
|---|---|---|
| 429 | 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. |
Security and Threat Detection
| Status | Code | Cause and Solution |
|---|---|---|
| 403 | SECURITY_BLOCKED | Request blocked by threat detection. Response includes riskScore, threatLevel, and verdict. Threat levels: LEVEL_1 (warning), LEVEL_2 (enhanced throttling), LEVEL_3 (suspension). |
| 400 | INVALID_DEVICE_ID | Device ID format is invalid. |
| 403 | FINGERPRINT_INCONSISTENT | Device fingerprint changed between requests. Possible spoofing detected. |
Transaction and Verification Errors
| Status | Code | Cause and Solution |
|---|---|---|
| 400 | MISSING_REQUIRED_FIELDS | Required fields missing from request body. Check APIKey, pipelineID, verificationAddress. |
| 404 | TRANSACTION_NOT_FOUND | Transaction ID does not exist. Verify the transactionReqID from Step 2. |
| 410 | TRANSACTION_EXPIRED | Transaction expired (3-minute TTL). Create a new transaction. |
| 403 | INVALID_OTP | OTP entered by user does not match. Prompt them to re-enter. |
| 502 | OTP_SEND_FAILED | OTP delivery failed across all channels. Retry the request. |
| 500 | TRANSACTION_CREATION_FAILED | Internal error creating transaction. Retry. If persistent, contact support. |
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.