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.
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
# 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:
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 number | Full name | Date of birth | Facilities | Open | History | Score | Use it for |
|---|---|---|---|---|---|---|---|
| 3009001707670007 | Keenan Aldebran | 1967-07-17 | 705 | 23 | — | 591 RRD3 | Largest portfolio in the set. Test payload size (~6 MB) and per-contract iteration. |
| 3009001606700006 | Erlangga Rasyid | 1970-06-16 | 702 | 31 | 24 mo | 589 RRD3 | Large portfolio that also carries 24 months of portfolio history. |
| 3009001909850009 | Makaila Kaysha Rafani | 1985-09-19 | 701 | 39 | 24 mo | 583 RRD3 | Large portfolio with 24 months of history. Reports for this subject sometimes stall upstream -- useful for exercising your 503 and retry handling. |
| 3009002010920010 | Naisha Azmya Sakhi | 1992-10-20 | 695 | 33 | 24 mo | 600 RRD2 | Large portfolio with 24 months of history and the best score of the big files. |
| 3009001808780008 | Kiano Zeva Mahadi | 1978-08-18 | 677 | 30 | — | 588 RRD3 | Large portfolio, no portfolio history -- tests the history-absent path at scale. |
| 3009001112750001 | Aretha Azezaty Purchila | 1975-12-11 | 524 | 27 | — | 587 RRD3 | Mid-large portfolio, no portfolio history. |
| 3529116903740002 | TRI MURYATUN | 1982-12-17 | 145 | 8 | 24 mo | 624 RRD1 | Rich but manageable. Good default while developing. |
| 1248085011710613 | R.M. Margana Sihombing | 1971-11-10 | 2 | 2 | — | 575 RRD3 | Two facilities, scored. Small and quick. |
| 2163342504821735 | EKA NASYIAH | 1982-04-25 | 2 | 2 | 24 mo | unscorable | Unscorable, but carries 24 months of portfolio history under _Coverage -- adverse history with no score. |
| 3101010102907204 | Outw One Four | 1990-02-01 | 2 | 0 | — | unscorable | Two facilities, unscorable. Thin file. |
| 3175010102901159 | Armageddemon Enam Sembilan | 1990-02-01 | 2 | 0 | — | unscorable | Two facilities, unscorable. Thin file. |
| 1211061407892035 | Lidya Andriani | 1989-07-14 | 1 | 1 | — | 582 RRD3 | Single facility with a real score. Smallest useful response. |
| 1272764407753108 | Prakosa Agustina | 1975-07-04 | 1 | 1 | — | 200 RRE3 | Worst score in the set (RRE3, 181 days past due). Test the decline path. |
| 3175010102901185 | Armageddemon Sembilan Lima | 1990-02-01 | 1 | 0 | — | unscorable | Single facility, unscorable. Minimal thin file. |
| 3175010102901329 | Bakemon Tiga Sembilan | 1990-02-01 | 1 | 0 | — | unscorable | Single facility, unscorable. Minimal thin file. |
| 3205150505800014 | NOPI MEILANI | 1950-01-01 | 1 | 0 | 13 mo | unscorable | Unscorable with 13 months of history -- a partial-history case. |
| 3578050209770003 | A BADJURI | 1963-03-20 | 1 | 0 | — | unscorable | Unscorable: ScoreRaw is "" and grade is XX. Test the no-score path. |
| 9999888877776666 | NOBODY | 1990-01-01 | — | — | — | not found | Valid 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
400otherwise. - full_namerequired
- Full name as printed on the KTP.
- date_of_birthrequired
YYYY-MM-DD. Any other format is rejected with400.
Recommended
- provider_application_norecommended
- Your application id. Returned in
ApplicationCodes.ProviderApplicationNoand 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. Default300. 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.
| Field | Default | Notes |
|---|---|---|
| identity_type | KTP | KTP, NPWP or PASSPORT. The 16-digit rule applies to KTP only. |
| application_id | — | Echoed to ApplicationCodes.CBContractCode. |
| application_amount | 0 | Amount applied for. |
| contract_type_code | P99 | Contract type; P99 is "Others". |
| contract_type_desc | Others | Label for the above. |
| request_date | now | YYYY-MM-DD. |
| reference_date | now | Subject reference date. |
| role | B | B = 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. |
| country | ID | Country code. |
Output options
- include_coveragedefault true
- Adds
CG_RES._Coveragedescribing what this source could not fill. It sits outside the CRIF nodes, so an existing parser ignores it. Setfalseto omit. - crif_strictdefault false
falserenders every CRIF attribute, using""where there is no value — so your parser never hits a missing key.truereproduces the incumbent's literal conventions instead: its key order, and absent attributes omitted rather than empty. Usetrueonly if you compare or hash the raw JSON.- derive_quality_from_dpddefault true
- Fills
PastDueStausCode/DescandMaximum.WorstStatusCode/Descfrom 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 — setfalseto leave the fields out. - quality_profiledefault true
- Populates
CreditProfile[].QualityCodewith 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_sourcespells this out. Setfalseto leaveCreditProfileempty. - 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.
| Node | What it holds |
|---|---|
| Info | Status and timestamps for the call. |
| Response.Detail | Per-detail status; Service: "CLIK", Process: "NAE". |
| …CBScore | Credit score and risk grade. |
| …EnquiredData | Your request, echoed back. |
| …CreditReport.MatchedSubject | Identity: name, KTP, NPWP, DOB, address / contact / employment history. |
| …ContractsHistory.Credit.GrantedCredit[] | One entry per credit facility. |
| …ContractsHistory.AggregatedData | Portfolio totals: limit, balance, overdue, lender count. |
| _Coverage | Not 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 for | Coverage | Notes |
|---|---|---|
| Credit score & risk grade | complete | Plus a 13-period score history in _Coverage. |
| Identity match | complete | Name, KTP, NPWP, DOB, mother's name, address/contact/employment history. |
| Total exposure | complete | Limit, balance, overdue, potential exposure, lender count. |
| Portfolio counts | complete | Active / closed / contract counters and amounts. |
| Per-contract balances | complete | Credit limit, debit balance, overdue, days past due. |
| Per-contract counterparty | complete | Lender name and code, phase, status, type, start date. |
| Collectibility (KOL) | portfolio-level | CreditProfile[].QualityCode carries the monthly KOL, but it is the worst grade across the whole portfolio that month, not per contract. |
| Worst-case history | partial | Max DPD and max overdue amount are real; WorstStatusCode/Desc are derived from the contract's own DPD. |
| Monthly repayment trend | none | No per-contract month-by-month balances. CreditProfile repeats the contract's latest balance alongside the portfolio KOL. |
| Enquiry velocity | none | FootPrints empty, counts blank. No "how many enquiries in 3 months". |
| Collateral & guarantors | none | Counters are 0 but the true value is unknown. |
| Requested / refused applications | none | NotGrantedCredit[] 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
- 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 toNaN. Omitting avoids both. - Absent means unknown, not zero. The
FootPrintscounts 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
| Code | Meaning | What to do |
|---|---|---|
| 200 SS | Report returned. Info.Status === "SS". | Parse as CRIF. |
| 200 NF | No match, or best match below min_similarity. | Treat as a thin file. Detail.Content is "". |
| 400 | Bad parameters. error.field names the offender. | Fix and resend. Do not retry as-is. |
| 401 | Missing or invalid API key. | Check the header. |
| 502 | Upstream answered but refused us. | Alert the platform team. Do not retry — it will keep failing. |
| 503 | Upstream 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_secondsto your failover budget, not ours. A fallback that blocks a request for five minutes is not a fallback. - On
503the body carries anevent_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}—202while still building,200with 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.
- Call the incumbent. On its failure, call this endpoint.
- Record which source answered —
_Coverage.sourceis"alternate-bureau"here. You will want this when explaining a decision later. - Guard features that read
CreditProfile[]orFootPrints. 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. - 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
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.
This request contract as JSON. Authoritative if it ever disagrees with this page.
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().