FAIR — API Integration Guide#
Endpoint-by-endpoint mapping for the FAIR mobile flow: which call to fire, in what order, with what payload, what comes back, and what to do next.
This is the flow-specific companion to API.md, which is the full reference. Where they disagree, API.md is authoritative on general behaviour and this document is authoritative on the FAIR sequence.
Two models are used, and they are not what their UI labels suggest:
| FAIR screen | API model_id |
Platform name |
|---|---|---|
| Screener Saham | model_f_combined |
Model F: Combined ML + Signals |
| Portofolio Recovery | recovery_v4 |
Model E: Portfolio Recovery |
model_f_combined is the right id even though the screen is described as
"confluence" — it is the hybrid that blends the ML probability with confluence
signals, and it is the only model exposing prob_weight and signal_penalty,
which the filter sheet uses. Model D (confluence_filter) is a different,
signal-only model and is not part of this flow.
0. What you need before you start#
Base URL https://dev.multiscreener.qomu.ai/api
API key idxs_live_xxxxxxxxxxxx (issued per app, keep it server-side)
Scopes models:read, models:run (+ usage:read if you show history)
This environment pins the analysis date to 2026-09-10#
Every run analyses 2026-09-10, whatever you send as
analysis_date. A later date (or omitting it) is silently clamped back; an earlier date is honoured, so historical testing still works.This is deliberate. The dataset is refreshed out of band and its most recent day is sometimes partial — on 2026-09-16 the broker summary covered 206 of ~835 tickers. For integration work you want results that are stable and complete, not freshest: the same request returns the same picks every time, so you can write assertions against them. 2026-09-10 is a fully healthy trading day.
The effective date is always echoed back in the run's
paramsand inresult.as_of— never assume, read it. Treat these numbers as shape-correct, not trading-accurate. The pin lifts when this environment is promoted.
Every endpoint in this document is under /api. That prefix is deliberate:
this host also serves the human-facing docs at the root, and a path should say
whether it is meant for a client or a browser. Concatenating the base URL above
with any path below gives you the full URL.
Every request carries two headers:
Authorization: Bearer idxs_live_xxxxxxxx ← identifies YOUR APP
X-End-User: nasabah_00471 ← identifies THE CUSTOMER
X-End-User must be your own stable, opaque customer id — the same value
every time for that person. Not an email, name, or account number that is itself
sensitive: @ is rejected, and the value is stored for reporting. It is
required on every model run and on both consent calls.
Never put the API key in the mobile app binary. It carries your app's full scopes and cannot be rotated per device. Call this API from your backend and have the app talk to your backend.
1. The sequence#
APP OPEN
│
├─ 1. GET /api/v1/consent ──▶ accepted?
│ │
│ ├─ false ─▶ show disclaimer ─▶ 2. POST /api/v1/consent ─▶ 201 ─┐
│ └─ true ──────────────────────────────────────────────────┤
│ ▼
├─ 3. GET /api/v1/models/model_f_combined/params (cache; drives the filter sheet)
│ GET /api/v1/models/recovery_v4/params
│
├─ SCREENER SAHAM ├─ PORTOFOLIO RECOVERY
│ 4a. POST .../model_f_combined/runs 4b. POST .../recovery_v4/runs
│ ?view=compact&wait=30 ?view=compact&wait=30
│ │ │
│ ├─ 200 ─▶ render ├─ 200 ─▶ render
│ └─ 202 ─▶ 5. GET /api/v1/jobs/{id} ──────┘ (poll every 3s)
│
└─ 6. (optional) GET /api/v1/usage/end-users/{id}/events ← "riwayat analisa saya"
2. Step-by-step#
Step 1 — Check the disclaimer gate#
Fire this on entry to the FAIR menu, before rendering either screen.
GET /api/v1/consent?document_id=fair_disclaimer
Authorization: Bearer idxs_live_xxxx
X-End-User: nasabah_00471
{
"end_user": "nasabah_00471",
"document_id": "fair_disclaimer",
"required_version": "2026-09-01",
"accepted": false,
"accepted_version": null,
"accepted_at": null
}
Next:
- accepted: true → skip to Step 3, show the screens (keep the persistent
disclaimer banner visible).
- accepted: false → show the blocking disclaimer sheet. Display the text for
required_version. Do not let the user through until Step 2 succeeds.
- 503 consent_unavailable → keep the gate closed and retry. Never treat a
failed check as acceptance.
Bumping the disclaimer text server-side changes required_version, which flips
everyone back to accepted: false automatically and re-gates them. Your app
should drive the sheet off required_version, not off a locally stored flag.
Step 2 — Record the acceptance#
Fire when the user ticks the box and taps "Setuju & Lanjutkan".
POST /api/v1/consent
Authorization: Bearer idxs_live_xxxx
X-End-User: nasabah_00471
Content-Type: application/json
{
"document_id": "fair_disclaimer",
"document_version": "2026-09-01",
"accepted": true,
"locale": "id-ID",
"client_version": "1.0.0"
}
201 Created
{
"consent_id": "cs_57ff3944c2f149a3",
"ts": 1789696014.67,
"end_user": "nasabah_00471",
"document_id": "fair_disclaimer",
"document_version": "2026-09-01",
"accepted": true
}
Next: 201 → open the gate. Anything else → keep it closed.
| Status | code |
Meaning |
|---|---|---|
| 409 | stale_document_version |
You showed old text. Re-fetch Step 1, show the current version, resubmit |
| 422 | consent_not_accepted |
You sent accepted: false — don't call this for a refusal |
| 503 | consent_write_failed |
Not recorded. Keep the gate closed and retry |
Records are append-only, so re-accepting a new version is a new POST, and the
history of what was agreed and when is preserved for audit.
Step 3 — Load the parameter schemas#
Fetch once per app session (or cache for a day) and drive the filter sheet from it, rather than hardcoding names, defaults and ranges.
GET /api/v1/models/model_f_combined/params
{
"model_id": "model_f_combined",
"params": {
"min_price": {"type":"integer","default":500,"min":100,"max":5000},
"min_daily_val": {"type":"integer","default":10000000000,"min":100000000,"max":100000000000},
"min_probability": {"type":"float","default":65,"min":30,"max":100},
"min_signals": {"type":"integer","default":2,"min":1,"max":4},
"prob_weight": {"type":"float","default":0.6,"min":0.3,"max":0.8},
"signal_penalty": {"type":"float","default":0.3,"min":0.0,"max":0.5},
"strategy": {"type":"string","default":"Inverse Signal",
"options":["Inverse Signal","Gated","Hybrid Score","Intersection","Compare All"]},
"target_gain": {"type":"float","default":0.10,"min":0.05,"max":0.30},
"stop_loss": {"type":"float","default":0.05,"min":0.02,"max":0.15},
"max_picks": {"type":"integer","default":10,"min":1,"max":20}
}
}
Mapping to the filter sheet:
| Sheet control | Parameter | Note |
|---|---|---|
| Min Price (IDR) | min_price |
Stepper, ±50 |
| Min Daily Value (B IDR) | min_daily_val |
Sheet shows billions; send raw rupiah (10,00 B → 10000000000) |
| Min Probability (%) | min_probability |
30–100 |
| Min Signals (confluence) | min_signals |
1–4. The sheet's slider starts at 0; the model's floor is 1 |
| Probability Weight | prob_weight |
0.3–0.8 |
| Signal Penalty | signal_penalty |
0.0–0.5. Only affects the default Inverse Signal strategy |
Two things the mockup should change:
- Min Signals cannot be 0. The schema floor is 1; sending 0 returns
422 invalid_request. Set the slider's minimum to 1. - Min Daily Value units. The sheet shows
10,00meaning 10 billion. The API takes rupiah, so multiply by 1e9 before sending.
recovery_v4 is much smaller:
{
"model_id": "recovery_v4",
"params": {
"analysis_date": {"type":"date","default":"today"},
"horizon": {"type":"integer","default":20,"options":[10,20,30,40]},
"positions": {"type":"array","default":[]}
}
}
horizon must be exactly one of 10/20/30/40 — the mockup's slider is continuous
10–40 and must snap to those four values, or it returns 422.
Step 4a — Run the Screener (Model F)#
Fired by "Jalankan Model F" / START.
POST /api/v1/models/model_f_combined/runs?view=compact&wait=30
Authorization: Bearer idxs_live_xxxx
X-End-User: nasabah_00471
Content-Type: application/json
{
"params": {
"min_price": 500,
"min_daily_val": 10000000000,
"min_probability": 65,
"min_signals": 2,
"prob_weight": 0.6,
"signal_penalty": 0.3,
"max_picks": 10
}
}
Two query parameters do the work:
view=compact— returns only the card fields. The defaultfullview returns ~99 raw feature columns per pick (28.8 KB vs 1.8 KB for the same run — 16× larger). Always usecompacton mobile.wait=30— holds the connection up to 30s. A warm run takes ~15s, so this usually returns the result inline and you never poll.
Response when it finished in time (200):
{
"job_id": "job_9f2c...",
"status": "succeeded",
"source": "live",
"result_available": true,
"duration_seconds": 17.55,
"end_user": "nasabah_00471",
"result": {
"model_id": "model_f_combined",
"view": "compact",
"as_of": "2026-06-11",
"pick_count": 10,
"levels": {"target_gain_pct": 10.0, "stop_loss_pct": 5.0, "entry_spread_pct": 2.0},
"picks": [
{"ticker":"ENRG","close":1380,"probability":83.9,"signals":0,
"entry_low":1380,"entry_high":1408,"stop":1311,"target":1518},
{"ticker":"ISAT","close":1895,"probability":81.6,"signals":0,
"entry_low":1895,"entry_high":1933,"stop":1800,"target":2084}
],
"model_quality": {"win_rate": 0.55, "payoff": 1.8, "candidates": 94},
"funnel": {"total_liquid": 94, "after_probability": 94, "after_signals": 94, "final": 0}
}
}
Response when it needs longer (202): go to Step 5.
{"job_id":"job_9f2c...","status":"running","result_available":false,
"poll_url":"/api/v1/jobs/job_9f2c..."}
Card field mapping:
| Mockup element | Field | Note |
|---|---|---|
tk-ticker |
ticker |
|
tk-close "Rp 2.680" |
close |
A number. Format for id-ID yourself |
prob-ring "75.1%" |
probability |
Number, one decimal |
| "N sinyal konfluensi" + dots | signals |
Integer 0–4 |
| Entry | entry_low – entry_high |
|
| Stop | stop |
= close × (1 − stop_loss) |
| Target | target |
= close × (1 + target_gain) |
| Data freshness banner | as_of |
"Menggunakan data per …" |
levels tells you the percentages behind those prices, so you can label the card
"Target +10%" without re-deriving them. These follow the target_gain /
stop_loss you sent — if you send target_gain: 0.15, target is +15%.
Step 4b — Run Portfolio Recovery (Model E)#
POST /api/v1/models/recovery_v4/runs?view=compact&wait=30
{
"params": {
"horizon": 20,
"positions": [
{"ticker": "BBCA", "pnl_input": 5.18},
{"ticker": "GOTO", "pnl_input": -41.81},
{"ticker": "MEDC", "pnl_input": -12.01}
]
}
}
Each position takes either pnl_input (percent, as the mockup's "PnL %"
column collects) or avg_price_input (your average buy price). Send one.
pnl_input: -41.81 means down 41.81%.
An empty
positionsarray fails the run — the model analyses a portfolio you hold, so there is nothing to analyse. It arrives as HTTP200with"status": "failed"anderror.message: "⚠️ No positions entered.", not as an HTTP error. Keep the "Jalankan" button disabled until at least one row is filled, so a user never spends 5 seconds to be told that.
Response (200), ~4.5s:
{
"status": "succeeded",
"result": {
"model_id": "recovery_v4",
"view": "compact",
"as_of": "2026-09-18",
"horizon_days": 20,
"summary": {"total": 3, "winners": 0, "neutral": 2, "losers": 1},
"positions": [
{
"ticker": "GOTO",
"pnl_pct": -41.81,
"current_price": 56,
"avg_price": 96.24,
"outlook": "BEARISH",
"bucket": "loser",
"reason": "Recovery unlikely: need +72%, P75 only +8%",
"restore_target": {"price": 60.35, "change_pct": 7.77},
"support_level": {"price": 51.83, "change_pct": -7.44},
"exit_window": {"day_from": 6, "day_to": 10},
"probabilities": {"up": 54.7, "down": 33.2, "neutral": 12.1},
"trajectory": {"CRASH":12.6,"DECLINE":20.6,"SIDEWAYS":12.1,"CLIMB":25.5,"RALLY":29.2}
}
]
}
}
Card field mapping:
| Mockup element | Field |
|---|---|
| Stat grid: Total / Winners / Neutral / Losers | summary.total / .winners / .neutral / .losers |
pos-ticker |
positions[].ticker |
pos-pnl "-41,81%" |
pnl_pct (number) |
pos-desc |
reason |
outlook badge (bear/neutral/bull) |
outlook — or bucket (loser/neutral/winner) for styling |
traj-bar segments |
trajectory — five states, percentages summing to ~100 |
| Restore Target "54 (+7,9%)" | restore_target.price and .change_pct |
| Support Level "46 (-7,9%)" | support_level.price and .change_pct |
| Profit Window "Day 5–10" | exit_window.day_from – .day_to |
The mockup's traj-labels currently read "Ret / Rat / PWin". Those three don't
map cleanly to anything the model returns. The closest honest equivalents are
probabilities.up / .down / .neutral, and the stacked bar itself should be
driven by trajectory. Worth settling before build.
Step 5 — Poll (only if you got 202)#
GET /api/v1/jobs/job_9f2c...
Poll every 3 seconds. Faster gains nothing. Give up after ~5 minutes.
{"status": "running", "result_available": false}
then
{"status": "succeeded", "source": "live", "result_available": true, "result": { … }}
status is one of queued, running, succeeded, failed, interrupted.
Branch on
result_available, not onstatus. After an hour the job still resolves with200andsource: "ledger", butresult_availableisfalseand you get only a summary — the picks are gone. Re-run to get them back.
Step 6 — Usage history (optional)#
If you add a "riwayat analisa" screen:
GET /api/v1/usage/end-users/nasabah_00471/events?limit=20
Returns that customer's past runs with parameters, timing and a summary of what
was picked — so you don't have to store any of it yourself. Needs usage:read.
3. The three result states#
Your flow diagram's Sukses / Kosong / Error branches map exactly:
Sukses#
status: "succeeded" and pick_count > 0 (or position_count > 0).
Render the cards.
Kosong — "tidak ada hasil"#
status: "succeeded" and pick_count: 0. This is a success, not an error —
HTTP 200, job succeeded, the filters simply matched nothing. The response carries
an empty block built for this screen:
"result": {
"pick_count": 0,
"empty": {
"reason": "no_results",
"message": "Tidak ada saham yang lolos kriteria pada tanggal analisis ini. Coba longgarkan filter.",
"funnel": {"total_liquid": 94, "after_probability": 94, "after_signals": 94, "final": 0},
"suggestions": [
{"label":"Min Signals","current":"4","suggested":"1",
"why":"cukup 1 sinyal teknikal — kandidat melebar."},
{"label":"Min Probability","current":"99%","suggested":"84%",
"why":"probability threshold internal turun, kandidat melebar."}
]
}
}
Render message, then suggestions as tappable chips that reopen the filter
sheet with the suggested value pre-set. The copy is already in Bahasa Indonesia.
Ignore
funnelin the UI. Its intermediate counts are unreliable for Model F —after_probabilitydoes not reflect the strategy's own filtering. Usepick_countto decide empty vs not.
Error#
Any non-2xx, or status: "failed".
{"error": {"code": "invalid_request",
"message": "Parameter 'min_probability' must be <= 100, got 150.0.",
"request_id": "req_a1b2c3d4"}}
Show a retry and the message. Log request_id — it matches the server log line.
| Status | code |
What the app should do |
|---|---|---|
| 401 | missing_api_key, invalid_api_key |
Config bug. Don't retry |
| 403 | insufficient_scope |
Your key lacks a scope. Don't retry |
| 422 | missing_end_user |
You forgot X-End-User. Don't retry |
| 400 | invalid_end_user |
Bad id format. Don't retry |
| 422 | invalid_request |
Show the message, reopen the filter sheet |
| 429 | rate_limited |
Back off for Retry-After seconds |
| 503 | data_unavailable |
Transient. Retry with backoff |
| 500 | internal_error |
Report with request_id |
| — | status: "failed" |
Model-side failure. Show error.message, offer retry |
A failed run is not an HTTP error: submit returns 202, polling returns 200,
and the failure appears inside the job body. Always check status.
4. Things that will bite you#
1. Data is not live. The dataset is refreshed out of band. Every result
carries as_of — surface it in the freshness banner, and if it is more than a
couple of days old, say so. Users acting on stale analysis while the UI implies
live data is the worst failure mode here.
2. Runs cost ~15 seconds and retrain every time. There is no cheap "refresh". Don't fire a run on screen focus, on pull-to-refresh, or on every filter tweak — only on an explicit START tap. The whole platform runs two concurrent model workers, so parallel runs queue rather than speed up.
3. Rate limits are per API key, not per user. All your customers share one
budget. Respect X-RateLimit-Remaining and Retry-After.
4. min_probability used to be silently ignored by Model F. Fixed on
2026-09-18. If you tested against an earlier build and concluded the slider did
nothing, retest — it filters correctly now.
5. Prices are numbers, not strings, in view=compact. The full view mixes
formatted strings into results ("Last Close": "8,050"). Compact never does. One
more reason to use compact.
6. X-End-User must be stable. A fresh id per request still works, but makes
per-user history and reporting meaningless, and nothing will warn you.
5. Quick reference#
| # | Method | Path | When | Next on success |
|---|---|---|---|---|
| 1 | GET | /api/v1/consent |
FAIR menu opened | accepted → 3; else → 2 |
| 2 | POST | /api/v1/consent |
User agrees | → 3 |
| 3 | GET | /api/v1/models/{id}/params |
Session start, cached | → 4a / 4b |
| 4a | POST | /api/v1/models/model_f_combined/runs?view=compact&wait=30 |
START on Screener | 200 → render; 202 → 5 |
| 4b | POST | /api/v1/models/recovery_v4/runs?view=compact&wait=30 |
START on Recovery | 200 → render; 202 → 5 |
| 5 | GET | /api/v1/jobs/{job_id} |
Every 3s after a 202 | succeeded → render |
| 6 | GET | /api/v1/usage/end-users/{id}/events |
History screen | → render |
Health check for your monitoring, no auth required: GET /api/health — data_loaded
tells you whether the first run will pay a cold-start penalty.
docs/FAIR-INTEGRATION.md in the idx-screener repository. This page is rendered from that file, so the repo stays the single source of truth.