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}
{"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}
{"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", ...]}
{"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}
{"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)
{"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)
{"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"}
{"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}
{"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:
{"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).