Branch: all work happens on
implementation-drips. Open PRs against that branch, notmain.
This reference is generated from the backend source on
implementation-drips(mostlybackend/src/api/*andbackend/src/server.ts). It is machine-checked against the route registrations inbackend/src. A live, always-current OpenAPI 3.1 document is served by the API itself atGET /api/docs.
The API is a Fastify service (backend/src/server.ts) that serves market,
bet, leaderboard, and stats data to the frontend. It reads from an indexed
PostgreSQL copy of on-chain state (written by the
indexer/) with an optional Redis cache-aside layer in front.
- Default local port:
4000(PORTenv, seebackend/.env.example). - Listen host:
0.0.0.0(HOSTenv). - Example:
http://localhost:4000/api/markets
- Operational endpoints (
/healthz,/readyz,/status,/api/docs) are unversioned. - Feature routes are served under
/api/v1(mounted inbackend/src/api/index.tsasAPI_PREFIX = /api/v1). Route files declare paths relative to the version, soprofile/:addressresolves to/api/v1/profile/:address. - The markets routes (
/api/markets*), leaderboard, and stats are currently registered at their unversioned paths as shown below.
There is no authentication on the read endpoints documented here. The
OpenAPI document defines an oracleApiKey bearer security scheme intended for
the not-yet-registered oracle submission endpoints (POST /api/oracle/*), but
no such route is currently registered, so no endpoint requires a token today.
- Errors use a single envelope for the framework-handled routes:
{ "error": { "code": "...", "message": "..." } }. (Some routes return a flatter shape — noted per endpoint below.) - Unknown paths →
404NOT_FOUND; a known path called with the wrong method →405METHOD_NOT_ALLOWEDwith anAllowheader. - Rate limiting is enforced per route (see
Rate limiting). When exceeded, a
429is returned with aRetry-Afterheader. - CORS: responses carry CORS headers only for origins in the allowlist
(
CORS_ORIGINS); requests with noOriginheader are unaffected. - Request ID: every request is logged with a correlation id, echoed back in
the
x-request-idresponse header (configurable viaREQUEST_ID_HEADER). A valid inboundx-request-idis reused. - Numeric amounts (
total_yes,total_no) are returned as strings because PostgreSQLNUMERICvalues are serialised as strings.
GET /healthz— liveness probeGET /readyz— readiness probe (DB + Redis)GET /status— public status feed for a status pageGET /api/docs— OpenAPI 3.1 specification (JSON)GET /api/markets— list markets (filter/sort/paginate)GET /api/markets/:id— market detailGET /api/markets/:id/odds— derived odds and implied probabilityGET /api/leaderboard— player rankingsGET /api/stats— global platform statisticsGET /api/v1/profile/:address— a user's bets + leaderboard totals
Description: Liveness probe. Returns a fixed 200 with a static body
unless the process itself is down.
Authentication: none.
Query Parameters: none.
Response 200 OK:
{ "status": "ok" }Description: Readiness probe. Verifies that PostgreSQL and Redis are
reachable. Returns 200 when both are healthy and 503 otherwise.
Authentication: none.
Query Parameters: none.
Response 200 OK:
{
"status": "ready",
"checks": {
"db": { "ok": true, "latencyMs": 2 },
"redis":{ "ok": true, "latencyMs": 3 }
}
}Error Responses:
| Status | Condition |
|---|---|
| 503 | Either db.ok or redis.ok is false. Body: { "status": "not ready", "checks": { "db": { "ok": false, "error": "..." }, "redis": { ... } } } |
Description: Public status feed for an external status page: API health, indexer progress, and the most recently resolved market.
Unlike /readyz, this endpoint is meant to be published. It carries no error
strings, hostnames, connection details, or user addresses — a failing
dependency reports ok: false and nothing more. The detailed reason stays in
/readyz, which is an internal probe.
It always returns 200, including during an outage; a status page must stay
readable when things are broken. Read the status field, not the HTTP code:
status |
Meaning |
|---|---|
ok |
Everything is working. |
degraded |
Serving traffic, but the cache is down or the indexer is behind, so market data may be stale. |
down |
PostgreSQL is unreachable; the API cannot serve real data. |
indexer.lagSeconds is the age of the newest indexed event, not the gap to the
chain head — it needs no RPC call, so the feed stays cheap. The indexer is
reported as ok: false once that exceeds 300 seconds, and when nothing has
been indexed yet (in which case the ledger and lag fields are null rather than
a fabricated zero).
Authentication: none.
Query Parameters: none.
Caching: responses carry
Cache-Control: public, max-age=15, stale-while-revalidate=30 and are cached in
Redis for 15s, so a polling status page and any CDN in front of it are cheap.
generatedAt is the real observation time and may trail the request by up to
the TTL.
Response 200 OK:
{
"status": "ok",
"generatedAt": "2026-01-02T03:04:05.000Z",
"api": {
"ok": true,
"db": { "ok": true, "latencyMs": 2 },
"redis": { "ok": true, "latencyMs": 1 }
},
"indexer": {
"ok": true,
"lastIndexedLedger": 5150,
"lastEventAt": "2026-01-02T03:03:55.000Z",
"lagSeconds": 10
},
"lastResolvedMarket": {
"id": 42,
"question": "Will it rain tomorrow?",
"outcome": "yes",
"resolvedAt": "2026-01-02T02:00:00.000Z"
}
}lastResolvedMarket is null when no market has resolved yet.
Error Responses: none — failures are reported in the body as degraded or
down.
Description: Returns the generated OpenAPI 3.1 specification as JSON.
Generated from the schema attached to each route, so it cannot drift from the
implementation.
Authentication: none.
Query Parameters: none.
Response 200 OK:
{
"openapi": "3.1.0",
"info": { "title": "iPredict API", "version": "0.1.0" },
"paths": { ... }
}Description: List markets with filtering, sorting, and pagination. This is the hot read path the frontend market list and the synthetic probes use.
Authentication: none.
Query Parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
filter |
string |
no | all |
One of active, resolved, ended, cancelled, all. |
category |
string |
no | — | One of Crypto, Sports, Politics, Entertainment, Science. |
sort |
string |
no | newest |
One of newest, volume, ending_soon, bettors. |
page |
integer |
no | 1 |
1-indexed page number (min 1). |
limit |
integer |
no | 20 |
Results per page (1–100). |
Filter semantics (from backend/src/db/markets.ts):
| filter | SQL condition |
|---|---|
active |
resolved = false AND cancelled = false AND end_time > now() |
resolved |
resolved = true |
ended |
resolved = false AND cancelled = false AND end_time <= now() |
cancelled |
cancelled = true |
all |
(no filter) |
Sort semantics: newest → created_at DESC; volume →
(total_yes + total_no) DESC; ending_soon → end_time ASC; bettors →
bet_count DESC.
Response 200 OK:
{
"markets": [
{
"id": 1,
"question": "Will Stellar XLM reach $1 in 2026?",
"image_url": "https://example.com/image.png",
"category": "Crypto",
"end_time": "1735689600",
"total_yes": "100.0000000",
"total_no": "50.0000000",
"resolved": false,
"outcome": null,
"cancelled": false,
"creator": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"bet_count": 5,
"created_at": "2026-01-01T00:00:00.000Z",
"updated_at": "2026-01-01T00:00:00.000Z"
}
],
"total": 1,
"page": 1,
"limit": 20
}total is the count of rows across all pages; page and limit echo the
requested (or default) values.
Error Responses:
| Status | Condition |
|---|---|
| 400 | Invalid filter/category/sort, or page/limit out of range (e.g. limit > 100, page = 0). Body: { "error": { "code": "BAD_REQUEST", "message": "Invalid query parameters", "issues": [...] } } |
| 429 | Rate limit exceeded (60 req/min window per IP for this route). |
Description: Returns derived YES/NO odds and implied probabilities for a market by its integer id. Handles zero-pool markets gracefully by defaulting probabilities to 0.5 (50%/50%).
Authentication: none.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string |
Positive integer market id. |
Query Parameters: none.
Response 200 OK:
{
"market_id": 42,
"total_yes": "60.0000000",
"total_no": "40.0000000",
"total_pool": "100.0000000",
"yes_odds": 0.6,
"no_odds": 0.4,
"implied_probability": {
"yes": 0.6,
"no": 0.4
}
}Error Responses:
| Status | Condition |
|---|---|
| 400 | id is not a positive integer (e.g. GET /api/markets/not-a-number/odds). |
| 404 | Market not found. Body: { "error": { "code": "NOT_FOUND", "message": "Market not found" } } |
Description: Returns a single market by its integer id, or 404 if it does
not exist.
Authentication: none.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string |
Positive integer market id. |
Query Parameters: none.
Response 200 OK:
{
"id": 42,
"question": "Will the price of ETH exceed $4,000?",
"image_url": null,
"category": "Crypto",
"end_time": "1735689600",
"total_yes": "60.0000000",
"total_no": "40.0000000",
"resolved": false,
"outcome": null,
"cancelled": false,
"creator": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"bet_count": 3,
"created_at": "2026-01-01T00:00:00.000Z",
"updated_at": "2026-01-01T00:00:00.000Z"
}Error Responses:
| Status | Condition |
|---|---|
| 400 | id is not a positive integer (e.g. GET /api/markets/not-a-number). |
| 404 | Market not found. Body: { "error": { "code": "NOT_FOUND", "message": "Market not found" } } |
| 429 | Rate limit exceeded (120 req/min window for this route). |
Description: Returns paginated, sortable player rankings from the
leaderboard snapshot table.
Authentication: none.
Query Parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
offset |
integer |
no | 0 |
0-based offset (min 0). |
limit |
integer |
no | 20 |
Results per page (1–100). |
sort |
string |
no | points |
One of points, bets. |
Sort semantics: points → points DESC; bets →
(won_bets + lost_bets) DESC.
Response 200 OK:
{
"players": [
{
"address": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"display_name": "best_bettor",
"points": "83",
"won_bets": 30,
"lost_bets": 2,
"updated_at": "2026-01-01T00:00:00.000Z"
}
],
"total": 1
}Note: points is returned as a string (BIGINT), while won_bets /
lost_bets are numbers. total is the total number of leaderboard rows.
Error Responses:
| Status | Condition |
|---|---|
| 400 | Invalid query parameters. Body (flat shape): { "code": "BAD_REQUEST", "message": "Invalid leaderboard query parameters", "issues": [...] } |
| 429 | Rate limit exceeded (this route falls under the default 30 req/min). |
Description: Returns global platform statistics computed from the database.
Authentication: none.
Query Parameters: none.
Response 200 OK:
{
"totalMarkets": 7,
"totalVolume": "12345.6780000",
"totalUsers": 31,
"totalBets": 120
}totalVolume is a string (sum of total_yes + total_no across markets).
Error Responses:
| Status | Condition |
|---|---|
| 429 | Rate limit exceeded (default 30 req/min). |
Description: Returns a user's bet history and leaderboard aggregates (points, wins, losses). Unregistered addresses return empty bet history and zeroed aggregates rather than an error.
Authentication: none.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
address |
string |
Stellar public key: starts with G, 56 characters, Base32. Matched by ^G[A-Z2-7]{55}$. |
Query Parameters: none.
Response 200 OK:
{
"bets": [
{
"market_id": "1",
"bettor": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"net_amount": "10.0000000",
"gross_amount": "10.2040816",
"is_yes": true,
"claimed": false,
"created_at": "2026-01-01T00:00:00.000Z"
}
],
"points": "83",
"won_bets": 30,
"lost_bets": 2
}Error Responses:
| Status | Condition |
|---|---|
| 400 | address does not match the Stellar address format. Body (flat shape): { "error": "Bad Request", "message": "Invalid Stellar address format" } |
| 500 | Failed to fetch profile data. Body (flat shape): { "error": "Internal Server Error", "message": "Failed to fetch user profile data" } |
| 429 | Rate limit exceeded (default 30 req/min). |
Two health endpoints are exposed:
GET /healthz— liveness: returns{ "status": "ok" }with200.GET /readyz— readiness: verifies DB and Redis, returning200/readyor503/not readywith per-checkok/latencyMs/error.
Point uptime probes at GET /healthz (cheap liveness) and GET /readyz
(dependency readiness). See
infra/monitoring/synthetic.md for the
synthetic monitoring configuration.
Rate limiting is enforced with an in-memory sliding window
(backend/src/cache/rateLimiter.ts), configured in
backend/src/config/rateLimits.ts. When the budget is exceeded the client
receives 429 with a Retry-After header, plus X-RateLimit-Limit,
X-RateLimit-Remaining, and X-RateLimit-Reset informational headers on every
request.
| Method / path | requests | window |
|---|---|---|
GET /api/markets |
60 | 60s |
GET /api/markets/:id |
120 | 60s |
POST /api/oracle/* |
10 | 60s |
| default (everything else) | 30 | 60s |
Read-heavy endpoints use a Redis cache-aside layer (backend/src/cache/):
| Endpoint | Cache key (example) | TTL |
|---|---|---|
GET /api/markets (all) |
ipredict:v1:markets:list:all:all:newest:1:20 |
30s |
GET /api/markets (active) |
ipredict:v1:markets:list:active:all:newest:1:20 |
15s |
GET /api/markets/:id |
ipredict:v1:market:42 |
30s |
GET /api/leaderboard |
ipredict:v1:leaderboard:points:20:0 |
60s |
GET /api/stats |
ipredict:v1:stats:global |
60s |
The keys use the ipredict:v1 namespace; see
backend/src/cache/cacheKeys.ts. The indexer invalidates these keys when it
writes new events, so the API never serves stale-on-chain data for long.
Read by the backend (backend/src/config/index.ts and .env.example):
| Variable | Required | Default | Purpose |
|---|---|---|---|
DATABASE_URL |
yes | — | PostgreSQL connection string (shared with the indexer). |
PORT |
no | 4000 |
HTTP listen port. |
HOST |
no | 0.0.0.0 |
HTTP listen host. |
CORS_ORIGINS |
no | http://localhost:3000 |
Comma-separated allowed browser origins. |
LOG_LEVEL |
no | info |
debug | info | warn | error. |
REDIS_URL |
no | redis://localhost:6379 |
Redis cache URL. When unreachable, routes read the DB directly. |
DB_POOL_SIZE |
no | 10 |
Postgres pool max connections. |
DB_IDLE_TIMEOUT_MS |
no | 30000 |
Postgres pool idle timeout. |
DB_CONNECTION_TIMEOUT_MS |
no | 5000 |
Postgres pool connection timeout. |
NODE_ENV |
no | development |
development | test | production. |
SOROBAN_RPC_URL, NETWORK_PASSPHRASE, MARKET_CONTRACT_ID, TOKEN_CONTRACT_ID, REFERRAL_CONTRACT_ID, LEADERBOARD_CONTRACT_ID |
— | — | Present in .env.example; required by the upstream indexer, currently unused by the backend API routes. |