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.
/api/identity/auth/step-upWhy 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
highrisk, reference must have been captured within N minutes). - Uses risk-tier-specific thresholds (a
highrisk action requires a stricter similarity score). - Atomic — race-free, single audit row, single rate-limit bucket.
- Returns a single
allow: true | falseyou can hand directly to your decision logic.
Request
customerIdstringRequiredriskLevelenumactionLabelstring"Withdraw IDR 50.000.000 to BCA 1234567890"triggerstring"high_value_transfer"bundleobjectRequiredhmacKeystringResponse
{
"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
riskLevel | Similarity threshold (default) | Reference freshness gate | Typical use |
|---|---|---|---|
low | 0.45 | None | Display a sensitive page, change a setting |
medium | 0.45 | None | Small transfer, profile edit |
high | 0.55 | Reference 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) orapiKeyId(if API-initiated)customerIdriskLevel,trigger,actionLabellivenessVerdict,matchVerdict,similarityallow(final decision)captureBundleId(sealed-at-rest pointer)referenceEnrollmentIdorreferenceSource: "image" | "embedding"
Pull the audit log via GET /api/identity/attempts?method=face_match,face_liveness&customerId=....