---
name: megapot-data-api
description: Read off-chain Megapot data — rounds, 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, and round history. 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=…` |
| Per-round LP yield (gross) | **API** — `Round.lp_earnings` |
| Top wins per round (leaderboard) | **API** — `GET /v1/rounds/{id}/wins` |
| 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.

## Get a Key

API access is partner-tier (60 req/min, 10K req/day). Anonymous traffic is allowed at a lower tier (10 req/min, 500 req/day) for evaluation.

Keys are now self-service from the Megapot dashboard:

1. Sign in at <https://megapot.io> — a Megapot account is required; create one if you don't have one.
2. Open <https://megapot.io/dashboard> 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_live_<22 base62 chars>`.

> **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';
const API_KEY = 'mpk_live_xxxxxxxxxxxxxxxxxxxxxx'; // optional — anonymous works at lower tier

const headers: Record<string, string> = {
  '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 | 60 | 10,000 |
| Anonymous | 10 | 500 |

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 |
| `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/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/{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 }`. |
| `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. Null until `prize_payouts` lands post-settlement. |

### `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 `ticket_count` is still populated for tickets that landed there — gives partners the full distribution, not just winners.

**Sum invariant:** when `prize_tiers` is non-null, `sum(prize_tiers[i].ticket_count) === Round.ticket_count`. Useful for reconciliation.

### 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.

## 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` | `mpk_dev_*` sent to `live` deploy or vice versa |
| 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 `<TicketRow>` 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<string, string> = { '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})`);
}
const stats = await res.json();
console.log(stats);
// e.g. { total_tickets, total_wins, total_winnings_usdc_6dec, lifetime_round_count, ... }
// Full shape in https://api.megapot.io/v1/openapi.json
```

### Recipe 2 — Round Leaderboard (Top Wins)

Replaces a `WinnersCalculated` event scan + `getExpectedDrawingTierPayouts`. Wins are returned sorted by amount, descending.

```ts
// PLACEHOLDER: replace with a real settled round id, or pull
// `(await api.activeRound()).id` and use the previous round.
const ROUND_ID = 'PLACEHOLDER_ROUND_ID';

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<T> = { 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<Page<unknown>>;
    },
    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.

## 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).

## Related

- `megapot-read-state` — RPC alternative for live current-drawing state and event subscriptions
- `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
