End-to-end KYC onboarding
A complete recipe for verifying a new Indonesian customer from KTP capture to "ready to transact." Wires Identity β AML β (optionally) Anti-Fraud into a single onboarding pipeline.
The shape
Customer fills onboarding form
β
βΌ
1. POST /api/customers (Identity)
β
βΌ
2. POST /api/identity/document/ktp/challenge (Identity)
β SDK uses challenge to capture KTP
βΌ
3. POST /api/identity/document/ktp/capture (Identity)
β
ββ verdict = passed β step 4
ββ verdict = requires_review β human review queue β step 4 on approve
ββ verdict = failed β decline
β
βΌ
4. POST /api/identity/face/flash-challenge (Identity)
β SDK captures live selfie
βΌ
5. POST /api/identity/auth/step-up (Identity)
β atomic liveness + match
ββ allow = true β step 6
ββ allow = false β decline (or retry once)
β
βΌ
6. POST /api/identity/nik/dukcapil (Identity) [optional β if you have PKS Dukcapil]
β
βΌ
7. POST /api/screenings (AML)
β lane = onboarding
ββ status = confirmed β human review queue (sanctions hit)
ββ status = review β human review queue (review-band match)
ββ status = no_match β step 8
β
βΌ
8. POST /api/evaluate (Anti-Fraud) [optional β if you have anti-fraud product]
β lane = onboarding
ββ decision = allow β onboarding complete, customer ready
ββ decision = review β human review queue (synthetic identity suspicions)
ββ decision = block β decline
β
βΌ
Onboarding complete Β· kycLevel = premium (if Dukcapil) or standard (without)When to use Orchestration
Wire steps 2β8 as an Orchestration workflow. The workflow becomes your single audit-grade record of the onboarding decision β every state transition logged, every retry handled, human approval gates explicit.
See the full workflow spec in the Orchestration Quickstart β
When to roll your own
If you prefer to handle the orchestration yourself (you have existing workflow infrastructure, or you want fine-grained control), call each endpoint sequentially. Three things to get right:
- Idempotency. Pass
externalId(your application ID) on every mutating call. Retries are then safe. - State machine. Persist the customer's onboarding state per step so a crash mid-flow can resume.
- Audit trail. You're now responsible for cross-step audit logs. Consider exporting to Orchestration's audit log via service connection even if you don't use its workflow runner.
Code: minimum-viable backend
import { QuantumElixir } from '@quantum-elixir/sdk';
const qe = new QuantumElixir({ apiKey: process.env.QE_API_KEY! });
async function onboard(input: OnboardingForm): Promise<OnboardingResult> {
// 1. Create customer
const { data: customer } = await qe.identity.customers.create({
fullName: input.fullName,
externalRef: input.applicationId,
});
// 2-3. KTP capture (SDK does this; here we assume the bundle arrived from client)
const { ktpBundle, faceEnrolment } = await qe.identity.document.ktp.capture({
customerId: customer.id,
bundle: input.ktpBundle,
hmacKey: input.hmacKey,
});
if (ktpBundle.verdict === 'failed') {
return decline(customer.id, 'ktp_failed');
}
if (ktpBundle.verdict === 'requires_review') {
await queueForHumanReview(customer.id, 'ktp_review');
return pending(customer.id);
}
// 4-5. Live selfie + match
const stepUp = await qe.identity.authStepUp({
customerId: customer.id,
riskLevel: 'medium',
actionLabel: 'Initial onboarding biometric',
trigger: 'onboarding',
bundle: input.faceBundle,
});
if (!stepUp.allow) {
return decline(customer.id, 'biometric_mismatch');
}
// 6. (Optional) Dukcapil verdict ingest
if (input.dukcapilVerdict) {
await qe.identity.dukcapil.ingest({
customerId: customer.id,
valid: input.dukcapilVerdict.valid,
photo: input.dukcapilVerdict.photo,
raw: input.dukcapilVerdict.raw,
});
}
// 7. AML screening
const screening = await qe.aml.screenings.create({
lane: 'onboarding',
customerId: customer.id,
subjectType: 'individual',
fullName: input.fullName,
nik: ktpBundle.ocr.nik,
nationality: 'ID',
externalId: input.applicationId,
});
if (screening.status === 'confirmed' || screening.status === 'review') {
await queueForHumanReview(customer.id, 'aml_review');
return pending(customer.id);
}
// 8. Anti-fraud onboarding evaluate
const fraud = await qe.antiFraud.evaluate({
customerId: customer.id,
lane: 'onboarding',
externalId: input.applicationId,
sessionToken: input.deviceSessionToken,
data: {
fullName: input.fullName,
email: input.email,
phone: input.phone,
ipAddress: input.clientIp,
},
});
if (fraud.decision === 'block') {
return decline(customer.id, 'fraud_block');
}
if (fraud.decision === 'review') {
await queueForHumanReview(customer.id, 'fraud_review');
return pending(customer.id);
}
return success(customer.id);
}Things that bite people
Lessons learned, all from real customer integrations
- NIK β name match doesn't mean fraud. Indonesian patronymic naming + spelling variations mean OCR-extracted name often differs from form-entered name. Compare loosely; rely on NIK + DOB for identity, not name.
- Dukcapil isn't always available. Their service has planned + unplanned outages. Plan a fallback: KTP capture only (tier
standard) when Dukcapil is unreachable, with a queued re-attempt when service returns. - One face enrollment per customer is not enough. Selfies vary by lighting, age, weight. After onboarding, every successful step-up creates a
step_up_promotionenrollment β over time the pool gets robust. Don't aggressively retire old enrollments. requires_reviewis not a soft failure. Treat it as an explicit hold state in your UX, not a silent retry. The customer is in limbo until analyst signs off β surface that clearly.- AML screening on a fresh customer with no NIK is noisy. Get NIK from KTP capture before screening, not from the form, for fewer false positives.
Next
- Loan underwriting β β extend this flow with bank-statement parsing
- Step-up auth β β reusing the face-match step on every transaction