📘 Public beta · Endpoints are stable; OpenAPI specs and SDKs ship monthly. See changelog →
Guides
Loan underwriting with bank statements

Loan underwriting with bank statements

How to use Bank Statement Platform's parsed ledger + Document Intelligence's NPWP/slip-gaji + AML screening to produce a decision-grade input to your credit model.

The shape

Loan applicant submits:
  - 3-6 months of bank statements (PDF, possibly password-protected)
  - NPWP card (image or PDF)
  - Recent pay slip (slip gaji)
  - Optional: collateral documents


1. POST /api/analyses/upload (Bank Statement) × N statements
   With auto_create_group=true so they consolidate

     ▼ (async) wait for "consolidation.completed" webhook

2. GET /api/consolidations/{id}/consolidated
   ← averageMonthlyInflow, affordabilityScore, anomalies


3. POST /api/documents/upload (Document Intelligence) × 1 per doc
   type=npwp / slip_gaji

     ▼ (async) wait for "document.extracted" webhooks

4. POST /api/screenings (AML)
   lane=onboarding, NIK from KTP capture or NPWP


5. Compose underwriting signal:
   - Affordability (Bank Statement)
   - Income (NPWP-declared + slip gaji + statement inflow)
   - Authenticity flags (Bank Statement anomalies, NPWP confidence)
   - Sanctions/PEP status (AML)


6. Feed to your credit model

Signals worth surfacing

From Bank Statement's consolidated response:

SignalUnderwriting use
averageMonthlyInflowRepayment-capacity baseline
affordabilityScorePre-computed normalized score
monthlyBuckets varianceIncome stability — low variance = predictable repayment
categoryBreakdown.salary vs total inflowIncome concentration — salaried vs gig workers
categoryBreakdown.loan_repayment totalExisting debt service ratio
anomalyCount + anomalies[]Possible doctored statements — investigate before approving
authenticityScore < 80Hard stop or manual review

From Document Intelligence (NPWP):

SignalUnderwriting use
npwp_numberVerify against statement's account holder
registered_atTax-history length

From slip gaji:

SignalUnderwriting use
gross_salary, net_payCross-check against statement salary credits
deductionsPPh21 deducted at source → income legitimacy

Decision matrix (example)

Signal combinationAction
Affordability ≥ 70 + authenticity ≥ 80 + AML no_matchAuto-approve up to tier-1 limit
Affordability 50-70 + authenticity ≥ 80 + AML no_matchManual underwriting
Affordability ≥ 50 + anomaliesCount > 0Anomaly review
Authenticity < 80Document review (likely decline)
AML confirmed / reviewAML compliance review
Affordability < 50Decline

Tune for your risk appetite. The Quantum Elixir scores are designed to be calibrated additions to your existing credit model, not replacements.

Wiring with Orchestration

{
  "slug": "loan-underwriting-v1",
  "spec": {
    "startNodeId": "wait_for_uploads",
    "nodes": [
      {
        "id": "wait_for_uploads",
        "type": "wait",
        "until": "${trigger.lastUploadAt + PT5M}"
      },
      {
        "id": "get_consolidated",
        "type": "service_call",
        "service": "bank-statement",
        "method": "GET",
        "path": "/api/consolidations/${trigger.consolidationGroupId}/consolidated"
      },
      {
        "id": "get_npwp",
        "type": "service_call",
        "service": "document-intelligence",
        "method": "GET",
        "path": "/api/documents/${trigger.npwpDocId}/extractions"
      },
      {
        "id": "screen_aml",
        "type": "service_call",
        "service": "aml",
        "method": "POST",
        "path": "/api/screenings",
        "body": {
          "lane": "onboarding",
          "subjectType": "individual",
          "fullName": "${trigger.fullName}",
          "nik": "${trigger.nik}",
          "externalId": "${trigger.applicationId}"
        }
      },
      {
        "id": "underwriting_decision",
        "type": "http_request",
        "method": "POST",
        "url": "${env.CREDIT_MODEL_URL}/decide",
        "body": {
          "applicationId": "${trigger.applicationId}",
          "affordabilityScore": "${step.get_consolidated.output.data.affordabilityScore}",
          "averageMonthlyInflow": "${step.get_consolidated.output.data.averageMonthlyInflow}",
          "anomalyCount": "${step.get_consolidated.output.data.anomalyCount}",
          "amlStatus": "${step.screen_aml.output.data.status}",
          "npwpVerified": "${step.get_npwp.output.data[0].fields.npwp_number != null}"
        }
      }
    ],
    "edges": [
      { "from": "wait_for_uploads", "to": "get_consolidated" },
      { "from": "get_consolidated", "to": "get_npwp" },
      { "from": "get_npwp",         "to": "screen_aml" },
      { "from": "screen_aml",       "to": "underwriting_decision" }
    ]
  }
}

Trigger this workflow when your upload-collection step completes. Subscribe to the workflow.execution.completed webhook to receive the underwriting decision.

Common pitfalls

  • Statement period gaps. Customers sometimes upload statements with month-gaps. Check monthlyBuckets[] for missing months and decline if the gap is too large (typical threshold: > 1 month).
  • Multi-account masking. A single applicant may funnel earnings across multiple accounts to hide debt service ratios. Consolidating statements across all known accounts (including spouse's) gives a fuller picture.
  • Sandbox watchlist hits during QA. The sandbox watchlist contains real public-list entries (UN-SC, OFAC, PPATK PEP). Don't seed your test customers with names like Pak Chol Ryong, Umar Patek, Vladimir Putin, or NIK 3171010101710001 (Bambang Setiawan Hartono) unless you're trying to force a confirmed hit — they'll deterministically match.