Internal service · v1

Credit Bureau Fallback API

A second credit-bureau source for the SaaS, returning the same CRIF CG_RES envelope you already parse. When the incumbent provider is down, point at this base URL instead — no parser changes.

Overview

You send three identity fields; you get back a credit report in the CRIF CLIK format the SaaS already consumes. Everything upstream of this endpoint — which bureau we buy from, how we authenticate to them, what product we purchase — is handled on our side and is not part of this contract.

3
required fields
100%
score & exposure
0%
monthly trend
~5–10s
response time

Read the coverage section before you wire this in. This source fills the CRIF envelope well for identity, score, exposure and per-contract balances, but it supplies no per-contract monthly history and no enquiry footprints. If your scorecard reads CreditProfile[] or FootPrints, those arrays come back empty and any feature built on them will silently degrade.

Quick start

POST https://pefindo.milikirumah.ai/api/v1/credit-report
# minimal: the three required fields
curl -X POST https://pefindo.milikirumah.ai/api/v1/credit-report \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $CB_API_KEY" \
  -d '{
    "identity_number": "3529116903740002",
    "full_name": "TRI MURYATUN",
    "date_of_birth": "1982-12-17"
  }'
# what you get back (abridged)
{
  "CG_RES": {
    "Info": { "Status": "SS", "RequestTime": "...", "ResponseTime": "..." },
    "Response": {
      "Status": "SS",
      "RequestID": "1470453",
      "Detail": {
        "Status": "SS", "Process": "NAE", "Service": "CLIK",
        "Content": { "CB_NAE_ProductOutput": {
          "CBScore": { "CBSGlocal": { "ScoreData": {
            "@attributes": { "ScoreRaw": "624", "ScoreRange": "RRD1" } } } },
          "CreditReport": { "MatchedSubject": {...}, "ContractsHistory": {...} },
          "EnquiredData": {...}
        } }
      }
    },
    "_Coverage": { /* what this source could not fill */ }
  }
}

Every value is a string, exactly as in the incumbent's XML-derived JSON. int(x.DebitBalance) keeps working unchanged.

Authentication

Send the shared key on every request, in either header:

X-API-Key: <key>
Authorization: Bearer <key>

Keys look like cb_staging_… or cb_prod_… and are issued per consumer and per environment. Only a hash is stored server-side, so a lost key is replaced, never recovered.

  • Keep it in your secret store, not in the repo or a ticket.
  • Do not reuse a staging key in production — they are separate keys so we can revoke one without disrupting the other.
  • Every request is logged against your key id, so we can tell you exactly what your integration called and when.

Ask in #platform for a key, or to have one revoked.

Test subjects

17 identities you can call right now. They exist on the bureau's staging environment, so each call performs a real enquiry and returns a real report in roughly 5–10 seconds. Use them freely — staging carries dummy records, not production data.

Fetch the catalogue at any time:

GET /api/v1/test-subjects
curl -H "X-API-Key: $CB_API_KEY"   https://pefindo.milikirumah.ai/api/v1/test-subjects

Call /api/v1/credit-report with identity_number, full_name and date_of_birth exactly as listed — a mismatched name or date lowers the similarity score, which is deliberate so you can exercise your min_similarity handling.

Watch the double space in "A  BADJURI". It is genuinely two spaces in the bureau's record. Collapse it to one and the match drops to 75%, which trips a min_similarity: 80 gate and returns not found. That is the gate doing its job — and a useful reminder that names must be passed through verbatim, not normalised.

All 17 test subjects

Every identity below resolves against the bureau. Facilities is the total contract count, Open how many are still active, and History how many months of portfolio history the subject carries (blank means none — a common case worth handling).

