Create an attempt
Sign a request on your backend and create an attempt, with code examples for popular backend languages.
Step 2: Backend - Create Attempt
Your backend is responsible for initiating the authentication flow by creating an "attempt". This is a server-side operation that must never be done from the frontend to protect your widget secret.
Understanding the Flow
What happens when you create an attempt:
- User requests authentication - Your frontend collects the phone number and sends it to your backend
- Your backend creates a signature - Using your widget secret, you generate an HMAC-SHA256 signature to prove you own the widget
- Your backend calls Akedly API - Send the signed request to create an attempt
- Akedly returns an iframe URL - You receive a unique URL that opens the authentication widget
- Your backend sends URL to frontend - Pass the
iframeUrlto your frontend to display the widget
Security Rule: The widget secret must NEVER leave your server. All signature generation and API calls happen on your backend only.
Why HMAC Signatures?
HMAC-SHA256 signatures prove that the request came from someone who knows the widget secret, without exposing the secret itself. This prevents attackers from creating unauthorized authentication attempts even if they know your public key.
Signature Generation (Language-Agnostic)
The signature is the most critical part. Here's how to generate it in any language:
Step 1: Prepare the message
For a phone-bearing request, create a JSON string with these exact keys in this exact order:
apiKey, publicKey, timestamp, phoneNumber. Email-only signing is shown below; the
email address is not part of the HMAC message.
{
"apiKey": "YOUR_API_KEY",
"publicKey": "YOUR_PUBLIC_KEY",
"timestamp": 1234567890123,
"phoneNumber": "+1234567890"
}
Step 2: Generate HMAC-SHA256
Use your widget secret as the key and the JSON string as the message:
signature = HMAC-SHA256(secret, message)
output = hex_encode(signature)
Step 3: Include in request
Send the hex-encoded signature in the signature field of your API request.
Common Signature Mistakes
- Wrong JSON order: Keys must be in exact order:
apiKey,publicKey,timestamp,phoneNumberwhen a phone number is present - Extra spaces: Use compact JSON with no spaces after colons or commas
- Wrong encoding: Both secret and message must be UTF-8 encoded
- Stale timestamp: Timestamp must be within 60 seconds of server time, in either direction
The signature window is 60 seconds
A signed request is valid for 60 seconds only, measured as an absolute difference — a timestamp 60+ seconds in the future fails just as a stale one does, so unsynchronized clocks break signing in both directions. Sign immediately before sending. Do not queue, batch, or retry a previously-signed payload: any retry path must generate a fresh timestamp and a fresh signature.
API Reference
Required Parameters
- Name
- apiKey
- Type
- string
- Description
Your Akedly API key, from Account & Billing → API Keys in the dashboard
- Name
- publicKey
- Type
- string
- Description
The widget's public key (from widget creation)
- Name
- signature
- Type
- string
- Description
HMAC-SHA256 signature of the request payload
- Name
- timestamp
- Type
- number
- Description
Current Unix timestamp in milliseconds. Must be within 60 seconds of server time in either direction (absolute delta), so generate it immediately before signing.
- Name
- verificationAddress
- Type
- object
- Description
Contact information for OTP delivery. Include
phoneNumber(with country code) and/oremail.
Ensure that the phone number is in E.164 format with country code (e.g.
+20155664423). Make sure it's normalized (no spaces, dashes, or
parentheses). For a list of country codes, see E.164 Country
Codes.
Arabic-Indic (٠-٩) and Persian (۰-۹) digits are also accepted in phone
numbers and OTP codes, and Akedly converts them to ASCII digits. The
signature is checked on the phone number exactly as you send it, before that
conversion, so sign the same string you put in
verificationAddress.phoneNumber.
- Name
- digits
- Type
- number
- Requirement
- Optional
- Description
OTP length:
4,5or6. Defaults to6. Unlike V1.2, V2 rejects an out-of-range value withINVALID_OTP_DIGITS. The hosted widget renders whatever length the attempt carries — to try one without sending anything, see Dev Mode & Test Pairs.
- Name
- otp
- Type
- string
- Requirement
- Optional
- Description
Bring-your-own OTP (4, 5 or 6 digits). When provided, billing switches to pay-per-message instead of pay-per-verification. If you send both
otpanddigits, the OTP's length must matchdigitsor the request fails withOTP_DIGITS_MISMATCH.
- Name
- otpLanguage
- Type
- string
- Requirement
- Optional
- Description
OTP message language for WhatsApp and SMS. Overrides the pipeline's OTP Language for this attempt. Set it on your server at create-attempt. It is unsigned and is fixed for that attempt, even if the pipeline language changes later; browser
request-otpandresend-otpcalls cannot change it. It is separate from any language value you put inpublicMetadata,data.template.languageand the widget’s screen language. Invalid values return HTTP 400 withcode: "INVALID_OTP_LANGUAGE"andmessage. See accepted values and defaults.
- Name
- template
- Type
- object
- Requirement
- Optional
- Description
{ id, variables }— send this attempt's WhatsApp OTP in your own approved OTP template wording. Set it on your server only: the signature does not covertemplate, and the widget cannot change it later. It must be Approved in the language the OTP uses (otpLanguagewhen supplied, otherwise the pipeline's OTP Language), or the request fails with a 400 before anything is sent.
- Name
- publicMetadata
- Type
- object
- Description
Optional custom data returned in frontend redirect URL as
meta_*query parameters. Useful for tracking IDs, session references, or order context. Max combined size with privateMetadata: 10KB.
- Name
- privateMetadata
- Type
- object
- Description
Optional server-only custom data included only in backend webhooks. Never sent to browser. Useful for sensitive data, auth tokens, or internal state. Max combined size with publicMetadata: 10KB.
- Name
- customHeaders
- Type
- object
- Description
Optional key-value pairs of custom HTTP headers forwarded in webhook callbacks to your backend. Max 4KB size limit. Headers
content-type,user-agent,host,content-lengthand anything beginningsvix-are blacklisted, silently filtered out before dispatch, and the filtered result is persisted with the attempt. Thesvix-prefix is reserved because that is where Akedly puts the callback's own signature headers — supplying your own would let a caller overwrite them.
Response
- Name
- status
- Type
- string
- Description
"success" or "error"
- Name
- data.attemptId
- Type
- string
- Description
Unique attempt identifier (e.g.,
attempt_a1b2c3d4e5f6...)
- Name
- data.iframeUrl
- Type
- string
- Description
Full URL to open in iframe (e.g.,
https://auth.akedly.io/auth?attemptId=xxx)
- Name
- data.expiresAt
- Type
- string
- Description
ISO 8601 timestamp when attempt expires (5 minutes from creation)
- Name
- data.otpLanguage
- Type
- string
- Description
Always present:
enorar. The language the OTP is requested in, not a delivery receipt. Browser-facing V2 responses are unchanged.
- Name
- data.template
- Type
- object
- Description
Present when a custom template was selected, in the request or by the pipeline default:
{ id, language }as resolved at creation.request-otpandresend-otpreturn the same value. It does not confirm which channel or template delivered the code.
Custom WhatsApp wording
Add a template in the dashboard, wait for Approved, then pass it as template: { id }.
See the OTP templates guide for variables, language, pipeline defaults and fallback.
Custom Metadata
Attach custom data to verification attempts that gets returned in callbacks. This is useful for tracking users, orders, or sessions through the authentication flow.
Two Types of Metadata:
- Name
- publicMetadata
- Type
- object
- Description
Data returned in the frontend redirect URL as query parameters prefixed with
meta_. Also included in backend webhooks.Use for: User IDs, order IDs, session references, analytics tracking, non-sensitive context.
Example redirect:
?status=success&meta_userId=1&meta_orderId=2
- Name
- privateMetadata
- Type
- object
- Description
Data returned only in backend webhooks. Never sent to the browser or included in redirect URLs.
Use for: Internal user IDs, session tokens, sensitive business data, server-side state.
Size Limit
Combined publicMetadata + privateMetadata must not exceed 10KB (10,240
bytes). Requests exceeding this limit will receive a METADATA_SIZE_EXCEEDED
error.
Implementation Examples
Complete code examples for creating an authentication attempt in popular backend languages.
const crypto = require('crypto')
const axios = require('axios')
// Environment variables (NEVER expose these in frontend)
const AKEDLY_API_KEY = process.env.AKEDLY_API_KEY
const WIDGET_PUBLIC_KEY = process.env.WIDGET_PUBLIC_KEY
const WIDGET_SECRET = process.env.WIDGET_SECRET
function generateSignature(apiKey, publicKey, secret, timestamp, phoneNumber) {
const message = JSON.stringify({
apiKey,
publicKey,
timestamp,
phoneNumber,
})
return crypto.createHmac('sha256', secret).update(message).digest('hex')
}
async function createAuthAttempt(phoneNumber, email = null, metadata = {}) {
const { publicMetadata, privateMetadata } = metadata
const timestamp = Date.now()
const signature = generateSignature(
AKEDLY_API_KEY,
WIDGET_PUBLIC_KEY,
WIDGET_SECRET,
timestamp,
phoneNumber,
)
const response = await axios.post(
'https://api.akedly.io/api/v1/widget-sdk/create-attempt',
{
apiKey: AKEDLY_API_KEY,
publicKey: WIDGET_PUBLIC_KEY,
signature,
timestamp,
verificationAddress: {
phoneNumber,
email,
},
digits: 6,
publicMetadata,
privateMetadata,
},
)
return response.data.data // { attemptId, iframeUrl, expiresAt }
}
// Usage in Express route
app.post('/api/auth/start', async (req, res) => {
try {
const { phoneNumber, email, userId, orderId } = req.body
const attempt = await createAuthAttempt(phoneNumber, email, {
publicMetadata: { userId, orderId },
privateMetadata: { internalId: req.session.internalId },
})
res.json({
success: true,
attemptId: attempt.attemptId,
iframeUrl: attempt.iframeUrl,
})
} catch (error) {
const upstream = error.response?.data
res.status(error.response?.status || 500).json({
success: false,
message: 'Could not start verification. Please try again.',
code: upstream?.code,
retryable: upstream?.retryable,
retryAfter: upstream?.retryAfter,
cooldownSeconds: upstream?.cooldownSeconds,
})
}
})
