V1.2 Passkeys
Add passkeys to a V1.2 REST integration without replacing your OTP flow. Your backend starts authentication, Akedly hosts the WebAuthn ceremony, and your app falls back to OTP whenever passkey authentication is unavailable or unverified.
Keep Akedly credentials on your backend
Only your backend should call POST /auth-options. It requires your APIKey,
pipelineID, and Shield proofs. The hosted ceremony uses short-lived tokens for the other
five endpoints and does not need your API key.
Before you start: passkeys must be enabled
Every endpoint on this page returns 403 PASSKEY_DISABLED until all three of these are true:
- Name
Passkeys released- Type
- Akedly-controlled
- Description
- A global switch we hold, and it is on. Passkeys are generally available — not an enterprise tier, not early access, and nothing to join.
- Name
Account enabled- Type
- entitlement
- Description
- On by default, for every account. There is nothing to request. If passkeys are ever switched off for a particular account that is a deliberate exception, and you would know about it.
- Name
Pipeline passkeys on- Type
- toggle
- Description
- Each pipeline carries a
passkeyEnabledtoggle, and in practice this is the only gate you set. New pipelines arrive with it on. Every pipeline you already have keeps exactly the setting it has right now — if passkeys were on for it, they stay on; if you had turned them off, they stay off. Turn it off on any pipeline you want to keep OTP-only, and it stays off.
You can integrate against this page before any of them is on: every call simply returns
403 PASSKEY_DISABLED and your OTP fallback runs, so the passkey path lights up with
no further code changes once all three are true.
Passkey or OTP
There is no passkey lookup endpoint. Use POST /auth-options as the availability check:
- Solve the same Proof-of-Work challenge used by the V1.2 OTP flow. Obtain a Turnstile token when the pipeline requires one.
- Call
POST /api/v1.2/transactions/passkey/auth-options. - On
200, open the hosted ceremony with the returnedceremonyToken. - On
404withcode: "NO_PASSKEY", continue with your existing OTPsendandverifyflow. - On
403withcode: "PASSKEY_DISABLED", continue with OTP. It is a safe fallback and a configuration signal: passkeys are off at one of the three gates above and the response does not tell you which — one code covers all of them. If you get it on every call, check all three in Before you start rather than your integration. For passkey-specific429responses, honorretryAfterand offer a user-initiated OTP option rather than triggering the paid fallback automatically. Apply your normal retry or failure policy to configuration, circuit-breaker, and invalid-proof errors.
NO_PASSKEY is the primary branch once passkeys are on
While the pipeline's toggle is off, the only branch you will ever see is 403 PASSKEY_DISABLED. Turn it on and NO_PASSKEY becomes the common case.
NO_PASSKEY means the phone has no active passkey for this pipeline and relying-party ID.
The speculative call is safe: the controller checks credentials before creating a
passkey-auth request, and the rate-limit middleware only counts existing requests. It does
not consume the passkey rate limit or create a record. It does consume the submitted PoW
solution because the credential lookup runs after the Shield middleware. When required,
the submitted Turnstile token is also validated and consumed, so obtain a fresh token
before OTP fallback.
Availability branch
const response = await fetch(
'https://api.akedly.io/api/v1.2/transactions/passkey/auth-options',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
APIKey: process.env.AKEDLY_API_KEY,
pipelineID: process.env.AKEDLY_PIPELINE_ID,
verificationAddress: { phoneNumber },
powSolution,
turnstileToken,
returnTarget: { origin: 'https://your-site.example' },
}),
}
)
const body = await response.json()
if (response.status === 404 && body.code === 'NO_PASSKEY') {
return startOtpFlow(phoneNumber)
}
if (!response.ok) throw new Error(body.code)
// Keep requestID with the user/sign-in you started. The result proof is bound to it.
return {
ceremonyToken: body.data.ceremonyToken,
requestID: body.data.requestID,
}
Endpoint reference
Base URL:
https://api.akedly.io/api/v1.2/transactions/passkey
| Method | Path | Authorization | Purpose |
|---|---|---|---|
POST | /auth-options | API key + Shield gate | Start authentication and mint an auth ceremony token. |
POST | /auth-verify | Auth ceremony token | Verify the assertion, settle billing, and return proof. |
POST | /register-options | Enrollment token | Validate the OTP enrollment gate and mint an enroll ceremony token. |
POST | /register-verify | Enroll ceremony token | Verify attestation and store the credential. |
GET | /ceremony | Enrollment or ceremony token | Resolve hosted-page purpose, branding, return target, and auth options. |
GET | /result | Auth ceremony token | Read an authentication state when the relay is lost. |
The token-authorized calls are normally made by the hosted /pk page.
This reference is for proxy, gateway, and troubleshooting work.
Authentication endpoints
Start authentication
POST /auth-options runs the API-key, circuit-breaker, passkey-rate-limit, Turnstile, and PoW middleware before it checks for a credential.
Request body
- Name
APIKey- Type
- string
- Description
Required. Your account API key.
- Name
pipelineID- Type
- string
- Description
Required. An active V1.2 pipeline owned by the API-key account.
- Name
verificationAddress.phoneNumber- Type
- string
- Description
Required. The user's phone number. Akedly normalizes supported country-code formats before lookup.
- Name
powSolution.challengeToken- Type
- string
- Description
Required when PoW is enabled. The signed challenge token from the V1.2 challenge endpoint.
- Name
powSolution.nonce- Type
- number
- Description
Required when PoW is enabled. The solved nonce.
- Name
turnstileToken- Type
- string
- Description
Required when Turnstile is enabled for the pipeline.
- Name
returnTarget- Type
- object
- Description
Required to receive a
resultToken. A server-signed relay target:originfor a webpostMessage,urlfor an HTTP(S) redirect or native custom-scheme deep link, or both. Use a bare HTTP(S) origin without a trailing slash. For a native SDK theurlis that app's deep link —<callbackScheme>://akedly-passkey. An HTTP(S)urlthat is not on the pinnedoriginis silently dropped.originis capped at 256 characters andurlat 2048; anything longer is dropped without an error.
Omit returnTarget and every successful sign-in reports no_proof
The hosted page releases a resultToken only to a target your backend signed into the
ceremony token here. A returnUrl the client puts in the ceremony query is untrusted by
design, so the relayed result is delivered token-stripped. The ceremony still succeeds and
is still billed — but every SDK fails closed and reports verified: false,
reason: "no_proof", with no error and nothing logged. If you cannot sign a target, treat the
relay as a UX signal only and confirm the outcome from the pipeline's backend callback or
GET /result instead.
Success response
- Name
status- Type
- string
- Description
"success".
- Name
data.ceremonyToken- Type
- string
- Description
Short-lived auth ceremony token.
- Name
data.requestID- Type
- string
- Description
Server-minted passkey-auth request UUID.
- Name
data.options- Type
- object
- Description
WebAuthn authentication options for the hosted page.
- Name
message- Type
- string
- Description
"Passkey authentication initiated".
Request
{
"APIKey": "YOUR_API_KEY",
"pipelineID": "YOUR_PIPELINE_ID",
"verificationAddress": {
"phoneNumber": "+201234567890"
},
"powSolution": {
"challengeToken": "SIGNED_CHALLENGE_TOKEN",
"nonce": 42
},
"turnstileToken": "TURNSTILE_TOKEN",
"returnTarget": {
"origin": "https://your-site.example"
}
}
Response
{
"status": "success",
"data": {
"ceremonyToken": "pk1.<encrypted-token>",
"requestID": "8ca6bb40-c01f-4dc7-a704-45d1d950a024",
"options": {
"challenge": "<base64url>",
"rpId": "akedly.io",
"timeout": 60000,
"userVerification": "required",
"allowCredentials": [
{
"id": "<credential-id>",
"transports": ["internal"],
"type": "public-key"
}
]
}
},
"message": "Passkey authentication initiated"
}
Verify authentication
POST /auth-verify burns the ceremony token, verifies the WebAuthn assertion with user verification required, settles the passkey charge, and returns signed proof.
Request body
- Name
ceremonyToken- Type
- string
- Description
Required. The auth ceremony token from
/auth-options.
- Name
authResp- Type
- object
- Description
Required. The WebAuthn authentication response.
authResp.idmust be present.
Success response
- Name
status- Type
- string
- Description
"success".
- Name
data.verified- Type
- boolean
- Description
true.
- Name
data.transactionID- Type
- string
- Description
The passkey-auth request UUID.
- Name
data.frontendCallbackURL- Type
- string | null
- Description
Pipeline frontend callback URL with
transactionIDandstatus=Successful, ornullwhen not configured.
- Name
data.resultToken- Type
- string | null
- Description
HMAC-signed proof for offline verification on your backend.
nullwhen the account has noAPIKeyto sign with.
- Name
message- Type
- string
- Description
"Passkey verified successfully".
Request
{
"ceremonyToken": "pk1.<encrypted-token>",
"authResp": {
"id": "<credential-id>",
"rawId": "<credential-id>",
"response": {
"authenticatorData": "<base64url>",
"clientDataJSON": "<base64url>",
"signature": "<base64url>",
"userHandle": null
},
"type": "public-key"
}
}
Response
{
"status": "success",
"data": {
"verified": true,
"transactionID": "8ca6bb40-c01f-4dc7-a704-45d1d950a024",
"frontendCallbackURL": "https://your-site.example/auth/callback?transactionID=8ca6bb40-c01f-4dc7-a704-45d1d950a024&status=Successful",
"resultToken": "pkrt1.<payload>.<hmac>"
},
"message": "Passkey verified successfully"
}
Terminal failure response
{
"status": "error",
"code": "PASSKEY_AUTH_FAILED",
"message": "Assertion did not verify",
"data": {
"frontendCallbackURL": "https://your-site.example/auth/callback?transactionID=8ca6bb40-c01f-4dc7-a704-45d1d950a024&status=Failed&reason=passkey_auth_failed"
}
}
For terminal PASSKEY_AUTH_FAILED, INSUFFICIENT_QUOTA, and BILLING_FAILED errors with
ceremony context, data.frontendCallbackURL contains the failed redirect URL when the pipeline
configures one; otherwise it is null. The idempotency-loser path explicitly skips callback
dispatch and returns the error envelope without data. When present, the URL's reason query
value is the lowercased error code.
Enrollment endpoints
Create registration options
POST /register-options accepts the enrollmentToken returned by a successful V1.2 OTP /verify. The underlying OTP verification must still be successful, unused for enrollment, and within the 120-second enrollment window.
Request body
- Name
enrollmentToken- Type
- string
- Description
Required. The enrollment token returned by OTP
/verify.
- Name
returnTarget- Type
- object
- Description
Optional. Relay target echoed by the hosted page. The controller sanitizes and signs this request-body value into the enroll ceremony token.
Success response
- Name
status- Type
- string
- Description
"success".
- Name
data.ceremonyToken- Type
- string
- Description
Short-lived enroll ceremony token, clamped to the remaining enrollment window.
- Name
data.options- Type
- object
- Description
WebAuthn registration options for the hosted page.
- Name
message- Type
- string
- Description
"Passkey enrollment initiated".
Request
{
"enrollmentToken": "pk1e.<encrypted-token>",
"returnTarget": {
"url": "myapp://akedly-passkey"
}
}
Response
{
"status": "success",
"data": {
"ceremonyToken": "pk1.<encrypted-token>",
"options": {
"challenge": "<base64url>",
"rp": {
"id": "akedly.io",
"name": "Akedly"
},
"user": {
"id": "<opaque-user-handle>",
"name": "Your company (+201****7890)",
"displayName": "Your company — +201****7890"
},
"pubKeyCredParams": [
{ "alg": -8, "type": "public-key" },
{ "alg": -7, "type": "public-key" },
{ "alg": -257, "type": "public-key" }
],
"timeout": 60000,
"attestation": "none",
"excludeCredentials": [],
"authenticatorSelection": {
"residentKey": "discouraged",
"userVerification": "required",
"requireResidentKey": false
},
"extensions": {
"credProps": true
},
"hints": []
}
},
"message": "Passkey enrollment initiated"
}
Verify registration
POST /register-verify burns the enroll ceremony token, requires user verification, consumes the OTP verification's single enrollment right, and stores the credential.
Request body
- Name
ceremonyToken- Type
- string
- Description
Required. The enroll ceremony token from
/register-options.
- Name
attResp- Type
- object
- Description
Required. The WebAuthn registration response.
attResp.idmust be present.
- Name
deviceLabel- Type
- string
- Description
Optional. Customer-facing device label stored with the credential. Stored as
nullwhen omitted.
Success response
- Name
status- Type
- string
- Description
"success".
- Name
data.enrolled- Type
- boolean
- Description
true.
- Name
data.credentialId- Type
- string
- Description
Stored WebAuthn credential ID.
- Name
data.deviceLabel- Type
- string | null
- Description
Stored device label.
- Name
data.resultToken- Type
- string | null
- Description
Signed enrollment proof. Its
transactionIdis the OTPTransactionReq._idthat authorized enrollment.nullwhen the account has noAPIKeyto sign with.
- Name
message- Type
- string
- Description
"Passkey enrolled successfully".
Request
{
"ceremonyToken": "pk1.<encrypted-token>",
"attResp": {
"id": "<credential-id>",
"rawId": "<credential-id>",
"response": {
"attestationObject": "<base64url>",
"clientDataJSON": "<base64url>",
"transports": ["internal"]
},
"type": "public-key"
},
"deviceLabel": "Sara's phone"
}
Response
{
"status": "success",
"data": {
"enrolled": true,
"credentialId": "<credential-id>",
"deviceLabel": "Sara's phone",
"resultToken": "pkrt1.<payload>.<hmac>"
},
"message": "Passkey enrolled successfully"
}
Enrollment proof is opt-in
Pass returnTarget to the V1.2 OTP /verify call so Akedly can sign it into the
enrollmentToken. If you omit it, enrollment still succeeds, but the gateway strips
resultToken from web-popup and custom-scheme redirects. The Web, Swift, Kotlin, and
Dart SDKs then fail closed with verified: false and reason: "no_proof". Treat that
enrollment result as advisory and confirm the credential on the next successful sign-in.
The React Native WebView bridge receives the result directly and is not subject to this
return-target stripping.
Hosted page endpoints
Resolve ceremony
GET /ceremony?token=... is read-only and never burns the token. It accepts an enrollment token or ceremony token.
- Name
token- Type
- query string
- Description
Required. An enrollment token or ceremony token.
- Name
data.purpose- Type
- string
- Description
"auth"for an auth ceremony token; otherwise"enroll".
- Name
data.branding- Type
- object
- Description
logo,companyName,primaryColor,secondaryColor, andtheme.
- Name
data.phoneNumber- Type
- string | null
- Description
Masked phone number. The endpoint never returns the full phone number.
- Name
data.returnTarget- Type
- object | null
- Description
The sanitized, server-signed relay target carried by the token.
- Name
data.options- Type
- object
- Description
Present for auth only. Rebuilt WebAuthn authentication options.
Responses
{
"status": "success",
"data": {
"purpose": "auth",
"branding": {
"logo": null,
"companyName": "Your company",
"primaryColor": "#008081",
"secondaryColor": "#ABD1D1",
"theme": "light"
},
"phoneNumber": "+201****7890",
"returnTarget": {
"origin": "https://your-site.example"
},
"options": {
"challenge": "<base64url>",
"rpId": "akedly.io",
"timeout": 60000,
"userVerification": "required",
"allowCredentials": [
{
"id": "<credential-id>",
"type": "public-key",
"transports": ["internal"]
}
]
}
}
}
Read authentication result
GET /result?token=... is a read-only recovery poll for auth ceremonies. It accepts an already-burned auth ceremony token and never burns it. It never returns or remints a resultToken. When it recovers state: "verified" after the hosted page loses the verify response, the page returns verified: false, code: "no_proof", and the transaction ID; confirm that transaction through your backend record or callback before falling back to OTP. Normal token expiry returns 410 PASSKEY_TOKEN_EXPIRED. The 200 response with state: "expired" is reserved for a missing auth-request row while the token is still fresh.
- Name
token- Type
- query string
- Description
Required. An auth ceremony token.
- Name
data.state- Type
- string
- Description
"pending","verified","failed", or"expired".
- Name
data.transactionID- Type
- string
- Description
Passkey-auth request UUID. The key uses capital
ID.
- Name
data.verified- Type
- boolean
- Description
trueonly after the auth request is bothVerifiedand billed.
- Name
data.isTest- Type
- boolean
- Description
Present when the auth-request row exists. Identifies a dev-mode test-pair request.
- Name
data.frontendCallbackURL- Type
- string | null
- Description
Present when the auth-request row exists. Non-null only for settled success or failure.
Responses
{
"status": "success",
"data": {
"state": "verified",
"transactionID": "8ca6bb40-c01f-4dc7-a704-45d1d950a024",
"verified": true,
"isTest": false,
"frontendCallbackURL": "https://your-site.example/auth/callback?transactionID=8ca6bb40-c01f-4dc7-a704-45d1d950a024&status=Successful"
}
}
Error reference
Every controller error uses:
{
"status": "error",
"code": "ERROR_CODE",
"message": "Error description"
}
/auth-options can also return middleware errors with retry metadata.
| Status | Code | Endpoints |
|---|---|---|
400 | MISSING_REQUIRED_FIELDS | auth-options, auth-verify, register-verify |
401 | PASSKEY_TOKEN_INVALID | Token-authorized endpoints |
401 | PASSKEY_TOKEN_PURPOSE_MISMATCH | auth-verify, register-verify, result |
410 | PASSKEY_TOKEN_EXPIRED | Token-authorized endpoints |
409 | PASSKEY_TOKEN_REPLAYED | auth-verify, register-verify |
403 | PASSKEY_DISABLED | auth-options, auth-verify, register-options, register-verify, ceremony |
404 | NO_PASSKEY | auth-options, auth ceremony |
404 | PIPELINE_NOT_FOUND | register-options, auth-verify, register-verify, ceremony |
403 | ENROLL_GATE_FAILED | register-options |
409 | ALREADY_ENROLLED | register-options, register-verify |
400 | PASSKEY_REGISTRATION_FAILED | register-options, register-verify |
401 | PASSKEY_AUTH_FAILED | auth-verify |
402 | INSUFFICIENT_QUOTA, BILLING_FAILED | auth-verify |
503 | PASSKEY_CEREMONY_SECRET_MISSING | Endpoints that parse or mint passkey tokens |
500 | PASSKEY_V12_FAILED | Controller fallback for an unclassified failure |
POST /auth-options also inherits the V1.2 pipeline and Shield middleware:
| Status | Codes |
|---|---|
400 | MISSING_REQUIRED_FIELDS, POW_SOLUTION_MISSING, POW_INVALID_TOKEN, POW_INVALID_SIGNATURE, POW_PIPELINE_MISMATCH, POW_INVALID_SOLUTION, TURNSTILE_TOKEN_MISSING |
401 | INVALID_API_KEY |
403 | PIPELINE_OWNERSHIP_MISMATCH, UTILITY_PIPELINE_ON_OTP_ENDPOINT, INVALID_API_VERSION, PIPELINE_INACTIVE, TURNSTILE_VERIFICATION_FAILED |
404 | PIPELINE_NOT_FOUND |
409 | POW_CHALLENGE_REPLAYED, POW_DIFFICULTY_STALE |
410 | POW_CHALLENGE_EXPIRED |
429 | PASSKEY_RATE_LIMIT_PHONENUMBER_PERMINUTE, PASSKEY_RATE_LIMIT_PHONENUMBER_PERHOUR, PASSKEY_RATE_LIMIT_PHONENUMBER_PERDAY |
500 | POW_VERIFICATION_FAILED, POW_DIFFICULTY_CALCULATION_FAILED, TURNSTILE_SECRET_KEY_MISSING |
502 | TURNSTILE_API_ERROR |
503 | CIRCUIT_BREAKER_OPEN, CIRCUIT_BREAKER_TRIGGERED |
504 | TURNSTILE_VALIDATION_TIMEOUT |
PoW and Turnstile errors add retryable. Passkey rate-limit and circuit-breaker errors add
retryable, retryAfter, cooldownSeconds, and details; retryAfter is an ISO timestamp
and cooldownSeconds is the delay.
Results and validation
A successful passkey authentication has three result channels:
- Name
Relayed message or redirect- Type
- UX signal
- Description
Unblocks the browser or app flow. Do not grant access from
verified: truealone.
- Name
resultToken- Type
- proof
- Description
pkrt1.<payload>.<hmac>, signed with your account API key. Send it to your backend, verify it offline, requireverified: trueandpurpose: "auth", check expiry, confirmpipelineIdis the pipeline you configured, and bindtransactionIdto the sign-in you started.
- Name
backendCallbackURL- Type
- server callback
- Description
Also fires when configured. Offline
resultTokenverification does not suppress it. The V1.2 callback is unsigned, so use it as a delivery signal rather than cryptographic proof.
The browser relay and backend callback both fire for successful authentication when a callback is configured. Do not treat one channel as suppressing the other.
The passkey callback uses the V1.2 OTP-shaped envelope:
Backend callback
{
"mainTransaction": {
"transactionID": "8ca6bb40-c01f-4dc7-a704-45d1d950a024",
"status": "Successful",
"pipelineID": "YOUR_PIPELINE_ID",
"verificationAddress": {
"phoneNumber": "+201234567890"
},
"verificationMethod": "passkey",
"isTest": false,
"updateDate": "2026-07-29T12:00:00.000Z"
},
"transactionReq": {
"status": "Successful",
"verificationMethod": "passkey",
"isTest": false,
"verificationDate": "2026-07-29T12:00:00.000Z"
}
}
mainTransaction.transactionID is the PasskeyAuthRequest.requestID UUID. It is not a MainTransaction.transactionID; the callback uses a synthetic OTP-compatible object because passkey authentication does not create a MainTransaction.
Terminal PASSKEY_AUTH_FAILED, INSUFFICIENT_QUOTA, and BILLING_FAILED outcomes send the same envelope with status: "Failed". A successful idempotent replay does not send a duplicate callback.
Verify resultToken
The token payload is:
{
"v": 1,
"purpose": "auth",
"transactionId": "8ca6bb40-c01f-4dc7-a704-45d1d950a024",
"pipelineId": "YOUR_PIPELINE_ID",
"verified": true,
"iat": 1785326400000,
"exp": 1785326520000
}
The signature is HMAC_SHA256("pkrt1." + base64url(payload), APIKey). iat and exp are milliseconds; the token expires after two minutes. Verify it on your backend with a constant-time signature comparison, require canonical unpadded base64url, check exp and verified, require purpose: "auth" and your own pipelineId, bind transactionId, and enforce single use.
The proof is only proof while your API key stays off the device
The signature is an HMAC under your account API key, so anyone holding that key can mint a
verified: true token for any transaction. That is fine while the key lives only on your
backend — which is why the auth-options call above is a server-side call.
It stops being fine if your mobile or web client also calls the V1.2 OTP endpoints directly with
the same APIKey, because that ships it inside your app where it is trivially extracted. An
attacker who pulls it out can enter a victim's phone number, let your backend start the
transaction, forge a proof for that transactionId, and be signed in as the victim — the
signature check, the expiry, and the transaction binding all pass.
Pick one: route your V1.2 calls through your own backend so the key never reaches a client,
or skip offline verification and confirm the outcome server-to-server with GET /result,
treating resultToken as a UX-only signal.
Reject an empty API key before you verify anything
If your key is missing or not a string, fail closed and stop — do not fall through to the
HMAC. A key that arrives as undefined, null or "" would otherwise compute a signature under
that literal value, and a token forged with the same literal would verify. Akedly's own verifier
does this first.
Verify on your backend (Node.js)
const crypto = require('crypto')
function verifyResultToken(token, apiKey) {
// Fail closed on an unusable key — see the callout above.
if (!apiKey || typeof apiKey !== 'string') return null
if (typeof token !== 'string' || !token.startsWith('pkrt1.')) return null
const parts = token.slice('pkrt1.'.length).split('.')
if (parts.length !== 2) return null
const [payloadB64, sigB64] = parts
// Strict unpadded base64url charset. Buffer.from(..,'base64url') is LENIENT and ignores
// stray characters, which would admit alternate spellings of the same signed proof.
const B64URL = /^[A-Za-z0-9_-]+$/
if (!B64URL.test(payloadB64) || !B64URL.test(sigB64)) return null
const given = Buffer.from(sigB64, 'base64url')
const payloadBuf = Buffer.from(payloadB64, 'base64url')
// Enforce CANONICAL encoding, not just the charset. A segment whose length is not a
// multiple of 3 bytes has slack bits in its final character, so a DIFFERENT string can
// decode to the SAME bytes. Your single-use check below is keyed on the token STRING, so
// an alias would sail past it and replay a spent proof. Re-encode and require an exact match.
if (given.toString('base64url') !== sigB64) return null
if (payloadBuf.toString('base64url') !== payloadB64) return null
const expected = crypto
.createHmac('sha256', apiKey)
.update(`pkrt1.${payloadB64}`)
.digest()
// Constant-time compare; timingSafeEqual throws on a length mismatch.
if (given.length !== expected.length) return null
if (!crypto.timingSafeEqual(given, expected)) return null
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8'))
if (payload.verified !== true) return null
if (typeof payload.exp !== 'number' || Date.now() > payload.exp) return null
// The HMAC key is your ACCOUNT API key, so a valid signature only proves the token
// came from Akedly for YOUR account — not which pipeline it came from, and not that
// it was an authentication at all. Both of the next two checks are required.
if (payload.purpose !== 'auth') return null
if (payload.pipelineId !== process.env.AKEDLY_PIPELINE_ID) return null
// Still yours to do: bind payload.transactionId to the sign-in YOU started,
// and enforce single use so a replayed token cannot mint a second session.
return payload
}
An `enroll` proof must never create a session
A successful enrollment also returns a resultToken with verified: true, signed with
the same account API key. It is confirmation that the passkey was stored — nothing more.
A verifier that checks only the signature, verified, and exp will accept it as an
authentication, so a user who reaches the enrollment step could be signed in without
ever authenticating. The purpose !== 'auth' check above is what prevents that; the
pipelineId check is what stops a proof minted by one of your pipelines being replayed
against another.
Reason values are platform-specific
The Web, Swift, Kotlin, and Dart SDKs fail closed with reason: "no_proof" when a
relay claims success without a non-blank resultToken. Other SDK reasons differ because
popup, authentication-session, and Custom Tab lifecycles expose different signals. Use the
reason list on your platform's SDK page rather than a shared enum.
Reconcile authentication no_proof before starting OTP
no_proof denies access from the relay; it does not prove the ceremony failed. The usual cause
is an omitted returnTarget — the ceremony succeeded and was billed, but the proof was never
released to an unsigned target, so every authentication reports no_proof. A rarer cause is a
settled, billed verification whose HTTP response was lost, leaving the recovery relay without a
resultToken. In both cases the sign-in may have genuinely happened: check /result or correlate
the configured backend callback before starting a second OTP flow. For enrollment, no_proof is advisory: enrollment
may have succeeded, and the next passkey sign-in confirms the credential.
Platform SDKs
Each SDK page covers only its launcher, return channel, and platform-specific outcomes. Keep the API key and resultToken verifier on your backend.