📘 Public beta · Endpoints are stable; OpenAPI specs and SDKs ship monthly. See changelog →
Products
Identity Platform
Continuous step-up auth

Continuous step-up authentication

The single endpoint you call when a customer is about to do something sensitive — a large transfer, a password change, a new-device login, a withdraw-to-bank.

It bundles liveness + face match into one atomic decision, with risk-tiered thresholds.

POST/api/identity/auth/step-up
Auth · API keyScope · verifications:view

Why a separate endpoint?

You could call POST /api/identity/face/liveness then POST /api/identity/face/match and stitch the result yourself. Don't. The combined endpoint:

  • Enforces freshness on the reference (e.g. for high risk, reference must have been captured within N minutes).
  • Uses risk-tier-specific thresholds (a high risk action requires a stricter similarity score).
  • Atomic — race-free, single audit row, single rate-limit bucket.
  • Returns a single allow: true | false you can hand directly to your decision logic.

Request

customerIdstringRequired
CUID of the customer.
riskLevelenum
"low" · "medium" · "high". Default "low". Controls thresholds + freshness gate.
actionLabelstring
Human-readable description of what is being authorized. Appears in audit log.
Example: "Withdraw IDR 50.000.000 to BCA 1234567890"
triggerstring
Machine-readable trigger code (alphanumeric, dots, dashes, underscores).
Example: "high_value_transfer"
bundleobjectRequired
Liveness capture bundle. Same shape as POST /face/liveness.
hmacKeystring
Web SDK only.

Response

{
  "allow": true,
  "liveness": {
    "verdict": "live",
    "assuranceTier": "high"
  },
  "match": {
    "verdict": "match",
    "similarity": 0.74,
    "threshold": 0.55
  },
  "verification": {
    "id": "ver_01HXY...",
    "status": "verified",
    "score": 89
  },
  "audit": {
    "riskLevel": "high",
    "trigger": "high_value_transfer",
    "actionLabel": "Withdraw IDR 50.000.000 to BCA 1234567890"
  }
}

If allow: false, look at liveness.verdict and match.verdict to know why.

Risk tiers

riskLevelSimilarity threshold (default)Reference freshness gateTypical use
low0.45NoneDisplay a sensitive page, change a setting
medium0.45NoneSmall transfer, profile edit
high0.55Reference captured within N minutes (default 60)Large transfer, withdraw, password reset

Per-org overrides for both threshold and freshness gate are configurable from the dashboard.

End-to-end example

// Backend: user just confirmed a IDR 50M outbound transfer
async function authorizeTransfer(userId: string, amountIdr: number) {
  // 1. Get a flash challenge so the SDK can capture
  const { flashChallenge } = await qe.identity.faceFlashChallenge.create({
    customerId: userId,
    steps: 5,
  });

  // 2. Hand challenge to the client (web/mobile SDK)
  //    The SDK captures + signs + posts a bundle back to your server.
  const bundle = await waitForBundleFromClient(flashChallenge);

  // 3. Step up
  const result = await qe.identity.authStepUp({
    customerId:  userId,
    riskLevel:   'high',
    actionLabel: `Withdraw IDR ${amountIdr.toLocaleString('id-ID')}`,
    trigger:     'high_value_transfer',
    bundle,
  });

  if (!result.allow) {
    throw new StepUpRejected(result.liveness, result.match);
  }
  return result.verification.id; // attach to your transfer record
}

Bring-your-own reference

If you maintain your own customer photos (e.g. from a legacy KYC system), you can match against them instead of our enrolled references.

Pass referenceImage (base64) or referenceEmbedding (512-float ArcFace vector) in the body. We compare the live probe directly against your reference. The result still goes through liveness gating.

Why client-supplied references?

This is the canonical path for banks migrating from a legacy biometric vendor. You keep your reference photos under your control, we provide the comparison engine. Many banks also use the Dukcapil photo (received via client-side Dukcapil verdict) as the canonical reference.

What it audits

Every step-up call writes an audit row with:

  • actorUserId (if dashboard-initiated) or apiKeyId (if API-initiated)
  • customerId
  • riskLevel, trigger, actionLabel
  • livenessVerdict, matchVerdict, similarity
  • allow (final decision)
  • captureBundleId (sealed-at-rest pointer)
  • referenceEnrollmentId or referenceSource: "image" | "embedding"

Pull the audit log via GET /api/identity/attempts?method=face_match,face_liveness&customerId=....