Identity numberFull nameDate of birthFacilitiesOpenHistoryScoreUse it for
3009001707670007Keenan Aldebran1967-07-1770523—591 RRD3Largest portfolio in the set. Test payload size (~6 MB) and per-contract iteration.
3009001606700006Erlangga Rasyid1970-06-167023124 mo589 RRD3Large portfolio that also carries 24 months of portfolio history.
3009001909850009Makaila Kaysha Rafani1985-09-197013924 mo583 RRD3Large portfolio with 24 months of history. Reports for this subject sometimes stall upstream -- useful for exercising your 503 and retry handling.
3009002010920010Naisha Azmya Sakhi1992-10-206953324 mo600 RRD2Large portfolio with 24 months of history and the best score of the big files.
3009001808780008Kiano Zeva Mahadi1978-08-1867730—588 RRD3Large portfolio, no portfolio history -- tests the history-absent path at scale.
3009001112750001Aretha Azezaty Purchila1975-12-1152427—587 RRD3Mid-large portfolio, no portfolio history.
3529116903740002TRI MURYATUN1982-12-17145824 mo624 RRD1Rich but manageable. Good default while developing.
1248085011710613R.M. Margana Sihombing1971-11-1022—575 RRD3Two facilities, scored. Small and quick.
2163342504821735EKA NASYIAH 1982-04-252224 mounscorableUnscorable, but carries 24 months of portfolio history under _Coverage -- adverse history with no score.
3101010102907204Outw One Four1990-02-0120—unscorableTwo facilities, unscorable. Thin file.
3175010102901159Armageddemon Enam Sembilan1990-02-0120—unscorableTwo facilities, unscorable. Thin file.
1211061407892035Lidya Andriani1989-07-1411—582 RRD3Single facility with a real score. Smallest useful response.
1272764407753108Prakosa Agustina1975-07-0411—200 RRE3Worst score in the set (RRE3, 181 days past due). Test the decline path.
3175010102901185Armageddemon Sembilan Lima1990-02-0110—unscorableSingle facility, unscorable. Minimal thin file.
3175010102901329Bakemon Tiga Sembilan1990-02-0110—unscorableSingle facility, unscorable. Minimal thin file.
3205150505800014NOPI MEILANI1950-01-011013 mounscorableUnscorable with 13 months of history -- a partial-history case.
3578050209770003A  BADJURI1963-03-2010—unscorableUnscorable: ScoreRaw is "" and grade is XX. Test the no-score path.
9999888877776666NOBODY1990-01-01———not foundValid format, no such subject. Returns Info.Status "NF" with an empty Content -- use it to test your not-found path.

Pick a subject by its shape, not by its exact numbers. These are live records — a score or balance can move between calls, because the bureau's own data moves. Assert on structure and on your own decision logic, not on a specific score.

Occasionally a report stalls upstream and you get a 503. That is normal operation, not a fault — see Timeouts.

Request

Required

All three are used for identity matching. A wrong name or date of birth lowers the match score and can turn a real subject into a not-found.

identity_numberrequired
KTP number. Exactly 16 digits. Rejected with 400 otherwise.
full_namerequired
Full name as printed on the KTP.
date_of_birthrequired
YYYY-MM-DD. Any other format is rejected with 400.

Recommended

provider_application_norecommended
Your application id. Returned in ApplicationCodes.ProviderApplicationNo and written to our request log, so a report can be traced back to the application that asked for it. Without it, that trace is guesswork.
min_similarityrecommended
0–100. If the best identity match scores below this, you get a not-found envelope instead of a possibly wrong person's credit file. Start at 80.
timeout_secondsrecommended
How long to wait before giving up with 503. Default 300. Set it to your failover budget — see Timeouts.

Optional — echoed back only

Copied into EnquiredData in the response, the way the incumbent echoes an enquiry back. They do not change the credit data returned. Send them if you want the response to carry your application context.

FieldDefaultNotes
identity_typeKTPKTP, NPWP or PASSPORT. The 16-digit rule applies to KTP only.
application_id—Echoed to ApplicationCodes.CBContractCode.
application_amount0Amount applied for.
contract_type_codeP99Contract type; P99 is "Others".
contract_type_descOthersLabel for the above.
request_datenowYYYY-MM-DD.
reference_datenowSubject reference date.
roleBB = Borrower.
gender—L or P.
place_of_birth—Free text.
education_status—Education code, e.g. 04.
cellphone_number—Free text.
address—Street address.
city—City code; the description is resolved for you.
district—District.
sub_district—Sub-district.
postal_code—Postal code.
countryIDCountry code.

Output options

include_coveragedefault true
Adds CG_RES._Coverage describing what this source could not fill. It sits outside the CRIF nodes, so an existing parser ignores it. Set false to omit.
crif_strictdefault false
false renders every CRIF attribute, using "" where there is no value — so your parser never hits a missing key. true reproduces the incumbent's literal conventions instead: its key order, and absent attributes omitted rather than empty. Use true only if you compare or hash the raw JSON.
derive_quality_from_dpddefault true
Fills PastDueStausCode/Desc and Maximum.WorstStatusCode/Desc from each contract's own days-past-due using the standard OJK bands (0 → Current, 1–90 → Special Attention, 91–120 → Substandard, 121–180 → Doubtful, 180+ → Loss). The source reports DPD per contract but not collectibility per contract, so this is a derivation — set false to leave the fields out.
quality_profiledefault true
Populates CreditProfile[].QualityCode with the monthly collectibility (KOL) the source reports. It is portfolio-level — the worst grade across everything the subject held that month — attached to contracts open in that month, not that contract's own grade. _Coverage.quality_source spells this out. Set false to leave CreditProfile empty.
collapse_singletonsdefault false
With crif_strict, renders a one-element array as a bare object. Unverified against the incumbent — see the caveat.

