Credora APIs

Mock external APIs#

All three are FastAPI services. Every /v1/* call needs the header X-API-Key: <MOCK_API_TOKEN>; a missing or wrong key gets 401 {"detail":"missing or invalid X-API-Key"}. Each service also serves GET /health (no key) and an OpenAPI page at /docs. Before the first generation, or while the mock database is down, data endpoints and /status return 503.

Bulk endpoints take up to 5,000 keys per call; duplicates are ignored and results follow the request order. Exasol UDFs should prefer them (one call per few hundred keys) to thousands of single calls.

Service Port in Docker network Path prefix through Caddy
mock CIBIL cibil:8101 /mock/cibil/
mock GSTN gstn:8102 /mock/gstn/
mock AA aa:8103 /mock/aa/

Exasol reaches them on the app host's private IP at ports 8101–8103 (open to the VPC only).

Mock CIBIL#

GET /v1/consumer/{pan}

json
{"pan":"ABCPM1234K","name":"Rohit Mehta","dob":"1979-04-12","score":782,"report_date":"2026-10-31",
 "enquiries_6m":1,"enquiries_12m":2,
 "accounts":[{"lender":"HDFC Bank","acct_type":"Home Loan","opened_on":"2018-06-01","sanctioned":4500000.0,
              "balance":2100000.0,"overdue":0.0,"status":"ACTIVE","dpd_36m":[0,0,0,30,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}

404 if the PAN is unknown.

GET /v1/commercial/{pan}

json
{"pan":"AAACS1234K","name":"Shree Ganesh Polymers Pvt Ltd","cmr_rank":3,"report_date":"2026-10-31",
 "total_exposure":32000000.0,"overdue":0.0,"suit_filed":false,"wilful_default":false,"facilities":4}

POST /v1/consumer/bulk body {"pans":["ABCPM1234K", ...]}

json
{"reports":[{...same shape as GET /v1/consumer/{pan}...}],"not_found":["ZZZZZ9999Z"]}

POST /v1/commercial/bulk body {"pans":[...]} returns {"reports":[{...same shape as GET /v1/commercial/{pan}...}],"not_found":[...]}

GET /status (no key): {"generated_at":"...","seed":123,"consumer_reports":3511,"commercial_reports":1000}

Mock GSTN#

GET /v1/gstin/{gstin}

json
{"gstin":"27AAACS1234K1Z5","legal_name":"Shree Ganesh Polymers Pvt Ltd","pan":"AAACS1234K","state":"Maharashtra",
 "registered_on":"2019-04-01","status":"ACTIVE","years_of_filing":7.5,"stage":"Mid-level"}

GET /v1/gstin/{gstin}/returns?from=YYYY-MM&to=YYYY-MM (default: all)

json
{"gstin":"27AAACS1234K1Z5","returns":[{"period":"2026-09","gstr1_turnover":5230000.0,"gstr3b_tax_paid":941400.0,
 "due_date":"2026-10-20","filed_on":"2026-10-18","delay_days":0}]}

Returns are newest first. For a return that was not filed, filed_on, delay_days, gstr1_turnover and gstr3b_tax_paid are null. delay_days = max(0, filed_on − due_date). years_of_filing = number of periods / 12; stage is New below 5 years, Mid-level below 8, else Stable. 404 if the GSTIN is unknown; 422 if from or to is not YYYY-MM.

POST /v1/returns/bulk body {"gstins":[...], "from":"YYYY-MM", "to":"YYYY-MM"} (from and to optional)

json
{"items":[{"gstin":"27AAACS1234K1Z5","registration":{...same shape as GET /v1/gstin/{gstin}...},
           "returns":[{...same shape as above...}]}],"not_found":[...]}

GET /status (no key): {"generated_at":"...","registrations":1000,"filings":61234}

Mock Account Aggregator#

POST /v1/consent body {"pan":"ABCPM1234K","purpose":"Loan underwriting"}

json
{"consent_id":"7a1c...","pan":"ABCPM1234K","status":"ACTIVE","created_at":"...","expires_at":"..."}

Consent is approved at once (the mock customer always says yes). It expires after 24 hours.

GET /v1/fi/{consent_id}

json
{"consent_id":"7a1c...","pan":"ABCPM1234K","accounts":[
  {"aa_account_id":"AA00000123","fip":"HDFC Bank","acct_type":"SAVINGS","masked_no":"XXXXXX4521",
   "opened_on":"2015-02-11","current_balance":412500.0,
   "transactions":[{"txn_date":"2026-10-28","dr_cr":"C","amount":85000.0,"narration":"SALARY/NEFT","balance_after":412500.0}]}]}

Accounts are ordered by id; transactions are newest first. 404 if the consent id is unknown, 403 if the consent expired. A PAN with no other-bank accounts still gets a consent, with an empty accounts list.

POST /v1/fi/bulk body {"pans":[...], "purpose":"Loan underwriting"} (purpose optional). Creates one consent per PAN and returns the data at once:

json
{"items":[{"pan":"ABCPM1234K","consent_id":"7a1c...","accounts":[{...same shape as in GET /v1/fi/{consent_id}...}]}]}

GET /status (no key): {"generated_at":"...","seed":123,"accounts":5210,"transactions":412345,"consents":17}

Mock data generator (generator:8100, internal only)#

Method and path Purpose
POST /generate {"seed": int or null} clears and regenerates everything; 202 {job_id, seed}; 409 while a generation is running
GET /jobs/{job_id} {job_id, kind:"generate", status, progress, steps:[{name,status,detail}], started_at, finished_at, error, seed, counts, timings}
GET /last last finished job: {job_id, seed, status, started_at, finished_at, seconds, counts:{table: rows}, timings:{step: seconds}, error}; 404 if none
GET /health liveness

Steps: clear, synthesize, oracle, mysql, postgres, mockdb, truth_export. The same seed gives the same data on the same day (dates are relative to the generation date).

Credora backend API (/api, FastAPI)#

Login returns a bearer token. Each user has one role: admin, datasci, loanops or rm. Every response that carries data includes a source field naming where it came from:

source value Shown in the UI as
bank.oracle Sapphire Bank · Oracle (core banking)
bank.mysql Sapphire Bank · MySQL (customer master)
bank.postgres Sapphire Bank · PostgreSQL (loans and CRM)
api.cibil API · CIBIL (mock)
api.gstn API · GSTN (mock)
api.aa API · Account Aggregator (mock)
exasol Exasol result
Method and path Roles Purpose
POST /api/auth/login {username,password} all {token, user:{username, role, display_name}}
GET /api/me all current user
GET /api/overview all counts, last generation, last analysis, decision mix, source health
POST /api/admin/generate {seed?:int} admin clears everything and generates new data; returns {job_id}
GET /api/jobs/{job_id} all {job_id, kind, status:"running"|"done"|"failed", progress:0..100, steps:[{name,status,detail}], started_at, finished_at, error}
GET /api/admin/sources admin, datasci per source: {source, label, kind:"bank"|"api"|"exasol", status:"up"|"down", tables:[{name, rows}], latency_ms}
GET /api/admin/audit?limit= admin who viewed or changed what
GET /api/admin/purge-log admin purge evidence
GET /api/companies?q=&stage=&decision=&page=&size= all (RM: own companies) list rows {cif_id, legal_name, industry, city, stage, partners, rm_id, decision, rank_no, offer_amount}
GET /api/companies/{cif_id}/tree all (scope by role) tree of nodes {id, label, sublabel, source, kind, children:[...]}
GET /api/nodes/{node_id} all (scope by role) records behind one tree node {node_id, title, source, columns:[...], rows:[[...]], meta:{}}
POST /api/analysis/run admin, loanops runs CREDORA.RUN_ANALYSIS() in Exasol; returns {job_id}
GET /api/runs all analysis runs with timings and counts
GET /api/decisions?decision=&grade=&page= admin, datasci, loanops decision rows
GET /api/top50 all top 50 from PREFERRING
GET /api/companies/{cif_id}/report admin, datasci, loanops, rm Exasol report: decision, grade, pd, rank, offer, limit, reasons (feature attribution), features, steps (each with the SQL that ran), sources used
POST /api/ml/train datasci trains a new model version; returns {job_id}
GET /api/ml/models datasci, admin versions with AUC, Gini, feature importance, active flag
POST /api/ml/activate {version} datasci switches the active model
POST /api/whatif {hurdle_pct?, eblr_pct?, max_gst_share_pct?} datasci counts of approve/refer/decline before and after
GET /api/offers?stage= rm, loanops, admin CRM pipeline
POST /api/offers/{offer_id}/quote {amount, roi, tenure} rm Exasol checks the terms: {within_limit, raroc, emi, reasons}
POST /api/offers/{offer_id}/propose {amount, roi, tenure, note} rm records agreed terms; above the limit it goes to ESCALATED
POST /api/offers/{offer_id}/verify / sanction / reject {note} loanops maker-checker
GET /api/offers/{offer_id}/events rm, loanops, admin negotiation trail
GET /api/ai/status all whether the AI features are on
POST /api/ai/ask {question} all Ask Credora: Claude answers through the Exasol MCP server as read-only CREDORA_MCP; returns the answer and every tool call
POST /api/companies/{cif_id}/memo admin, datasci, loanops, rm writes a credit memo for the company (inside Exasol by CREDORA.CREDIT_MEMO when possible) and stores it
GET /api/companies/{cif_id}/memo admin, datasci, loanops, rm the latest stored credit memo for the current run, or 404

Backend response shapes#

The shapes follow frontend/src/api/types.ts; where this document and types.ts differed, types.ts won. Reads for a signed-in person run as that role's Exasol user (CREDORA_ADMIN, CREDORA_DATASCI, CREDORA_LOANOPS, CREDORA_RM), so Exasol enforces access; a query Exasol refuses comes back as 403 {"detail":"Exasol denied access for your role: ..."}. Analysis, training, the CRM export, model activation and the purge run as CREDORA_SVC. Times are ISO 8601 in UTC (...Z). Errors are {"detail": "..."}.

Sign-in. POST /api/auth/login returns {token, user:{username, role, display_name, rm_id?}}. The token is an HS256 JWT signed with APP_SECRET, valid 12 hours; send it as Authorization: Bearer <token>. Usernames are the role names. The rm login acts as one relationship manager, APP_RM_ID (default RM001), and sees only companies and offers whose rm_id matches.

Current run. Lists and reports use the newest analysis run that finished without a failed step and after the last data generation (GET generator/last). Until analysis runs on fresh data, reports return 404 and decision fields are null.

Endpoint Response
GET /api/me User
GET /api/overview {counts:{companies, partners, applications, accounts}, last_generation:{seed, generated_at, job_id}\|null, last_analysis:{run_id, finished_at, duration_ms, model_version}\|null, decision_mix:{APPROVE, REFER, DECLINE}\|null, sources:[SourceHealth]}. RM: counts and mix for the RM's book; accounts = the book's company current accounts. Source health is a system probe run as the service account.
GET /api/jobs/{id} {job_id, kind:"generate"\|"analysis"\|"train", status, progress, steps:[{name, status, detail}], started_at, finished_at, error, ...}; analysis adds run_id, counts, crm; training adds version, auc, gini; generation adds seed. Jobs live in the API process's memory.
GET /api/admin/sources [{source, label, kind, status, tables:[{name, rows}], latency_ms}]: bank tables counted through VS_CBS / VS_CIF / VS_LOS (plus crm.offer, crm.offer_event), API counts from each mock's /status, Exasol counts of CREDORA.*.
GET /api/admin/audit?limit= [{at, username, role, action, target, detail}] from crm.audit_event (LOGIN, VIEW_TREE, VIEW_NODE, VIEW_REPORT, RUN_ANALYSIS, GENERATE, TRAIN_MODEL, ACTIVATE_MODEL, WHAT_IF, PROPOSE, VERIFY, SANCTION, REJECT, ASK_CREDORA, CREDIT_MEMO).
GET /api/admin/purge-log [{purged_at, table_name, source, rows, sha256, run_id, purge_due}] from CREDORA.PURGE_LOG. The log keeps no due time, so purge_due equals purged_at. The backend runs CREDORA.PURGE_EXPIRED() every PURGE_INTERVAL_MIN minutes (default 15, 0 turns it off).
GET /api/companies {items:[CompanyRow], total, page, size}. stage is "" when unknown: before analysis, and for RMs unless V_COMPANY_MASKED has a GST_STAGE column.
GET /api/companies/{cif}/tree TreeNode. Node ids: C…, C…:account, C…:txns, C…:application, C…:gst, C…:gst:<year>, C…:cibil, C…:partner:<id>, …:cibil, …:sapphire, …:aa:<aa_account_id>, C…:report. API nodes appear only for admin and datasci. RMs get the company, application and report nodes, read from CREDORA.V_COMPANY_MASKED (tagged exasol).
GET /api/nodes/{id} {node_id, title, source, columns, rows, meta}; meta has table, database, via (the virtual schema object) for bank records and endpoint for API records.
GET /api/runs [Run], newest first, failed runs last. started_by comes from the audit log (system if unknown).
GET /api/decisions {items:[DecisionRow + hard_fails], total, page, size, run_id}, ordered by rank; raroc is RAROC_OFFER.
GET /api/top50 [Top50Row + skyline_layer, rm_id]; stage is the CRM stage.
GET /api/companies/{cif}/report Report plus hard_fails:[...] and raroc_floor. reasons[].contribution is the feature's change to PD in percentage points (from SCORES.CONTRIBUTIONS, PD with the feature against PD at the portfolio median); positive raises risk; up to 4 drivers each way. steps are the 11 RUN_ANALYSIS steps with that run's RUN_LOG duration and the real script text. For RMs features is empty and sources_used is ["exasol"], because CREDORA_RM may not read CREDORA.FEATURES.
GET /api/ml/models [Model + default_rate, model_path]; trained_by from the audit log.
POST /api/ml/activate {version, active:true}
POST /api/whatif {params, baseline, before, after, run_id}: one Exasol query re-runs the decision step of RUN_ANALYSIS over the current run's stored features and scores with the overridden CONFIG values; nothing is written.
GET /api/offers?stage=A,B&include_declined= [Offer] (crm.offer with legal_name, rm_id), by rank. Offers whose Exasol decision was DECLINE are left out unless include_declined=true.
POST /api/offers/{id}/quote {within_limit, raroc, emi, reasons} computed in Exasol from CREDORA.CONFIG and the run's decision row. EMI: TL amortising, WC interest only. Within limit means amount ≤ max, ROI ≥ floor, tenure ≤ max and RAROC ≥ hurdle.
POST /api/offers/{id}/propose Offer: within limit goes to NEGOTIATING, otherwise ESCALATED with a system ESCALATE event. Only from OFFERED, NEGOTIATING or ESCALATED.
POST /api/offers/{id}/verify / sanction / reject Offer. Note required. verify: NEGOTIATING or ESCALATED with proposed terms → VERIFIED; sanction: VERIFIED → SANCTIONED; reject: any open stage → DECLINED.
GET /api/offers/{id}/events [OfferEvent], oldest first
GET /api/ai/status {enabled, provider, model, assistant}. enabled is true when AI_PROVIDER is anthropic (with ANTHROPIC_API_KEY) or bedrock, and AI_MODEL is set; assistant also needs EXA_MCP_PASSWORD.
POST /api/ai/ask {answer, steps:[{tool, input, result_preview, is_error}], model, run_id}. The backend starts the official Exasol MCP server (exasol-mcp-server 2.3.0) over stdio, logged in as CREDORA_MCP (role R_MCP: SELECT on DECISIONS, SCORES, FEATURES, RUN_LOG, MODEL_REGISTRY, CONFIG, V_COMPANY_MASKED; read queries and table summaries only, no writes). Its tools are passed to Claude, which may make up to 8 tool calls; steps lists them in order, with the SQL in input.query for execute_exasol_query and the first 1,200 characters of each result. run_id is the current run, given to Claude so it filters to it. Every role may ask; RMs therefore see results for the whole portfolio, never raw outside data. 503 when AI is off or the MCP server cannot start; 502 when the AI provider fails; 422 for an empty question or one over 1,000 characters.
POST /api/companies/{cif}/memo {memo, model, engine:"exasol-udf"\|"backend", created_at, run_id}. The facts are built from the same data as the report (decision, policy rules failed, grade, PD, rank, indicated need vs pre-approved offer vs negotiation limit, RAROC, top reasons, key features, GST stage), read as the caller's role. With AI_PROVIDER=anthropic and the Exasol CONNECTION AI_LLM present, the memo is written inside Exasol by SELECT CREDORA.CREDIT_MEMO('AI_LLM', <model>, <cif>, <facts JSON>) as CREDORA_SVC (engine:"exasol-udf"); otherwise the backend calls the model (engine:"backend", e.g. Bedrock). Stored in CREDORA.MEMOS as CREDORA_SVC; MODEL holds <model>\|<engine>. 503 when AI is off.
GET /api/companies/{cif}/memo the latest stored memo for the company in the current run, same shape, or 404. RMs only for their own book.

CRM export. After each successful analysis run, offers are upserted by app_id: rows at OFFERED or DECLINED take the new run's terms (APPROVE and REFER become OFFERED, DECLINE becomes DECLINED) and keep their offer_id. Rows at NEGOTIATING, ESCALATED, VERIFIED or SANCTIONED keep their state. If the CRM is empty when offers are first listed, the current run is exported then. The backend adds legal_name and rm_id columns to crm.offer and creates crm.audit_event.

Backend environment. The runtime secret keys (APP_SECRET, APP_*_PASSWORD, EXASOL_HOST, EXASOL_PORT, EXA_*_PASSWORD, PG_HOST, PG_PORT, PG_USER, PG_PASSWORD, MOCK_API_TOKEN, AI_PROVIDER, AI_MODEL, ANTHROPIC_API_KEY, EXA_MCP_PASSWORD), plus GENERATOR_URL, MOCK_CIBIL_URL, MOCK_GSTN_URL, MOCK_AA_URL from compose. Optional: APP_RM_ID (RM001), PG_CRM_DB (crm), PG_SSLMODE (require), EXA_POOL_SIZE (3), TOKEN_HOURS (12), PURGE_INTERVAL_MIN (15), AWS_REGION (for AI_PROVIDER=bedrock), MCP_SERVER_CMD (command that starts the Exasol MCP server; default exasol-mcp-server on PATH, as in the Docker image, else uvx exasol-mcp-server@2.3.0).