--- name: megapot-data-api description: Read off-chain Megapot data — rounds, prize tiers, tickets, wins, wallet aggregates — via the REST API at api.megapot.io. Use instead of RPC loops for cross-drawing aggregates, wallet lifetime stats, leaderboards, round history, and per-tier prize payouts on both settled and active rounds. Read-only. --- # Megapot Data API (REST) The Megapot Data API is a read-only REST API at `https://api.megapot.io/v1`. Use it for any off-chain aggregate read — wallet history, round leaderboards, cross-drawing stats — that would otherwise require RPC pagination loops or a self-hosted indexer. ## When to Use This vs. RPC | You need... | Use | |---|---| | Wallet lifetime stats (total tickets, total wins) | **API** — `GET /v1/wallets/{addr}/stats` | | Wallet's ticket list — **any** drawing, current or past | **API** — `GET /v1/wallets/{addr}/tickets`, `…/tickets/rounds/{roundId}`. For current-drawing tickets `matched_normals` / `bonusball_match` / `winnings_amount` are `null` by design — the round hasn't been drawn — and remain valid against the published `Ticket` schema. | | Per-ticket match status / payout (settled drawings) | **API** — same `…/tickets[/rounds/{roundId}]` endpoint (server-precomputed); narrowed to winning rows on `…/wins`. | | Cross-drawing wallet wins / claim status | **API** — `GET /v1/wallets/{addr}/wins` (add `?claimed=false` for the unclaimed-only feed used by claim UIs) | | Round-by-round history page | **API** — `GET /v1/rounds?cursor=…` | | Most recent completed drawing's results (last winning numbers, payouts) | **API** — `GET /v1/rounds/latest-settled` (one call, no scanning the list for the first `settled` row) | | Per-round LP yield (gross) | **API** — `Round.lp_earnings` | | Top wins per round (leaderboard) | **API** — `GET /v1/rounds/{id}/wins` | | Per-player round totals (results page — one row per wallet, ranked by payout) | **API** — `GET /v1/rounds/{id}/players` | | Per-tier prize payouts — **settled or active** round | **API** — `Round.prize_tiers` on any round endpoint. On the active round these are the contract's *expected* payouts, served from a per-drawing cached chain read. Added v1.9.0 — prefer this over calling `getExpectedDrawingTierPayouts` yourself. | | Per-tier payouts with no API dependency (trust-minimized, sub-block) | **RPC** — `megapot-read-state` (`getExpectedDrawingTierPayouts`) | | Live current-drawing state (jackpot, time, ball bounds) | **RPC** — `megapot-read-state` (`getDrawingState`) | | Real-time post-buy ticket confirmation (sub-block, no indexer lag) | **RPC** — `megapot-read-state` (`getUserTickets`) + `TicketPurchased` event subscription | | Settlement transitions, ticket-purchase events | **RPC** — event subscriptions | | Buy / claim / deposit / subscribe / compound | **RPC** — corresponding write skill | The API is read-only. For writes, use the on-chain task skills. ## Environments The API runs in two deployments with an identical surface — same endpoints, same response shapes, same spec version. Only the host and key prefix differ: | Environment | Base URL | Serves data from | Key format | | --- | --- | --- | --- | | **Production** | `https://api.megapot.io/v1` | Base mainnet | `mpk_live_…` | | **Testnet** | `https://api-testnet.megapot.io/v1` | Base Sepolia testnet | `mpk_testnet_…` | Point testnet integrations at `api-testnet.megapot.io` — including anything built against the toolkit's Base Sepolia [examples](https://github.com/coordinationlabs/megapot-developer-toolkit/tree/main/examples). Every endpoint, recipe, and shape below applies unchanged; swap only the `BASE_URL` constant (and the key, if you use one). Keys are environment-bound. A `mpk_live_…` key sent to the testnet host — or a `mpk_testnet_…` key sent to production — is rejected with `403 key_environment_mismatch`. The anonymous tier works on both hosts for evaluation, so you can start on testnet with no key at all. Each host advertises its own OpenAPI spec and interactive reference: - Testnet spec: · docs: - Production spec: · docs: ## Get a Key API access is partner-tier (300 req/min, observed). Anonymous traffic is allowed at a lower tier (120 req/min, observed) for evaluation. Keys are now self-service from the Megapot dashboard: 1. Sign in at — a Megapot account is required; create one if you don't have one. 2. Open and find the API keys panel. 3. Create a key. Copy it once and store it like a password — Megapot does not retain a retrievable copy. Reissuing replaces the old key. Key format: `mpk__<22 base62 chars>` — `mpk_live_…` from the production dashboard above, `mpk_testnet_…` for the [testnet environment](#environments). The prefix is not decorative: the auth layer binds each key to one host and `403`s it anywhere else (see `key_environment_mismatch`). > **Testnet keys:** the self-service dashboard issues production (`mpk_live_…`) keys. For testnet, the anonymous tier already covers evaluation against `api-testnet.megapot.io`; if you need an elevated `mpk_testnet_…` key, request one from the Megapot team via [megapot.io/support](https://megapot.io/support). > **Agents:** self-service issuance for autonomous agents (an agent minting its own key without a human-bound Megapot account) is a future consideration. Today the dashboard is the only path — an agent operator should mint the key from their own dashboard and inject it into the agent's environment / secret store. ## Prerequisites - `fetch` (built-in on Node 18+, or `node-fetch` / browser native) - An EVM address to query for wallet endpoints - Optional: API key for the elevated tier — anonymous traffic works for evaluation ## Base URL and Auth ```ts const BASE_URL = 'https://api.megapot.io/v1'; // testnet: https://api-testnet.megapot.io/v1 (see Environments) const API_KEY = 'mpk_live_xxxxxxxxxxxxxxxxxxxxxx'; // optional — anonymous works at lower tier const headers: Record = { 'Accept': 'application/json', }; if (API_KEY) headers['Authorization'] = `Bearer ${API_KEY}`; ``` Send the key in the `Authorization` header on every request. With no header, the request is treated as anonymous-tier. ## Rate Limits and Headers | Tier | Per-minute | Per-day | | --- | ---: | ---: | | Authenticated | 300 | not published | | Anonymous | 120 | not published | The per-minute figures are what production returns in `X-RateLimit-Limit` today, measured against both tiers. Windows are fixed and align to wall-clock minute boundaries, so `X-RateLimit-Reset` is the top of the next minute rather than 60 seconds after your first request. There is no header that exposes a per-day allowance, and no daily cap was reached during testing, so treat any daily figure as unconfirmed. Do not hardcode either number. Budget against `X-RateLimit-Remaining` on live responses, which is the only value guaranteed to track the bucket actually being enforced. Every response carries: - `X-RateLimit-Tier` — `authenticated` or `anonymous` - `X-RateLimit-Limit` — the more restrictive of per-minute and per-day - `X-RateLimit-Remaining` - `X-RateLimit-Reset` — Unix epoch ms - `Retry-After` — present on 429s ## Endpoints | Path | Description | | --- | --- | | `GET /v1/rounds` | Paginated rounds, newest first | | `GET /v1/rounds/active` | Current open or drawing round. Carries expected `prize_tiers` for the drawing in flight (v1.9.0). | | `GET /v1/rounds/latest-settled` | Most recently settled round (last completed drawing). 404 before any round has settled. Added v1.8.0. | | `GET /v1/rounds/{roundId}` | Single round + per-round aggregates | | `GET /v1/rounds/{roundId}/tickets` | Paginated tickets in a round | | `GET /v1/rounds/{roundId}/wins` | Paginated wins in a round, sorted by amount. Optional `?claimed=true\|false` filter. | | `GET /v1/rounds/{roundId}/players` | Paginated per-player aggregates for a round — one row per wallet, sorted by total payout. Added v1.7.0. | | `GET /v1/wallets/{address}/stats` | Aggregate ticket and winnings stats | | `GET /v1/wallets/{address}/tickets` | Paginated tickets for a wallet | | `GET /v1/wallets/{address}/tickets/rounds/{roundId}` | Wallet tickets in a specific round | | `GET /v1/wallets/{address}/wins` | Paginated wins for a wallet. Optional `?claimed=true\|false` filter (added v1.6.0). | | `GET /v1/wallets/{address}/wins/rounds/{roundId}` | Wallet wins in a specific round | For full request/response shapes, fetch the OpenAPI spec: ```bash curl https://api.megapot.io/v1/openapi.json ``` The interactive reference (Scalar UI) is at https://api.megapot.io/v1/docs. ## Round Shape `Round` (returned by `GET /v1/rounds`, `/v1/rounds/active`, `/v1/rounds/latest-settled`, `/v1/rounds/{roundId}`) folds per-round aggregates and round-state into one object. The fields most integrations use: | Field | Type | Notes | | --- | --- | --- | | `id` | `string` | Stringified `drawing_id` (the on-chain `uint256`). Use `BigInt(round.id)` to round-trip to the contract. | | `status` | `'active' \| 'settled'` | DB `open` and `locked` map to API `active`. | | `prize_pool` | `Amount` | `{ amount, decimals }` — see "Helpers" below for `bigint` conversion. | | `ticket_count` | `number` | Total tickets sold for this round. | | `unique_participants` | `number` | Distinct recipient wallets. | | `winners_count` | `number` | Tickets with `winnings_amount > 0`. | | `top_prize_amount` | `Amount \| null` | Null until drawn with at least one winner. | | `top_prize_winners_count` | `number` | Tied-jackpot count. | | `lp_earnings` | `Amount` | Per-round LP yield (gross). Always populated — `{amount: "0", …}` while the round is open and accumulates as tickets sell, finalized at settlement. Tightened to non-null in v1.5.0 (was nullable in v1.3.0–v1.4.0). | | `started_at` / `ended_at` / `settled_at` | `Timestamp \| null` | ISO 8601 ms-Z. `started_at` is derived from the previous round's `settled_at`. | | `ball_pool` | `BallPool` | Always populated, `{ normals_max, bonusball_max }`. Normals are picked from `1..normals_max`, bonusball from `1..bonusball_max`. Base mainnet currently runs `{ normals_max: 30, bonusball_max: 10 }`. `bonusball_max` is set per drawing and varies with prize-pool size, so read it off the round rather than hardcoding. Note the `BallPool` `example` block in the published spec at shows `49`/`26`. That is decorative sample metadata, it is stale, and it matches no live drawing. Trust this field on the round response over that example. | | `winning_numbers` | `WinningNumbers \| null` | `{ normals: number[5], bonusball: number }`. Null until the round is drawn. | | `prize_tiers` | `PrizeTier[12] \| null` | Per-tier payout + ticket count. Settled payouts once drawn; the contract's **expected** payouts while the round is still `active` (v1.9.0). Non-null does **not** imply settled — discriminate on `status`. | ### `prize_tiers` 12-element array sorted ascending by `tier_id`, where `tier_id = normal_matches * 2 + (bonusball_match ? 1 : 0)`. Each entry: ```ts type PrizeTier = { tier_id: number; // 0..11 normal_matches: number; // 0..5 bonusball_match: boolean; payout: Amount; // contract gross — partner divides by 1e6 for USDC ticket_count: number; // tickets that landed in this tier }; ``` Tiers 0 and 2 are always zero-payout (no bonus at low matches) but on a settled round `ticket_count` is still populated for tickets that landed there — gives partners the full distribution, not just winners. **Sum invariant (settled rounds only):** on a `settled` round, `sum(prize_tiers[i].ticket_count) === Round.ticket_count`. Useful for reconciliation. It does **not** hold on an `active` round — see below. #### Settled vs. active rounds `prize_tiers` is populated in both states, but it does not mean the same thing in each. **Branch on `Round.status`, never on whether the field is present.** | | `status: 'settled'` | `status: 'active'` | | --- | --- | --- | | `payout` | Final per-tier payout, projected from the contract's `PrizeTierPayoutsCalculated` event | The contract's **expected** payout for the drawing in flight | | `ticket_count` | Real count of tickets that landed in the tier | Always `0` — per-ticket matches are only computed at settlement | | Sum invariant | Holds | Does not hold (all counts are `0`) | Two caveats on the active-round numbers: - **Expected, not guaranteed.** They assume no duplicate ticket combinations, so they can differ slightly from the final settled values. Label them as estimates in UI. - **Stable within a drawing.** The estimate is a per-drawing constant — ordinary ticket sales don't move it (only duplicate combinations do), and the ball ranges and tier config are fixed when the drawing opens. Don't poll it expecting a live-ticking number; it changes at rollover. `prize_tiers` can still be `null` in two cases, so keep a fallback path: 1. On an `active` round when the expected payouts are momentarily unavailable (the API degrades to `null` rather than erroring). 2. On a `settled` round during the short window before per-tier payouts finish projecting from the contract pipeline. ### What this replaces Before these fields were on the API, History/round-detail UIs had to hop to RPC for two extra reads per drawing (winning numbers + per-tier amounts) and a third read for `getDrawingState().lpEarnings`. With `winning_numbers`, `prize_tiers`, and `lp_earnings` all on the round response, a settled-round detail view is one API call. As of v1.9.0 the same applies to the **active** round: showing "what each tier pays this round" no longer needs the two-round RPC read (`getDrawingState` → `getExpectedDrawingTierPayouts`) described in `megapot-read-state`. The API performs that read server-side and caches it per drawing. Reach for the RPC path only when you need it independent of the API — see the routing table above. ## Player Shape `Player` (returned by `GET /v1/rounds/{roundId}/players`) collapses a round to **one row per distinct ticket-holding wallet** — the shape a results page (`megapot.io/results/{id}`-style) wants without paging `/tickets` and `/wins` and reducing client-side. | Field | Type | Notes | | --- | --- | --- | | `wallet` | `string` | The ticket recipient/owner (`0x…`), canonical EIP-55 checksummed. Gifted / agent-bought tickets collapse into the **receiving** wallet's row, not the buyer's. | | `total_ticket_count` | `number` | Tickets this wallet holds in the round. | | `winning_ticket_count` | `number` | How many of those won a prize in any paying tier. `0` until results land. | | `total_payout` | `Amount` | `{ amount, decimals }`. Sums the same per-ticket amounts the round's `/wins` endpoint reports (net of referral win-share), so the two views always agree. `{amount: "0", …}` until results land. | ```ts type Player = { wallet: `0x${string}`; total_ticket_count: number; winning_ticket_count: number; total_payout: Amount; // { amount: string, decimals: number } }; ``` Rows are sorted by `total_payout` descending, tiebroken by `total_ticket_count` descending, then `wallet` ascending — a stable ranking the keyset cursor encodes in full, so paging never repeats or skips a wallet. The endpoint is **live during an active round** (every `winning_ticket_count` / `total_payout` reads zero until the draw settles) and `404`s an unknown `roundId`. **Reconciliation:** across a full page-walk, `sum(players[].total_ticket_count) === Round.ticket_count`, the player row count equals `Round.unique_participants`, and `sum(players[].winning_ticket_count) === Round.winners_count`. ## Wallet Stats Shape `WalletStats` (returned by `GET /v1/wallets/{address}/stats`) is the lifetime aggregate for one wallet, in a single call. Ticket and winnings figures are **recipient-keyed**: they count tickets the wallet *owns*, not tickets it *bought*, so gifted and agent-bought tickets land on the receiving wallet. | Field | Type | Notes | | --- | --- | --- | | `address` | `string` | Echo of the queried wallet, canonical EIP-55 checksummed. | | `total_tickets` | `number` | Lifetime tickets owned across all rounds. | | `total_wins` | `number` | Lifetime tickets that won a paying tier. | | `total_winnings` | `Amount` | Lifetime gross winnings. `{ amount, decimals }`, **not** a number. | | `total_spent` | `Amount` | Lifetime USDC spent on tickets. | | `total_referral_earnings` | `Amount` | Lifetime gross referral income: purchase fees, claim bonuses, and still-unclaimed pending winnings. | | `rounds_played` | `number` | Distinct rounds the wallet holds tickets in. | | `first_seen_at` | `Timestamp \| null` | First ticket purchase. Null for an unknown wallet. | | `last_seen_at` | `Timestamp \| null` | Most recent ticket purchase. Null for an unknown wallet. | ```ts // `Amount` is the shared money shape used across every endpoint on this API. type Amount = { amount: string // integer string in the token's smallest unit decimals: number // 6 for USDC } type WalletStats = { address: `0x${string}` total_tickets: number total_wins: number total_winnings: Amount total_spent: Amount total_referral_earnings: Amount rounds_played: number first_seen_at: string | null // ISO 8601 ms-Z last_seen_at: string | null } ``` The endpoint does **not** 404 on an unknown wallet. It returns `200` with a well-formed row: zeroed counts, zeroed `Amount`s, and null timestamps. A profile page can render an empty state without special-casing an error. Address input is case-insensitive and `address` comes back EIP-55 checksummed, so use the echoed value as your display key. Detect "never played" with `first_seen_at === null`, not with `total_referral_earnings`. The timestamps and every count are **ticket-keyed**, so a pure referrer that has never bought a ticket reports `total_tickets: 0`, `rounds_played: 0`, and null timestamps while still carrying nonzero `total_referral_earnings`. An integrator dashboard showing referral income has to read that field independently of the play-history fields. All three money fields are `Amount`, so run them through `amountToBigInt` (see [Helpers](#helpers--shape-parity)) rather than reading `.amount` as a number. Deliberately not documented here: a wallet profit-and-loss formula. Whether `total_spent` is what the wallet actually paid or the nominal cost of tickets it owns is unsettled, and gifted or agent-bought tickets make those differ. `total_referral_earnings` is also income that no ticket-based figure includes. Combine these fields into a P&L only once you have confirmed the attribution you need against your own data. ## Pagination List endpoints use cursor-based pagination: ```ts const res = await fetch(`${BASE_URL}/rounds?limit=50`, { headers }); const { data, next_cursor, has_more } = await res.json(); // Next page: if (has_more) { const next = await fetch(`${BASE_URL}/rounds?cursor=${next_cursor}`, { headers }); } ``` `limit` defaults to 50, max 100. Cursors are opaque base64url — don't construct them yourself. ## Error Envelope Every non-2xx response uses the same shape: ```json { "error": { "code": "invalid_address", "message": "Address must be 0x followed by 40 hex chars", "request_id": "req_abc123def456" } } ``` Discriminate on `error.code`: | HTTP | code | When | | --- | --- | --- | | 400 | `invalid_request` | Malformed query or path parameter | | 400 | `invalid_address` | EVM address fails shape validation | | 400 | `invalid_cursor` | Pagination cursor malformed | | 401 | `invalid_api_key` | Bearer key not recognized | | 403 | `revoked_api_key` | Key was revoked | | 403 | `key_environment_mismatch` | Key's environment ≠ the host's — e.g. `mpk_live_*` sent to the testnet host, or `mpk_testnet_*` sent to production | | 404 | `not_found` | Resource doesn't exist | | 422 | `validation_failed` | Request body fails schema validation | | 429 | `rate_limited` | Bucket exhausted — see `Retry-After` | | 500 | `internal_error` | Quote `request_id` when reporting | | 503 | `upstream_unavailable` | Retry with backoff | **Retry guidance:** retry `429` and `503` with exponential backoff (start 1s, max 60s). Don't retry `4xx` (except `429`). ## Helpers / Shape Parity The API serializes types for token-agnostic, forward-compatible JSON; the on-chain reads in the other Megapot skills speak `bigint` and Unix timestamps. Two helpers cover the boundary — drop these into your project once and reuse: ```ts /** Parse an API `Amount` into a USDC bigint (or any 6-decimal token's smallest-unit bigint). */ export function amountToBigInt(a: Amount | null | undefined): bigint | undefined { if (!a) return undefined; return BigInt(a.amount); } /** Parse an ISO timestamp to Unix-seconds bigint to match on-chain `drawingTime` shape. */ export function timestampToUnix(t: Timestamp | null | undefined): bigint | undefined { if (!t) return undefined; return BigInt(Math.floor(new Date(t).getTime() / 1000)); } ``` **Why the wrapper exists:** `Amount` is `{ amount: string, decimals: number }` rather than raw `bigint` so the wire format stays valid JSON (no `BigInt` to serialize) and forward-compatible with non-6-decimal tokens (multi-token rewards, etc.). Don't try to "fix" it to a number — partners running on USDC today get future-proofing for free. ### `user_ticket_id` is a stringified `uint256` `Ticket.user_ticket_id` and `Win.user_ticket_id` are strings on the wire (e.g. `"47"`). The on-chain `claimWinnings(uint256[] _userTicketIds)` argument is `uint256`. Convert with `BigInt()` before passing to write functions: ```ts // Pass ?claimed=false so the list excludes already-claimed wins — // claimWinnings reverts with NoTicketsToClaim() if every ID has // already been claimed. const wins = await api.walletWins(address, { claimed: false }); const ticketIds = wins.data.map((w) => BigInt(w.user_ticket_id)); await walletClient.writeContract({ address: JACKPOT_ADDRESS, abi: jackpotAbi, functionName: 'claimWinnings', args: [ticketIds], }); ``` ### `Win` vs `Ticket` `Win` is structurally `Ticket` with two narrowings: - `winnings_amount` (nullable on `Ticket`) is renamed `amount` and guaranteed non-null. - `matched_normals` and `bonusball_match` are non-null (a `Win` is by definition a drawn ticket). - `claimed_tx_hash` stays nullable — a `Win` can be unclaimed. If you write a single `` React component for both feeds, key off the union and narrow on `'amount' in row`. ### `drawing_id` (on-chain) vs `round_id` (API) The contract calls them "drawings"; the API calls them "rounds". Same value, different name. `BigInt(round.id)` round-trips to a contract `uint256` argument. The API's `Round.id` is the on-chain `drawingId`, stringified. ## Recipes ### Recipe 1 — Wallet Lifetime Stats Replaces a per-drawing `getUserTickets` + `getTicketTierIds` loop. Single round-trip. ```ts const BASE_URL = 'https://api.megapot.io/v1'; const API_KEY = 'mpk_live_xxxxxxxxxxxxxxxxxxxxxx'; // optional const WALLET = '0x1111111111111111111111111111111111111111'; // PLACEHOLDER const headers: Record = { 'Accept': 'application/json' }; if (API_KEY) headers['Authorization'] = `Bearer ${API_KEY}`; const res = await fetch(`${BASE_URL}/wallets/${WALLET}/stats`, { headers }); if (!res.ok) { const { error } = await res.json(); throw new Error(`${res.status} ${error.code}: ${error.message} (request_id=${error.request_id})`); } // Type the response. `WalletStats` and `Amount` are declared // under "Wallet Stats Shape" above. const stats = (await res.json()) as WalletStats console.log(stats) // { address, total_tickets, total_wins, total_winnings, total_spent, // total_referral_earnings, rounds_played, first_seen_at, last_seen_at } // The three money fields are `Amount` objects, not numbers. ``` ### Recipe 2 — Round Leaderboard (Top Wins) Replaces a `WinnersCalculated` event scan + `getExpectedDrawingTierPayouts`. Wins are returned sorted by amount, descending. ```ts // The usual target is the latest completed drawing. `latest-settled` // resolves it in one call — no scanning the rounds list for the first // `settled` row, no walking back from the active round. For a specific // historical round, pass its id to topWinsForRound() directly instead. async function latestSettledRoundId() { const res = await fetch(`${BASE_URL}/rounds/latest-settled`, { headers }); if (res.status === 404) return null; // no round has settled yet if (!res.ok) { const { error } = await res.json(); throw new Error(`${res.status} ${error.code}: ${error.message} (request_id=${error.request_id})`); } return (await res.json()).id as string; // stringified drawing_id } const ROUND_ID = await latestSettledRoundId(); if (ROUND_ID === null) throw new Error('No settled round yet'); async function topWinsForRound(roundId: number | string, limit = 10) { const url = `${BASE_URL}/rounds/${roundId}/wins?limit=${limit}`; const res = await fetch(url, { headers }); if (!res.ok) { const { error } = await res.json(); throw new Error(`${res.status} ${error.code}: ${error.message} (request_id=${error.request_id})`); } return res.json(); // { data: [...wins...], next_cursor, has_more } } const { data: topWins } = await topWinsForRound(ROUND_ID, 10); for (const win of topWins) { console.log(win); // shape: see openapi.json (wallet, ticket_id, amount, tier, ...) } ``` ### Recipe 3 — Cursor-Paginated Wallet Ticket History Replaces a multi-drawing `getUserTickets` loop with manual cursor. ```ts async function* paginatedWalletTickets(wallet: string) { let cursor: string | undefined; do { const url = new URL(`${BASE_URL}/wallets/${wallet}/tickets`); url.searchParams.set('limit', '100'); if (cursor) url.searchParams.set('cursor', cursor); const res = await fetch(url, { headers }); if (!res.ok) { const { error } = await res.json(); throw new Error(`${res.status} ${error.code}: ${error.message} (request_id=${error.request_id})`); } const page = await res.json(); for (const ticket of page.data) yield ticket; cursor = page.has_more ? page.next_cursor : undefined; } while (cursor); } let count = 0; for await (const ticket of paginatedWalletTickets(WALLET)) { count++; // process each ticket — e.g., store in a db, render a table row } console.log(`Total tickets across all rounds: ${count}`); ``` ### Recipe 4 — React `useInfiniteQuery` for Cursor Pagination For React/wagmi consumers, TanStack Query's `useInfiniteQuery` is the natural fit for cursor pagination — handles cache, refetch, and the "load more" pattern without writing your own state machine. Pair the query key with the `['megapot-api', BASE_URL, …]` namespace recommended in `megapot-react-setup` so wagmi-cache and api-cache stay co-located in one `QueryClient`. ```tsx import { useInfiniteQuery } from '@tanstack/react-query'; type Page = { data: T[]; next_cursor: string | null; has_more: boolean }; function useWalletTicketsInfinite(wallet: `0x${string}` | undefined) { return useInfiniteQuery({ queryKey: ['megapot-api', BASE_URL, 'wallet-tickets', wallet], queryFn: async ({ pageParam }) => { const url = new URL(`${BASE_URL}/wallets/${wallet}/tickets`); url.searchParams.set('limit', '50'); if (pageParam) url.searchParams.set('cursor', pageParam); const res = await fetch(url, { headers }); if (!res.ok) { const { error } = await res.json(); throw new Error(`${res.status} ${error.code}: ${error.message}`); } return res.json() as Promise>; }, initialPageParam: undefined as string | undefined, getNextPageParam: (last) => (last.has_more ? last.next_cursor ?? undefined : undefined), enabled: !!wallet, staleTime: 60_000, }); } // In a component: const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useWalletTicketsInfinite(addr); const tickets = data?.pages.flatMap((p) => p.data) ?? []; ``` ### Recipe 5 — TanStack Query retry config wired to `Retry-After` Threading the API's `Retry-After` header into TanStack Query's retry policy turns rate-limit and upstream-unavailable responses into automatic graceful retries — no caller-side wrapper needed. Define an `ApiError` class that exposes `code` and `retryAfter`, then: ```ts class ApiError extends Error { constructor( readonly status: number, readonly code: string, readonly retryAfter: number | undefined, message: string, ) { super(message); } } useQuery({ queryKey: ['megapot-api', BASE_URL, 'wallet-stats', wallet], queryFn, retry: (n, e) => e instanceof ApiError && (e.code === 'rate_limited' || e.code === 'upstream_unavailable') && n < 3, retryDelay: (n, e) => e instanceof ApiError && e.retryAfter ? e.retryAfter * 1000 : 1000 * 2 ** n, // exponential fallback when header is absent }); ``` The `n < 3` cap prevents runaway retries on persistent outages. `e.retryAfter * 1000` converts the API's seconds-format `Retry-After` to milliseconds. For 4xx codes other than `rate_limited`, the predicate returns false → no retry, fail loudly. ### Recipe 6 — Unclaimed-Wins Feed (for "Claim" UIs and SCA flows) Pass `?claimed=false` on `/v1/wallets/{address}/wins` to skip already-claimed tickets server-side. This is the canonical entry point for "show me what I can claim" UIs, claim-bot keepers, and smart-contract-account claim flows where the SCA can't economically re-submit IDs the chain will reject. Mirror the same filter on `/v1/rounds/{id}/wins` when building round-scoped operator dashboards. ```ts const url = new URL(`${BASE_URL}/wallets/${WALLET}/wins`); url.searchParams.set('claimed', 'false'); url.searchParams.set('limit', '100'); const res = await fetch(url, { headers }); if (!res.ok) { const { error } = await res.json(); throw new Error(`${res.status} ${error.code}: ${error.message} (request_id=${error.request_id})`); } const { data: unclaimed } = await res.json(); // Hand off to the write path (megapot-claim-winnings): const ticketIds = unclaimed.map((w: { user_ticket_id: string }) => BigInt(w.user_ticket_id)); // → claimWinnings(ticketIds) — see megapot-claim-winnings for the contract call. ``` Pass `?claimed=true` instead for "what have I claimed?" history surfaces (tax reports, audit views). Omit the parameter to return both — current behavior before v1.6.0. ### Recipe 7 — Round Results (Per-Player Leaderboard) Renders the `megapot.io/results/{id}`-style board: one ranked row per wallet with ticket count and total payout. Replaces client-side reduction over both `/tickets` and `/wins` — the server does the GROUP BY and returns rows already sorted by payout. Works on the live round too (win columns read zero until it settles). ```ts // PLACEHOLDER: a real round id — e.g. `(await api.activeRound()).id` for the // live board, or `await latestSettledRoundId()` (Recipe 2) for the last // finished one. const ROUND_ID = 'PLACEHOLDER_ROUND_ID'; async function* roundPlayers(roundId: number | string) { let cursor: string | undefined; do { const url = new URL(`${BASE_URL}/rounds/${roundId}/players`); url.searchParams.set('limit', '100'); if (cursor) url.searchParams.set('cursor', cursor); const res = await fetch(url, { headers }); if (!res.ok) { const { error } = await res.json(); throw new Error(`${res.status} ${error.code}: ${error.message} (request_id=${error.request_id})`); } const page = await res.json(); // { data: Player[], next_cursor, has_more } for (const player of page.data) yield player; cursor = page.has_more ? page.next_cursor : undefined; } while (cursor); } let rank = 0; for await (const p of roundPlayers(ROUND_ID)) { rank++; const usdc = Number(p.total_payout.amount) / 10 ** p.total_payout.decimals; console.log(`#${rank} ${p.wallet}: ${p.total_ticket_count} tickets, ${p.winning_ticket_count} wins, $${usdc}`); // Rows arrive pre-sorted (payout desc, tickets desc, wallet asc) — rank is just the walk order. } ``` The player rows reconcile with the round aggregates: the total row count equals `Round.unique_participants`, and the ticket/win counts sum to `Round.ticket_count` / `Round.winners_count`. See [Player Shape](#player-shape) for the field contract. ### Recipe 8 — Prize Tiers for the Active Round ("what does each tier pay?") Renders the prize-tier breakdown for the drawing in flight. One API call — replaces the two-round RPC read (`getDrawingState` → `getExpectedDrawingTierPayouts`) that this previously required. ```ts // One call gets the round and its expected tier payouts together. async function activeRoundTiers() { const res = await fetch(`${BASE_URL}/rounds/active`, { headers }); if (!res.ok) { const { error } = await res.json(); throw new Error(`${res.status} ${error.code}: ${error.message} (request_id=${error.request_id})`); } const round = await res.json(); // Null is a normal, transient state — not an error. Render the round // without the tier breakdown rather than failing the page. if (round.prize_tiers === null) return { round, tiers: null }; // `status` is the discriminator, NOT `prize_tiers !== null` — the field is // populated on settled rounds too, where it carries final (not expected) // payouts and real ticket counts. const isEstimate = round.status === 'active'; const tiers = round.prize_tiers .filter(t => t.payout.amount !== '0') // drop tiers 0 and 2 (never pay) .map(t => ({ label: `${t.normal_matches} normal${t.normal_matches === 1 ? '' : 's'}` + `${t.bonusball_match ? ' + bonusball' : ''}`, usdc: Number(t.payout.amount) / 10 ** t.payout.decimals, // On an active round every ticket_count is 0 — matches are computed at // settlement. Only show counts once the round has settled. ticketCount: isEstimate ? null : t.ticket_count, })) .sort((a, b) => b.usdc - a.usdc); return { round, tiers, isEstimate }; } const { tiers, isEstimate } = await activeRoundTiers(); for (const t of tiers ?? []) { console.log(`${t.label}: $${t.usdc}${isEstimate ? ' (est.)' : ''}`); } ``` Label these as estimates in UI: the expected payouts assume no duplicate ticket combinations, so the settled values can differ slightly. They're also a per-drawing constant — no need to poll, they change at rollover. ## Versioning URL-based: `/v1/`. Additive changes (new endpoints, new optional fields, new error codes) are non-breaking. Breaking changes go to `/v2/`. Deprecation: `Sunset:` header + 90-day notice. ## Terms of Use Use of this API is governed by the [Megapot Data API Terms of Service](https://api.megapot.io/tos). ## See in practice A typed Data API client ships in the [Megapot Starter Kit](https://github.com/coordinationlabs/megapot-starter-kit): - [`src/lib/api.ts`](https://github.com/coordinationlabs/megapot-starter-kit/blob/main/src/lib/api.ts) — response types, `QK` query-key constants, retry-with-backoff, TanStack Query integration - [`src/hooks/useActiveRound.ts`](https://github.com/coordinationlabs/megapot-starter-kit/blob/main/src/hooks/useActiveRound.ts) — live-round aggregate on 30s refetch - [`src/hooks/useRound.ts`](https://github.com/coordinationlabs/megapot-starter-kit/blob/main/src/hooks/useRound.ts) — single-round detail (settled rounds use `staleTime: Infinity`) - [`src/hooks/useRoundsList.ts`](https://github.com/coordinationlabs/megapot-starter-kit/blob/main/src/hooks/useRoundsList.ts) — paginated rounds list, invalidates on `JackpotSettled` - [`src/hooks/useUserTickets.ts`](https://github.com/coordinationlabs/megapot-starter-kit/blob/main/src/hooks/useUserTickets.ts), [`useWalletStats.ts`](https://github.com/coordinationlabs/megapot-starter-kit/blob/main/src/hooks/useWalletStats.ts), [`useWalletTickets.ts`](https://github.com/coordinationlabs/megapot-starter-kit/blob/main/src/hooks/useWalletTickets.ts), [`useWalletWins.ts`](https://github.com/coordinationlabs/megapot-starter-kit/blob/main/src/hooks/useWalletWins.ts) — every wallet-scoped endpoint as a TanStack Query hook The kit also ships a framework-agnostic Hono proxy at [`server/proxy.ts`](https://github.com/coordinationlabs/megapot-starter-kit/blob/main/server/proxy.ts) for the proxy-tier deployment shape (Vercel Functions + Cloudflare Workers wrappers in [`examples/`](https://github.com/coordinationlabs/megapot-starter-kit/blob/main/examples/README.md)). See [megapot-starter-kit](https://llms.megapot.io/starter-kit) for the orientation skill. Live demo: [demo.megapot.io](https://demo.megapot.io/). ## Related - `megapot-read-state` — RPC alternative for live current-drawing state, event subscriptions, and per-tier payouts read straight from the chain - `megapot-claim-winnings` — write side; pair with `GET /v1/wallets/{addr}/wins` for "find then claim" UIs - `megapot-buy-tickets` / `megapot-buy-bulk` / `megapot-subscribe` — write paths - Entry point: https://llms.megapot.io/ - Full API reference: https://api.megapot.io/v1/docs - OpenAPI spec: https://api.megapot.io/v1/openapi.json