Response

A CRIF CLIK CG_RES envelope. The node tree, key names, key order and string typing all match the incumbent's. Structurally it is a drop-in; the difference is content, covered next.

NodeWhat it holds
InfoStatus and timestamps for the call.
Response.DetailPer-detail status; Service: "CLIK", Process: "NAE".
…CBScoreCredit score and risk grade.
…EnquiredDataYour request, echoed back.
…CreditReport.MatchedSubjectIdentity: name, KTP, NPWP, DOB, address / contact / employment history.
…ContractsHistory.Credit.GrantedCredit[]One entry per credit facility.
…ContractsHistory.AggregatedDataPortfolio totals: limit, balance, overdue, lender count.
_CoverageNot CRIF. What this source could not fill. Safe to ignore.

Score, and the one trap in it

A blank ScoreRaw means "not scorable", not zero. When the upstream scorecard cannot run — typically a thin file with too little payment history — ScoreRaw comes back "" with ScoreRange: "XX" and a reason in ScoreMessage. This happened on 7 of 17 test subjects, so treat it as a normal path, not an edge case.

Guard with if (!raw) { /* no score */ } before any numeric compare.

Field coverage

Measured, not estimated: the mapping was compared field by field against a real response from the incumbent across 17 subjects and 4,163 facilities. Of the 258 fields the incumbent populates, this source fills 175 (68%).

Coverage is flat across subjects from 1 to 705 facilities, because the gaps are whole missing sections rather than sparse data. What matters is which sections:

What you need it forCoverageNotes
Credit score & risk gradecompletePlus a 13-period score history in _Coverage.
Identity matchcompleteName, KTP, NPWP, DOB, mother's name, address/contact/employment history.
Total exposurecompleteLimit, balance, overdue, potential exposure, lender count.
Portfolio countscompleteActive / closed / contract counters and amounts.
Per-contract balancescompleteCredit limit, debit balance, overdue, days past due.
Per-contract counterpartycompleteLender name and code, phase, status, type, start date.
Collectibility (KOL)portfolio-levelCreditProfile[].QualityCode carries the monthly KOL, but it is the worst grade across the whole portfolio that month, not per contract.
Worst-case historypartialMax DPD and max overdue amount are real; WorstStatusCode/Desc are derived from the contract's own DPD.
Monthly repayment trendnoneNo per-contract month-by-month balances. CreditProfile repeats the contract's latest balance alongside the portfolio KOL.
Enquiry velocitynoneFootPrints empty, counts blank. No "how many enquiries in 3 months".
Collateral & guarantorsnoneCounters are 0 but the true value is unknown.
Requested / refused applicationsnoneNotGrantedCredit[] empty.

In one line: everything needed to size and price an application is present. What is missing is trend and velocity.

Two conventions that keep this safe

  1. An attribute with no value is omitted, not blank — exactly what the incumbent does. Check for presence (if (!attrs.DueDate)), not for an empty string. This matters: a blank date string parses to the Unix epoch and renders as 1 Jan 1970, and a blank numeric parses to NaN. Omitting avoids both.
  2. Absent means unknown, not zero. The FootPrints counts are absent rather than "0", because "0" would assert there were no enquiries. Do not default them to zero in your scorecard.

If your parser genuinely needs every key present, send emit_empty_attributes: true and unavailable fields come back as "" instead. That is not how the incumbent behaves, so prefer presence checks.

Machine-readable. CG_RES._Coverage.unavailable lists every unfilled node with the reason, and _Coverage.portfolio_history carries a portfolio-level monthly series (worst collectibility and worst DPD per month) as a partial substitute for CreditProfile[] — present for roughly a third of subjects.

Where codes differ

Collectibility 1–5 is the shared OJK/SLIK scale and maps exactly. ContractTypeCode and ProviderTypeCode use this source's code space, not the incumbent's — the descriptions are correct, the codes differ. If you branch on those codes rather than the descriptions, tell us and we will add a translation table.

CBContractCode is synthesised (prefixed PBK-) because this source carries no contract identifier. It is stable within one report but not across reports — do not use it as a durable key.

Status codes

CodeMeaningWhat to do
200 SSReport returned. Info.Status === "SS".Parse as CRIF.
200 NFNo match, or best match below min_similarity.Treat as a thin file. Detail.Content is "".
400Bad parameters. error.field names the offender.Fix and resend. Do not retry as-is.
401Missing or invalid API key.Check the header.
502Upstream answered but refused us.Alert the platform team. Do not retry — it will keep failing.
503Upstream timed out or errored.Retryable. Response carries error.event_id.

