REST API — Conventions and Authenticated Endpoints#
Fee parameters in examples use shipped fee defaults, not an observed live deployment.
<!-- src: program/src/logic/fee.rs:70 DEFAULT_FEE_CAP_BPS --> <!-- src: program/src/logic/fee.rs:96 DEFAULT_DECAY_RATE_BPS -->API conventions (base URLs, path prefixes, response envelope, headers, rate limits, data types, pagination) and authenticated endpoints for the Seesaw indexer.
- Base URL (production):
https://api.seesaw.markets
A machine-readable OpenAPI 3.0 specification is available alongside these docs at
api-reference/openapi.yamlfor generating clients and tooling.
Contents#
- Path Prefixes and Versioning
- Response Format
- Common Headers
- Rate Limits
- Data Types
- Pagination
- Authentication
- Markets
- Orders
- Positions
- Stats
- Achievements
- Error Codes
For the public market, orderbook, position, trade, referral, and creator read endpoints, see Endpoints.
Path Prefixes and Versioning#
The canonical REST base path is:
/api/v1/<endpoint>
The /v2/... paths are fee/referral feature paths inside API v1, so their
canonical form is /api/v1/v2/... (for example,
GET /api/v1/v2/fee-config). Older root-level aliases remain served for
compatibility: /v2/..., /tx/..., /orders?tx=..., /config, /trades,
/orderbook/top, /status, and /admin/.... Those aliases return
Deprecation: true, Link: </api/v1/...>; rel="canonical", and
X-Seesaw-Canonical-Path headers. New integrations should use /api/v1
forms only.
The ops endpoints /health, /ready, and /metrics remain root-level and are
not part of the versioned REST API.
Endpoint pages in this section write paths without a prefix; prepend /api/v1
unless a row explicitly says it is an operational root endpoint.
The web application also owns private same-origin Next.js app routes:
/api/rpc for browser RPC proxying, /api/pyth for the Pyth stream proxy, and
/api/health for web health checks. These are not indexer REST API aliases and
should be referenced through deployment configuration or client helpers.
Response Format#
Success response#
Standard endpoints wrap their payload in a data envelope:
{
"data": { ... }
}
A few diagnostic endpoints (/config, /orderbook/top, /trades, /orders?tx=..., /health)
return their payload unwrapped (no data envelope). Each endpoint page shows
the exact shape.
Error response#
{
"error": "Market not found",
"code": "NOT_FOUND"
}
Some validation errors include an optional details field. Error responses never
expose internal state, stack traces, or database details.
Common Headers#
Request headers#
| Header | Description |
|---|---|
Content-Type | application/json for POST/PUT bodies |
x-request-id | Optional client-supplied correlation ID (echoed back if provided) |
x-wallet-signature | Auth only — base58 Ed25519 signature (see Authentication) |
x-wallet-message | Auth only — the signed JSON auth message |
x-wallet-pubkey | Auth only — base58 wallet public key |
x-wallet-chain | Optional compatibility header; if supplied, it must match signed message chain |
Response headers#
| Header | Description |
|---|---|
x-request-id | Request correlation ID |
X-RateLimit-Limit | Rate limit ceiling for your tier |
X-RateLimit-Remaining | Remaining requests in the current window |
X-RateLimit-Reset | Unix time (seconds) when the window resets |
Retry-After | Seconds to wait, sent with 429 responses |
Rate Limits#
All windows default to 60 seconds (RATE_LIMIT_WINDOW_MS).
| Tier | Requests/window | Notes |
|---|---|---|
| Public market reads | 300 (default) | GET reads keyed per IP; PUBLIC_READ_RATE_LIMIT_MAX_REQUESTS |
| Public other no auth | 100 (default) | Configurable via RATE_LIMIT_MAX_REQUESTS |
| Authenticated | 2× public | Keyed per authenticated wallet |
| Write operations | ½ public | POST / PUT / PATCH / DELETE |
| Sensitive ops | 20 (default) | /orders, /positions, /tx/*, /admin/*, configured by RATE_LIMIT_SENSITIVE_MAX |
| Compliance | 5 (default) | RATE_LIMIT_COMPLIANCE_MAX |
Public market reads cover markets, market detail, order book, trades/fills,
resolution fields on market detail, /status, /config, and /stats/platform.
Use PUBLIC_READ_RATE_LIMIT_WINDOW_MS to set a separate read window; when it is
unset, it follows RATE_LIMIT_WINDOW_MS.
WebSocket connections: default max 10 per IP (WS_MAX_CONNECTIONS_PER_IP), 1000
total (WS_MAX_CLIENTS). These are deployment-configurable, not per-tier.
Requests that exceed a limit receive 429 with a Retry-After header. Handlers
that run longer than 30 seconds return 504 with code TIMEOUT.
Data Types#
All token amounts and share quantities are strings of integer base units
(settlement-mint base units, e.g. 6 decimals for USDT). Prices are integer basis
points in [0, 10000] on the canonical YES book.
Market (list item)#
interface MarketListItem {
id: string;
address: string; // market PDA (base58)
marketId: string; // epoch id = floor(unix_time / duration)
pythFeed: string; // Receiver-owned PriceUpdateV2 account used for Pull snapshots
pythFeedId: string | null; // 32-byte Pyth feed id (hex, no 0x prefix) — PDA seed
state: number; // 0=Pending, 1=Created, 2=Trading, 3=Settling, 4=Resolved, 5=Closed
outcome: number | null;
tStart: string; // ISO timestamp
tEnd: string;
startPrice: string | null;
startPriceConf: string | null;
startPriceExpo: number | null;
endPrice: string | null;
endPriceConf: string | null;
endPriceExpo: number | null;
creator: string | null; // PDA seed; needed for lifecycle cranks
durationSeconds: string | null; // u64 as string; PDA seed
maxConfidenceRatioBps: number | null;
totalVolume: string;
totalTrades: number;
totalPositions: number;
bestBid: number | null; // bps
bestAsk: number | null; // bps
}
The market detail endpoint adds share totals, mints, settlement mint,
accumulatedCreatorFees, and the live fee-curve parameters
feeConfig: { feeCapBps, decayRateBps } — see Endpoints.
Orderbook level#
interface OrderbookLevel {
price: number; // bps
quantity: string; // base units, aggregated at this level
orders: number; // resting order count at this level
}
Position#
interface Position {
id: number;
address: string;
marketAddress: string;
owner: string;
market: { pythFeed: string | null; state: number; outcome: number | null };
yesShares: string;
noShares: string;
collateralDeposited: string;
totalBought: string;
totalSold: string;
settled: boolean;
payout: string;
currentYesPrice: number; // bps; mid of best bid/ask, with fallbacks
}
Order#
interface Order {
id: number;
orderId: string;
marketAddress: string;
marketId: string;
settlementMint: string;
owner: string;
side: 'yes' | 'no';
sideCode: number; // 0=buy_yes, 1=sell_yes, 2=buy_no, 3=sell_no
sideLabel: 'buy_yes' | 'sell_yes' | 'buy_no' | 'sell_no';
isYes: boolean;
priceBps: number;
quantity: string;
filledQuantity: string;
status: 'open' | 'filled' | 'cancelled' | 'expired' | 'pending_cancel' | 'cancel_failed';
statusCode: number;
orderType: number;
createdAt: string | null;
updatedAt: string | null;
}
Trade#
Trades are returned with snake_case fields (market_address, price_bps,
taker_fee, protocol_treasury_index, …) — see
Endpoints → Trades.
Pagination#
List endpoints use offset-based pagination:
GET /api/v1/markets?limit=20&offset=0
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | number | 50 | Items per page (max 100) |
offset | number | 0 | Number of items to skip |
Responses include pagination info inside the data envelope:
{
"data": {
"markets": [...],
"total": 250,
"limit": 50,
"offset": 0
}
}
Authentication#
Authenticated endpoints require a Solana wallet signature. The server verifies an Ed25519 signature over a structured auth message on every authenticated request.
Required headers for authenticated routes:
| Header | Description |
|---|---|
x-wallet-pubkey | Base58-encoded Solana wallet public key |
x-wallet-signature | Base58-encoded Ed25519 signature of the auth message |
x-wallet-message | The signed auth message (JSON string, see below) |
x-wallet-chain | Optional compatibility header; must match signed chain if present |
Auth message format:
The x-wallet-message value is a JSON string containing these fields:
{
"domain": "api.seesaw.markets",
"chain": "solana",
"timestamp": 1706745600,
"action": "GET /positions",
"nonce": "unique-random-string"
}
domainis the server's hostname (e.g.api.seesaw.markets).chainis the signed wallet-chain binding. Solana beta clients sign"solana". Legacy messages without this field are treated as Solana-only, but a suppliedx-wallet-chainheader must match the signed value.timestampmust be within 5 minutes of server time and no more than 30 seconds in the future.actionis the exact authenticated REST request target asMETHOD /path, including the query string when present, after stripping the/api/v1prefix for versioned routes. Examples:GET /positions,GET /positions?owner=<wallet>,POST /tx/place-order. WebSocket subscriptions use"WS_SUBSCRIBE <channel>"— see WebSockets.noncemust be unique across HTTP and WebSocket auth — the server rejects replayed nonces.- The signature is an Ed25519 signature over the exact
x-wallet-messagestring bytes, base58-encoded.
Public routes (no authentication required):
GET /markets,GET /markets/current,GET /markets/:marketIdGET /orders?tx=<sig>(transaction log lookup; returns trades by signature)GET /leaderboard,GET /achievements/all,GET /challenges/todayGET /v2/fee-config,GET /v2/referral/:address,GET /v2/referrer/:address/earningsGET /v2/referrals/leaderboard,GET /v2/wallet/:address/analyticsGET /orderbook/top,GET /trades,GET /status,GET /time
For client clock synchronization, call the canonical GET /api/v1/time
endpoint. Its serverTime field is Unix milliseconds and iso is the matching
UTC ISO-8601 value. The response is served with Cache-Control: no-store; a
root /time alias is intentionally not exposed.
Markets#
List Markets#
GET /markets
Authentication: None required
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
state | integer | No | Filter by market state (0=Pending, 1=Created, 2=Trading, 3=Settling, 4=Resolved, 5=Closed) |
limit | integer | No | Items per page (default: 50, max: 100) |
offset | integer | No | Pagination offset (default: 0, max: 1,000,000) |
chain | string | No | Solana beta accepts only solana; omit to use Solana |
Response:
{
"data": {
"markets": [
{
"id": 1,
"address": "ABcd...1234",
"marketId": "1937600",
"pythFeed": "0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace",
"state": 2,
"outcome": null,
"tStart": "2026-02-12T00:00:00.000Z",
"tEnd": "2026-02-12T00:15:00.000Z",
"startPrice": "68523400000",
"startPriceExpo": -8,
"totalVolume": "15000000",
"totalTrades": 42,
"totalPositions": 18
}
],
"total": 96,
"limit": 50,
"offset": 0
}
}
Get Current Market#
GET /markets/current
Authentication: None required
Response:
{
"data": {
"market": {
"id": 1,
"address": "ABcd...1234",
"marketId": "1937600",
"pythFeed": "0xff61...",
"state": 2,
"outcome": null,
"tStart": "2026-02-12T00:00:00.000Z",
"tEnd": "2026-02-12T00:15:00.000Z",
"startPrice": "68523400000",
"startPriceConf": "34000000",
"startPriceExpo": -8,
"endPrice": null,
"endPriceConf": null,
"endPriceExpo": null,
"totalYesShares": "5000000",
"totalNoShares": "3000000",
"totalVolume": "15000000",
"totalTrades": 42,
"totalPositions": 18,
"createdAt": "2026-02-12T00:00:01.000Z",
"resolvedAt": null
},
"status": "trading"
}
}
The status field is either "trading" (current market) or "upcoming" (next
market if no current one). Returns 404 if no current or upcoming market exists.
Get Market by ID#
GET /markets/:marketId
Authentication: None required
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
marketId | string | Market address (base58) or numeric market ID |
Response:
{
"data": {
"market": {
"...": "all market list fields",
"accumulatedCreatorFees": "12000",
"tickSizeBps": 100,
"feeConfig": {
"feeCapBps": 200,
"decayRateBps": 400
}
},
"orderbook": {
"bids": [
{ "price": 6500, "quantity": "10000000", "orders": 3 },
{ "price": 6000, "quantity": "5000000", "orders": 1 }
],
"asks": [
{ "price": 7000, "quantity": "8000000", "orders": 2 },
{ "price": 7500, "quantity": "3000000", "orders": 1 }
]
}
}
}
The tickSizeBps field carries the live order-book tick size used for
canonical YES-price rounding previews. feeConfig carries the fee-curve
parameters used by the slippage estimator in @seesaw/core. tickSizeBps
defaults to 100 when no config row has been indexed. Market detail fails closed
until an authoritative program-scoped fee-config event is available. The
orderbook shows the top 10 price levels on each side. Prices are in basis
points (0–10000). Bids are sorted descending, asks ascending.
Orders#
List User Orders (Authenticated)#
GET /api/v1/orders
Without the ?tx parameter, this endpoint requires authentication and returns
the authenticated wallet's orders. The root-level GET /orders?tx=<sig> lookup
is public and returns the trades logged in that transaction.
Authentication: Required (omit for ?tx lookups)
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
tx | string | No | On-chain transaction signature (public) |
status | string | No | Filter: open, filled, or cancelled |
limit | integer | No | Items per page (default: 50, max: 100) |
offset | integer | No | Pagination offset (default: 0) |
Response:
{
"data": {
"orders": [
{
"id": 42,
"orderId": "7",
"marketAddress": "ABcd...1234",
"marketId": "1937600",
"settlementMint": "EPjF...Dt1v",
"owner": "Wallet...Address",
"side": "yes",
"sideCode": 0,
"sideLabel": "buy_yes",
"isYes": true,
"priceBps": 6500,
"quantity": "1000000",
"filledQuantity": "500000",
"status": "open",
"statusCode": 0,
"orderType": 0,
"createdAt": "2026-02-12T00:05:00.000Z",
"updatedAt": "2026-02-12T00:06:00.000Z"
}
],
"total": 15,
"limit": 50,
"offset": 0
}
}
Order status values: open, filled, cancelled, expired,
pending_cancel, cancel_failed
Note: pending_cancel is an optimistic state set when a cancellation is
submitted by the app. The indexer reconciles this when the on-chain CancelOrder
event is observed. Order cancellation is always submitted on-chain via the SDK or
CLI; there is no REST-only cancel operation.
Positions#
List User Positions#
GET /positions
Authentication: Required
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
settled | string | No | Filter: true or false |
owner | string | No | Must match authenticated wallet (for compatibility) |
limit | integer | No | Items per page (default: 50, max: 100) |
offset | integer | No | Pagination offset (default: 0) |
Response:
{
"data": {
"positions": [
{
"id": 7,
"address": "Pos...Address",
"marketAddress": "ABcd...1234",
"owner": "Wallet...Address",
"market": {
"pythFeed": "0xff61...",
"state": 4,
"outcome": 1
},
"yesShares": "1000000",
"noShares": "0",
"collateralDeposited": "650000",
"totalBought": "1000000",
"totalSold": "0",
"settled": false,
"payout": "0",
"currentYesPrice": 6500
}
],
"balance": "5000000",
"total": 3,
"limit": 50,
"offset": 0
}
}
The balance field is the sum of redeemable payouts from settled positions.
currentYesPrice is the mid of best bid/ask in basis points, with fallbacks.
If the owner query parameter is provided, it must exactly match the
authenticated wallet address; otherwise the server returns 404 for
anti-enumeration. Missing or invalid wallet authentication still returns 401.
Stats#
Wallet summary/history/trade stats require authentication. The public wallet
P&L-history route is GET /stats/user/:wallet/pnl-history.
Get Personal Stats#
GET /stats
Authentication: Required
Response:
{
"data": {
"totalTrades": 142,
"winningTrades": 85,
"losingTrades": 57,
"totalPnl": "12500000",
"winRate": 59.86,
"avgTradeSize": "500000",
"bestTradePnl": "2000000",
"worstTradePnl": "-1500000",
"currentStreak": 3,
"longestStreak": 7
}
}
If the user has no stored stats record, stats are computed on-the-fly from positions and trades (capped at 10,000 records).
Get P&L History#
GET /stats/history
Authentication: Required
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
days | integer | No | Number of days of history |
Response:
{
"data": {
"history": [
{ "date": "2026-02-10T00:00:00.000Z", "value": -2.5 },
{ "date": "2026-02-11T00:00:00.000Z", "value": 1.3 },
{ "date": "2026-02-12T00:00:00.000Z", "value": 5.7 }
]
}
}
Values are cumulative P&L in USDT (converted from base units by dividing by 1,000,000). Daily P&L is summed from settled positions.
Get Trade History#
GET /stats/trades
Authentication: Required
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Items per page (default: 20, max: 1000). The server fetches limit + 1. |
cursor | string | No | Opaque keyset cursor returned as nextCursor. Cannot be combined with offset. |
offset | integer | No | Legacy pagination offset (default: 0). Cannot be combined with cursor. |
outcome | string | No | Filter: win or loss |
Response:
{
"data": {
"trades": [
{
"id": 42,
"marketId": "1937600",
"side": "yes",
"shares": "1000000",
"cost": "650000",
"payout": "1000000",
"pnl": "350000",
"outcome": "win",
"marketOutcome": "up",
"settledAt": "2026-02-12T00:16:00.000Z",
"tStart": "2026-02-12T00:00:00.000Z",
"tEnd": "2026-02-12T00:15:00.000Z"
}
],
"total": 142,
"hasMore": true,
"nextCursor": "eyJleGVjdXRlZF9hdCI6IjIwMjYtMDItMTJUMDA6MDU6MDAuMDAwWiJ9"
}
}
Get Wallet P&L History#
GET /stats/user/:wallet/pnl-history
Authentication: Wallet signature required
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
days | integer | No | Number of days of history |
Response: Same history envelope shape as GET /stats/history, scoped to
the wallet in the path.
Achievements#
List All Achievements (Public)#
GET /achievements/all
Authentication: None required
Response:
{
"data": {
"achievements": [
{
"id": 1,
"code": "first_trade",
"name": "First Steps",
"description": "Complete your first trade",
"category": "trading",
"iconUrl": null,
"threshold": 1,
"createdAt": "2026-02-01T00:00:00.000Z"
}
]
}
}
Achievement categories: trading, streak, accuracy, exploration
Get User Achievements#
GET /achievements
Authentication: Required
Response:
{
"data": {
"achievements": [
{
"id": 1,
"code": "first_trade",
"name": "First Steps",
"description": "Complete your first trade",
"category": "trading",
"iconUrl": null,
"threshold": 1,
"createdAt": "2026-02-01T00:00:00.000Z",
"progress": 1,
"unlockedAt": "2026-02-05T12:00:00.000Z"
}
],
"grouped": {
"trading": [],
"streak": [],
"accuracy": [],
"exploration": []
},
"stats": {
"total": 13,
"unlocked": 5
}
}
}
Error Codes#
| HTTP Status | Code | Description |
|---|---|---|
| 400 | VALIDATION_ERROR | Invalid request parameters or body |
| 400 | INVALID_REQUEST_BODY | Request body could not be parsed |
| 400 | INVALID_PARAMETER | Invalid path or query parameter |
| 400 | MISSING_REQUIRED_FIELD | Required request field is absent |
| 400 | LIMIT_EXCEEDED | Per-resource cap exceeded |
| 401 | UNAUTHORIZED | Missing or invalid authentication |
| 401 | MISSING_AUTH_HEADERS | Required wallet-auth headers are missing |
| 401 | INVALID_SIGNATURE | Wallet signature verification failed |
| 401 | MESSAGE_EXPIRED | Signed auth message is stale or future |
| 404 | NOT_FOUND | Resource not found |
| 404 | USER_NOT_FOUND | User profile not found |
| 404 | RESOURCE_NOT_FOUND | Requested resource not found |
| 405 | METHOD_NOT_ALLOWED | HTTP method is not supported |
| 409 | CONFLICT | Conflicting operation |
| 429 | RATE_LIMITED | Rate limit exceeded |
| 451 | GEO_BLOCKED | Request from restricted jurisdiction |
| 451 | ATTESTATION_REQUIRED | Jurisdiction attestation required |
| 500 | INTERNAL_ERROR | Server error (details sanitized) |
| 500 | DATABASE_ERROR | Database operation failed |
| 503 | SERVICE_UNAVAILABLE | Required service unavailable |
| 504 | TIMEOUT | Request exceeded the route timeout |
Error responses never expose internal state, stack traces, or database details.
See Also#
- API Reference overview — section map and quick start
- Endpoints — public market, orderbook, position, trade, referral, and creator reads
- WebSockets — real-time subscriptions