@openpredict/metapredict-sdk
v0.1.0
Published
Clean, improved TypeScript SDK for the metapredict prediction market — pure fetch + SSE, no Solana deps.
Readme
@openpredict/metapredict-sdk
The official TypeScript SDK for the Metapredict prediction-market API.
One client object. Every read, write, and live subscription goes through it. Fully typed, tree-shakable, zero runtime dependencies beyond a global fetch.
Table of contents
Install
npm install @openpredict/metapredict-sdk
# or
pnpm add @openpredict/metapredict-sdk
# or
yarn add @openpredict/metapredict-sdkRequirements: a runtime with a global fetch (Node 18+, Deno, Bun, modern browsers). If you're on an older Node, pass a fetch polyfill via config.fetch.
Quick start
import { createClient } from "@openpredict/metapredict-sdk";
// Anonymous client — works for reads + tx builds, no key required.
const mp = createClient();
// List active topics
const { topics } = await mp.listTopics({ limit: 10 });
// Subscribe to live round updates (SSE, auto-reconnect)
const unsubscribe = mp.subscribeActiveRound(
{ topicSlug: "btc-100k-2025" },
(event) => {
console.log(event.type, event.round?.roundNumber);
},
);
// Later: stop the stream
unsubscribe();To place bets, claim winnings, or swap tokens you'll build a transaction server-side, sign it with your wallet, then submit it:
// 1. Build the tx (server wires all accounts, returns base64)
const { txBase64 } = await mp.buildPredictTx({
topicSlug: "btc-100k-2025",
side: "yes",
amount: "10.5",
wallet: "9xQeWvG8...Base58Wallet",
});
// 2. Sign with your wallet (Solana wallet adapter, @solana/kit, etc.)
const signedTx = await wallet.signTransaction(decodeBase64(txBase64));
// 3. Submit (or broadcast yourself via your own RPC)
const { signature } = await mp.submitTx({
txBase64: encodeBase64(signedTx.serialize()),
});Configuration
createClient(config?) accepts:
| Option | Type | Default | Description |
| ---------------- | ------------------------------------------------- | ------------------------------------ | ---------------------------------------------------------------------------- |
| apiUrl | string | https://www.metapredict.xyz/api | API base. Trailing slash optional; legacy /api/v1 is normalized to /api. |
| apiKey | string | (anonymous) | API key for higher rate limits + reward attribution. See [Authentication]. |
| fetch | typeof fetch | global fetch | Custom fetch impl (for tests / custom transports). |
| timeoutMs | number | 30000 | Per-request timeout. When elapsed, the request is aborted. |
| maxRetries | number | 3 | Max retry attempts for 5xx + network errors. |
| retryBackoffMs | number | 500 | Initial backoff delay; doubles each retry. |
| onRequest | (info: { method, path, body? }) => void | — | Hook fired before every request (logging / telemetry). |
| onResponse | (info: { method, path, status, durationMs }) => void | — | Hook fired after every response (including retried ones). |
const mp = createClient({
apiUrl: "https://www.metapredict.xyz/api",
apiKey: process.env.METAPREDICT_API_KEY,
timeoutMs: 10_000,
maxRetries: 5,
onRequest: ({ method, path }) => console.debug(`→ ${method} ${path}`),
onResponse: ({ method, path, status, durationMs }) =>
console.debug(`← ${method} ${path} ${status} (${durationMs}ms)`),
});Core concepts
Response envelope
Every API response is wrapped in a standard envelope:
// success
{ "ok": true, "data": { /* ... */ }, "ts": 1710000000000 }
// failure
{ "ok": false, "error": { "code": "not_found", "message": "topic not found" } }The SDK unwraps data for you and throws a typed error on failure (see Error handling). You never see the raw envelope — you get clean typed T or a thrown MetapredictError.
Back-compat: endpoints that haven't been envelope-wrapped yet return the body as-is on 2xx, so the SDK works against transitional responses too.
Authentication
Most of the read + tx-build surface works anonymously — no API key required. For higher rate limits and reward attribution, pass a key:
const mp = createClient({ apiKey: "mp_pk_live_..." });Three key tiers exist:
| Prefix | Tier | How obtained |
| ------------------- | ------ | ---------------------------------------------- |
| mp_pk_anon_… | anon | issueAnonymousKey() — no wallet signature. |
| mp_pk_live_… | live | signup() — requires a wallet signature. |
| mp_pk_terrace<N>_…| live | Live key in terrace bucket N (from signup). |
The key is sent as Authorization: Bearer <apiKey> on every request.
Timeouts & retries
- Timeout: every request gets an
AbortControllerwithtimeoutMs(default 30 s). If it fires, aTimeoutErroris thrown (code"timeout"). - Retry: on 5xx responses or network errors, the SDK retries up to
maxRetriestimes (default 3) with exponential backoff starting atretryBackoffMs(default 500 ms), doubling each attempt.
4xx errors (auth, validation, not found, rate limit) are not retried — they're client errors worth surfacing immediately.
Live updates (SSE)
The subscribe* functions open a text/event-stream connection and invoke onEvent for every parsed frame. Why fetch + manual parsing instead of native EventSource?
EventSourcecannot set request headers (noAuthorizationsupport).- The SDK auths every endpoint via
Authorization: Bearer <apiKey>.
Reconnect: on any disconnect not triggered by your unsubscribe(), the reader reconnects with exponential backoff (500 ms → 1 s → 2 s → … capped at 30 s). A successful connect resets the backoff.
Heartbeats: the server sends event: ping\ndata: {}\n\n every 25 s; the SDK drops these silently (no consumer dispatch).
Frame format:
event: <type> ← optional (type is in data)
data: <json>
<blank line>Error handling
Every thrown error is a MetapredictError (or subclass) — no bare Errors escape. Catch once for the broad bucket, switch on code for specifics:
import { MetapredictError, RateLimitError, AuthError } from "@openpredict/metapredict-sdk";
try {
await mp.listTopics();
} catch (err) {
if (err instanceof RateLimitError) {
console.warn(`rate limited — retry after ${err.retryAfterMs}ms`);
} else if (err instanceof AuthError) {
console.error("bad API key");
} else if (err instanceof MetapredictError) {
switch (err.code) {
case "not_found": // 404
case "validation": // 400/422
case "server": // 5xx
case "network": // fetch threw
case "timeout": // AbortController fired
case "unknown": // anything else
}
}
}| Class | Code | HTTP status | Notes |
| ------------------ | ------------- | --------------- | -------------------------------------------------- |
| AuthError | auth | 401, 403 | Missing or invalid API key. |
| RateLimitError | rate_limit | 429 | Check err.retryAfterMs before retrying. |
| ValidationError | validation | 400, 422 | Request body or params failed validation. |
| NotFoundError | not_found | 404 | Resource not found. |
| TimeoutError | timeout | — | Request exceeded timeoutMs and was aborted. |
| MetapredictError | network | — | Generic network error (fetch threw, non-HTTP). |
| MetapredictError | server | 5xx | Server-side failure. |
| MetapredictError | unknown | other | Anything not mapped above. |
API reference
The client is organized into domain slices mirroring the API route structure. Every method is a bound arrow function — destructure and pass around without losing this.
Keys & auth
| Method | Maps to | Auth | Description |
| ------------------- | ---------------------- | ----- | -------------------------------------------------- |
| issueAnonymousKey() | POST /keys/anonymous | none | Issue an anonymous API key (no wallet signature). |
| signup(args) | POST /keys/signup | none | Sign up with a wallet signature → live-tier key. |
| getKeyInfo() | GET /keys/me | any | Information about the current API key. |
| rotateKey() | POST /keys/rotate | any | Rotate the current key (invalidates the old one). |
| getKeyUsage(args) | GET /keys/usage | any | Usage statistics for the current key. |
// Issue an anonymous key
const { key, tier, expiresAt, rateLimits } = await mp.issueAnonymousKey();
// Check who you are
const info = await mp.getKeyInfo();
console.log(info.tier, info.ratesUsed.reads.current);
// Usage over the last day
const usage = await mp.getKeyUsage({ window: "1d" });
console.log(usage.topEndpoints);Topics & rounds
| Method | Maps to | Description |
| ------------------ | ------------------------- | ------------------------------------------------------ |
| listTopics(args) | GET /topics | List prediction topics with filtering + pagination. |
| getTopic(args) | GET /topics/:slug | Fetch a single topic by slug. Returns null if missing. |
| getActiveRound(args) | GET /topics/:slug/round | Get the currently-active round for a topic. |
| getChart(args) | GET /topics/:slug/chart | Historical price-chart data for a topic's stream. |
| getRound(args) | GET /rounds/:pubkey | Fetch a round by its on-chain pubkey. |
// Browse topics
const { topics, nextCursor } = await mp.listTopics({ limit: 20 });
// Paginate
if (nextCursor) {
const page2 = await mp.listTopics({ limit: 20, cursor: nextCursor });
}
// Active round for a topic
const { round } = await mp.getActiveRound({ topicSlug: "btc-100k-2025" });
if (round) {
console.log(`Round #${round.roundNumber} locks at ${round.lockTimestamp}`);
}
// Price chart (last 60 min)
const { points } = await mp.getChart({
topicSlug: "btc-100k-2025",
windowMinutes: 60,
});Events
An "event" groups several topics under one slug (e.g. a match, a release, a macro print).
| Method | Maps to | Description |
| ---------------- | ----------------- | -------------------------------------------------------- |
| listEvents(args) | GET /events | List + search public events with optional filters. |
| getEvent(args) | GET /events/:slug | Fetch a single event with its topic rows. Returns null if missing. |
// Active events only
const { events } = await mp.listEvents({ status: "active", limit: 50 });
// Search by name
const { events: matches } = await mp.listEvents({ q: "bitcoin" });
// Full event with topic rows (each row has everything needed to predict + claim)
const event = await mp.getEvent({ slug: "election-2025" });
if (event) {
for (const topic of event.topics) {
console.log(topic.question, topic.currentRound?.yesPool);
}
}Users
Read-only user data (predictions, claimables, combined activity). These are public reads of public on-chain data — no wallet-bound auth.
| Method | Maps to | Description |
| ----------------------- | ------------------------------------ | ---------------------------------------------------- |
| listUserPredictions(args) | GET /users/:wallet/predictions | List predictions made by a wallet, with filtering. |
| listClaimables(args) | GET /users/:wallet/claimables | List claimable (resolved + won) predictions. |
| getUserActivity(args) | GET /users/:wallet/activity | Combined prediction + round + topic activity. |
// User's active predictions
const { predictions } = await mp.listUserPredictions({
wallet: "9xQeWvG8...Base58Wallet",
status: "active",
limit: 50,
});
// What can I claim?
const { claimables } = await mp.listClaimables({
wallet: "9xQeWvG8...Base58Wallet",
});
for (const c of claimables) {
console.log(c.topicSlug, c.claimAmount, c.roundNumber);
}
// Combined activity feed (prediction + round + topic per row)
const { rows, nextCursor } = await mp.getUserActivity({
wallet: "9xQeWvG8...Base58Wallet",
limit: 100,
});Transaction build
Server-serialized transactions that the consumer signs and re-submits (via submitTx or their own RPC). Every build endpoint returns a base64 unsigned (or partial-signed) transaction plus a debug object with the accounts the server wired into the instruction.
| Method | Maps to | Description |
| ---------------------------- | --------------------------------- | ---------------------------------------------------- |
| buildPredictTx(args) | POST /tx/predict | Place a bet on an active round. |
| buildStartAndPredictTx(args) | POST /tx/start-and-predict | Start a new round, then predict. |
| buildClaimTx(args) | POST /tx/claim | Claim winnings from a resolved prediction. |
| buildSwapTx(args) | POST /tx/swap | SOL ↔ PAPER token swap. |
| confirmSwap(args) | POST /tx/swap/confirm | Confirm a swap after it's signed + submitted. |
| buildPaperClaimTx(args) | POST /tx/paper-claim | Claim a free PAPER token grant. |
| submitPaperClaim(args) | POST /tx/paper-claim/submit | Submit a signed paper-claim transaction. |
| getPaperClaimStatus(args) | GET /tx/paper-claim/status | Get the status of a paper-claim for a wallet. |
// Place a bet
const { txBase64, expiresAt, debug } = await mp.buildPredictTx({
topicSlug: "btc-100k-2025",
side: "yes",
amount: "10.5",
wallet: "9xQeWvG8...Base58Wallet",
});
console.log(`Blockhash expires at ${expiresAt}; round ${debug.roundPubkey}`);
// SOL → PAPER swap (two-step: build, then confirm after signing)
const swap = await mp.buildSwapTx({
wallet: "9xQeWvG8...Base58Wallet",
direction: "sol_to_paper",
solAmount: "100000000", // 0.1 SOL in lamports
});
// ... sign swap.txBase64 with wallet ...
const result = await mp.confirmSwap({
keyHash: swap.keyHash,
txSig: "<signature>",
wallet: "9xQeWvG8...Base58Wallet",
solAmount: swap.solAmount,
paperAmount: swap.paperAmount,
direction: "sol_to_paper",
});
// Free PAPER grant (eligible wallets only)
const claim = await mp.buildPaperClaimTx({
wallet: "9xQeWvG8...Base58Wallet",
});
if (claim.status === "eligible" && claim.partialTxBase64) {
// ... sign claim.partialTxBase64 ...
const submitted = await mp.submitPaperClaim({
wallet: "9xQeWvG8...Base58Wallet",
requestId: claim.requestId!,
signedTxBase64: "<signed base64>",
});
console.log(submitted.status, submitted.txSig);
}Transaction relay & status
| Method | Maps to | Description |
| -------------- | --------------------- | ----------------------------------------------------------------- |
| submitTx(args) | POST /tx/submit | Submit a fully signed transaction via the metapredict relay. |
| getTxStatus(args) | GET /tx/status/:sig | Poll the status of a submitted transaction by signature. |
// Submit a signed tx
const { signature } = await mp.submitTx({
txBase64: "<fully signed base64>",
});
// Poll until finalized
let status = await mp.getTxStatus({ signature });
while (status.status === "pending") {
await new Promise((r) => setTimeout(r, 2000));
status = await mp.getTxStatus({ signature });
}
console.log(status.status, status.slot, status.confirmations);Oracle
For clients building transactions locally instead of using the server-mediated /tx/predict and /tx/swap build endpoints.
| Method | Maps to | Description |
| -------------------------- | -------------------------------- | -------------------------------------------------------- |
| getOracleStartPrice(args) | GET /oracle/start-price | Fetch a signed oracle start-price quote. |
| getOracleSwapEnvelope(args) | GET /oracle/swap-envelope | Fetch the dual-envelope oracle quote for SOL ↔ PAPER. |
const quote = await mp.getOracleStartPrice({
feedId: "btc-usd",
vaultPubkey: "Base58VaultPubkey...",
});
// Pass quote.quoteBytesHex + quote.signerPubkey straight to your ix.
const envelope = await mp.getOracleSwapEnvelope({
direction: "label_to_peer", // or "peer_to_label"
});
// envelope.labelEnvelope + envelope.peerEnvelope → swap_single ixLive subscriptions
SSE-based live updates. Each subscribe* function returns an Unsubscribe function that aborts the stream and stops reconnect attempts.
| Method | Maps to | Events |
| ---------------------------- | -------------------------------------- | ---------------------------------------- |
| subscribeActiveRound(args, onEvent) | GET /sse/topic/:slug/round | round.snapshot, round.update, round.resolved, round.next |
| subscribeUserPredictions(args, onEvent) | GET /sse/user/:wallet/predictions | prediction.snapshot, prediction.added, prediction.updated, prediction.claimed |
| subscribePriceStream(args, onEvent) | GET /sse/stream/:slug/price | price.tick |
// Live round updates
const unsub = mp.subscribeActiveRound(
{ topicSlug: "btc-100k-2025" },
(event) => {
switch (event.type) {
case "round.snapshot":
console.log("initial round:", event.round?.roundNumber);
break;
case "round.update":
console.log("pools updated:", event.round?.yesPool, event.round?.noPool);
break;
case "round.resolved":
console.log("resolved:", event.round?.outcome);
break;
case "round.next":
console.log("new round started:", event.round?.roundNumber);
break;
}
},
);
// Price feed
const unsubPrice = mp.subscribePriceStream(
{ streamSlug: "btc-usd" },
({ time, value }) => {
console.log(`$${value} @ ${new Date(time * 1000).toISOString()}`);
},
);
// Later
unsub();
unsubPrice();Referrals
Terraced signup uses a referrer code; downline activity accrues credit to the referrer. redeemReferral and getMyReferrals require a live-tier API key.
| Method | Maps to | Description |
| ----------------------------- | --------------------------- | ---------------------------------------------------- |
| redeemReferral(args) | POST /referrals/redeem | Redeem a referral code (preview terrace + credit). |
| getMyReferrals() | GET /referrals/me | Your referral info + downline summary. |
| getReferralLeaderboard(args) | GET /referrals/leaderboard | Referral leaderboard, ranked by accrued credit. |
// Preview a referral code before signup
const preview = await mp.redeemReferral({ referrerCode: "ALICE42" });
console.log(`Terrace ${preview.signupTerrace}, credit ${preview.poolPrimingPreview}`);
// Your referral info
const me = await mp.getMyReferrals();
console.log(me.myCode, me.downlineCount, me.accruedCredit);
// Top referrers
const { rows } = await mp.getReferralLeaderboard({ limit: 50 });
for (const r of rows) {
console.log(r.rank, r.wallet, r.downlineCount, r.accruedCredit);
}Health & meta
No auth required. Useful for health checks, connectivity tests, and discovering cluster config at runtime.
| Method | Maps to | Description |
| -------------- | ------------- | -------------------------------------------------------- |
| getHealth() | GET /health | API health status (version, cluster, slot, uptime). |
| getNetwork() | GET /network | Network config (RPC URL, program IDs, mint addresses). |
| getVersion() | GET /version | API server build version. |
const health = await mp.getHealth();
console.log(health.ok, health.version, health.cluster, health.uptimeSeconds);
const network = await mp.getNetwork();
console.log(network.rpcUrl, network.programId, network.paperMint);TypeScript
This SDK is written in TypeScript and ships with full type definitions. Every type is exported and documented with JSDoc. Type names mirror the API contract 1:1 — if the API docs say Topic, the SDK type is Topic.
import { createClient, type Topic, type Round, type MetapredictError } from "@openpredict/metapredict-sdk";
const mp = createClient();
const topic: Topic | null = await mp.getTopic({ slug: "btc-100k-2025" });All method args and results are fully typed — your editor will autocomplete field names and flag unknown properties at compile time.
License
MIT