The 502 / 503 split is deliberate and worth honouring: 503 is worth retrying, 502 is not.

{
  "error": {
    "code": "invalid_enquiry",
    "message": "identity_number must be exactly 16 digits for KTP",
    "field": "identity_number"
  }
}

Timeouts and retries

Report generation upstream is variable — typically a few seconds, but we have observed it exceed nine minutes during an upstream incident. Plan for that rather than assuming the happy path.

  • Set timeout_seconds to your failover budget, not ours. A fallback that blocks a request for five minutes is not a fallback.
  • On 503 the body carries an event_id. The report may still be building upstream, and it has already been paid for.
  • Collect it later with GET /api/v1/report/{event_id} — 202 while still building, 200 with the full envelope once ready. This avoids being charged for a second enquiry.
# after a 503
curl -H "X-API-Key: $CB_API_KEY" \
  https://pefindo.milikirumah.ai/api/v1/report/08529317-1eed-4c62-b34b-d0774ba32b7a

Failover pattern

Because the envelope matches, failing over is a base-URL swap. The part worth thinking about is what you do with the reduced coverage.

  1. Call the incumbent. On its failure, call this endpoint.
  2. Record which source answered — _Coverage.source is "alternate-bureau" here. You will want this when explaining a decision later.
  3. Guard features that read CreditProfile[] or FootPrints. An empty array is not a clean file; it is an absent signal. Skip the feature or fall back to a coarser rule rather than scoring the blank as zero.
  4. Consider a stricter cut-off while on the fallback, since you are deciding with less information.

Sanity check before go-live: re-run a handful of applications you have already decided through this endpoint and compare the outcome. That surfaces which of your features actually depend on the missing sections — faster than reading the scorecard.

TypeScript client

// lib/creditBureau.ts
type Enquiry = {
  identity_number: string;   // 16 digits
  full_name: string;
  date_of_birth: string;     // YYYY-MM-DD
  provider_application_no?: string;
  min_similarity?: number;
  timeout_seconds?: number;
};

export class BureauUnavailable extends Error {
  constructor(msg: string, readonly eventId?: string) { super(msg); }
}

export async function fetchCreditReport(enquiry: Enquiry) {
  const res = await fetch(`${process.env.CB_FALLBACK_URL}/api/v1/credit-report`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-API-Key": process.env.CB_API_KEY!,
    },
    body: JSON.stringify({ min_similarity: 80, timeout_seconds: 45, ...enquiry }),
  });

  if (res.status === 400) {
    const { error } = await res.json();
    throw new Error(`bad enquiry (${error.field}): ${error.message}`);
  }
  if (res.status === 503) {
    const { error } = await res.json();
    // retryable; event_id collects the report without paying again
    throw new BureauUnavailable(error.message, error.event_id);
  }
  if (!res.ok) throw new Error(`bureau error ${res.status}`);

  const body = await res.json();
  const cg = body.CG_RES;

  if (cg.Info.Status !== "SS") return { found: false as const };

  const out = cg.Response.Detail.Content.CB_NAE_ProductOutput;
  const score = out.CBScore.CBSGlocal.ScoreData?.["@attributes"];

  return {
    found: true as const,
    // blank ScoreRaw means "not scorable" -- never coerce it to 0
    score: score?.ScoreRaw ? Number(score.ScoreRaw) : null,
    riskGrade: score?.ScoreRange ?? null,
    contracts: out.CreditReport.ContractsHistory.Credit.GrantedCredit,
    totals: out.CreditReport.ContractsHistory.AggregatedData["@attributes"],
    // tells you which sections came back empty
    coverage: cg._Coverage,
    raw: body,
  };
}

Other endpoints

GET /api/v1/health

Liveness plus upstream reachability. 200 when healthy, 503 when the upstream connection is down — useful as a pre-flight before routing traffic here during an incident.

GET /api/v1/schema

This request contract as JSON. Authoritative if it ever disagrees with this page.

GET /api/v1/report/{event_id}

Collect a report that timed out. 202 while still building, 200 with the envelope once ready.

Known caveat: single-element arrays

XML-to-JSON converters usually collapse a single repeated element into a bare object rather than a one-element array. Every array in the reference response we hold has two or more items, so we cannot tell what the incumbent emits for a subject with exactly one contract — which is precisely the thin-file case.

We always emit arrays. If you have a real incumbent response for a single-contract subject, send it over and we will settle this; until then, code defensively with Array.isArray().