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.

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 passkeyEnabled toggle, 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:

  1. Solve the same Proof-of-Work challenge used by the V1.2 OTP flow. Obtain a Turnstile token when the pipeline requires one.
  2. Call POST /api/v1.2/transactions/passkey/auth-options.
  3. On 200, open the hosted ceremony with the returned ceremonyToken.
  4. On 404 with code: "NO_PASSKEY", continue with your existing OTP send and verify flow.
  5. On 403 with code: "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-specific 429 responses, honor retryAfter and 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.

Availability branch

POST
/auth-options
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
MethodPathAuthorizationPurpose
POST/auth-optionsAPI key + Shield gateStart authentication and mint an auth ceremony token.
POST/auth-verifyAuth ceremony tokenVerify the assertion, settle billing, and return proof.
POST/register-optionsEnrollment tokenValidate the OTP enrollment gate and mint an enroll ceremony token.
POST/register-verifyEnroll ceremony tokenVerify attestation and store the credential.
GET/ceremonyEnrollment or ceremony tokenResolve hosted-page purpose, branding, return target, and auth options.
GET/resultAuth ceremony tokenRead an authentication state when the relay is lost.

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: origin for a web postMessage, url for 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 the url is that app's deep link — <callbackScheme>://akedly-passkey. An HTTP(S) url that is not on the pinned origin is silently dropped. origin is capped at 256 characters and url at 2048; anything longer is dropped without an error.

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

POST
/auth-options
{
  "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

JSON
200 Success
{
  "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.id must 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 transactionID and status=Successful, or null when not configured.

  • Name
    data.resultToken
    Type
    string | null
    Description

    HMAC-signed proof for offline verification on your backend. null when the account has no APIKey to sign with.

  • Name
    message
    Type
    string
    Description

    "Passkey verified successfully".

Request

POST
/auth-verify
{
  "ceremonyToken": "pk1.<encrypted-token>",
  "authResp": {
    "id": "<credential-id>",
    "rawId": "<credential-id>",
    "response": {
      "authenticatorData": "<base64url>",
      "clientDataJSON": "<base64url>",
      "signature": "<base64url>",
      "userHandle": null
    },
    "type": "public-key"
  }
}

Response

JSON
200 Success
{
  "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

JSON
401 or 402
{
  "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

POST
/register-options
{
  "enrollmentToken": "pk1e.<encrypted-token>",
  "returnTarget": {
    "url": "myapp://akedly-passkey"
  }
}

Response

JSON
200 Success
{
  "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.id must be present.

  • Name
    deviceLabel
    Type
    string
    Description

    Optional. Customer-facing device label stored with the credential. Stored as null when 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 transactionId is the OTP TransactionReq._id that authorized enrollment. null when the account has no APIKey to sign with.

  • Name
    message
    Type
    string
    Description

    "Passkey enrolled successfully".

Request

POST
/register-verify
{
  "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

JSON
200 Success
{
  "status": "success",
  "data": {
    "enrolled": true,
    "credentialId": "<credential-id>",
    "deviceLabel": "Sara's phone",
    "resultToken": "pkrt1.<payload>.<hmac>"
  },
  "message": "Passkey enrolled successfully"
}

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, and theme.

  • 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

GET
/ceremony?token=...
{
  "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

    true only after the auth request is both Verified and 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

GET
/result?token=...
{
  "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.

StatusCodeEndpoints
400MISSING_REQUIRED_FIELDSauth-options, auth-verify, register-verify
401PASSKEY_TOKEN_INVALIDToken-authorized endpoints
401PASSKEY_TOKEN_PURPOSE_MISMATCHauth-verify, register-verify, result
410PASSKEY_TOKEN_EXPIREDToken-authorized endpoints
409PASSKEY_TOKEN_REPLAYEDauth-verify, register-verify
403PASSKEY_DISABLEDauth-options, auth-verify, register-options, register-verify, ceremony
404NO_PASSKEYauth-options, auth ceremony
404PIPELINE_NOT_FOUNDregister-options, auth-verify, register-verify, ceremony
403ENROLL_GATE_FAILEDregister-options
409ALREADY_ENROLLEDregister-options, register-verify
400PASSKEY_REGISTRATION_FAILEDregister-options, register-verify
401PASSKEY_AUTH_FAILEDauth-verify
402INSUFFICIENT_QUOTA, BILLING_FAILEDauth-verify
503PASSKEY_CEREMONY_SECRET_MISSINGEndpoints that parse or mint passkey tokens
500PASSKEY_V12_FAILEDController fallback for an unclassified failure

POST /auth-options also inherits the V1.2 pipeline and Shield middleware:

StatusCodes
400MISSING_REQUIRED_FIELDS, POW_SOLUTION_MISSING, POW_INVALID_TOKEN, POW_INVALID_SIGNATURE, POW_PIPELINE_MISMATCH, POW_INVALID_SOLUTION, TURNSTILE_TOKEN_MISSING
401INVALID_API_KEY
403PIPELINE_OWNERSHIP_MISMATCH, UTILITY_PIPELINE_ON_OTP_ENDPOINT, INVALID_API_VERSION, PIPELINE_INACTIVE, TURNSTILE_VERIFICATION_FAILED
404PIPELINE_NOT_FOUND
409POW_CHALLENGE_REPLAYED, POW_DIFFICULTY_STALE
410POW_CHALLENGE_EXPIRED
429PASSKEY_RATE_LIMIT_PHONENUMBER_PERMINUTE, PASSKEY_RATE_LIMIT_PHONENUMBER_PERHOUR, PASSKEY_RATE_LIMIT_PHONENUMBER_PERDAY
500POW_VERIFICATION_FAILED, POW_DIFFICULTY_CALCULATION_FAILED, TURNSTILE_SECRET_KEY_MISSING
502TURNSTILE_API_ERROR
503CIRCUIT_BREAKER_OPEN, CIRCUIT_BREAKER_TRIGGERED
504TURNSTILE_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: true alone.

  • Name
    resultToken
    Type
    proof
    Description

    pkrt1.&lt;payload&gt;.&lt;hmac&gt;, signed with your account API key. Send it to your backend, verify it offline, require verified: true and purpose: "auth", check expiry, confirm pipelineId is the pipeline you configured, and bind transactionId to the sign-in you started.

  • Name
    backendCallbackURL
    Type
    server callback
    Description

    Also fires when configured. Offline resultToken verification 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

POST
pipeline.backendCallbackURL
{
  "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.

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
}

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.

Was this page helpful?