πŸ“˜ Public beta Β· Endpoints are stable; OpenAPI specs and SDKs ship monthly. See changelog β†’
Guides
End-to-end KYC onboarding

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:

  1. Idempotency. Pass externalId (your application ID) on every mutating call. Retries are then safe.
  2. State machine. Persist the customer's onboarding state per step so a crash mid-flow can resume.
  3. 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

  1. 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.
  2. 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.
  3. One face enrollment per customer is not enough. Selfies vary by lighting, age, weight. After onboarding, every successful step-up creates a step_up_promotion enrollment β€” over time the pool gets robust. Don't aggressively retire old enrollments.
  4. requires_review is 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.
  5. 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