Pay-Per-Request Market Data: No API Key, No Subscription
Build a TypeScript client that pays $0.01 in USDC per request over HTTP 402 with x402 — no API key, no signup. Full runnable code against Polyrank's agent API.
Every market-data API follows the same script: create an account, verify your email, generate a key, pick a plan, and pay a monthly bill whether your bot makes three thousand calls or three.
This tutorial does none of that. You'll build a TypeScript client that pays for prediction-market data one request at a time — $0.01 in USDC, settled on Base, negotiated over plain HTTP — using the x402 protocol. No signup. No API key to leak into a public repo. No subscription idling while your bot sleeps.
402 Payment Required has been reserved in the HTTP spec since 1997 and has sat there, unused, for almost thirty years. x402 finally makes it do its job: the server quotes a price inside the 402 response, your client pays it in-band, and the retry returns data.
The data you'll buy comes from Polyrank's agent API at api.polyrank.app/v1/agent/* (also served at api-agent.polyrank.app). Polyrank tracks roughly 2.8M Polymarket wallets across about 35M deduplicated trades and 3+ years of history, and ranks them by skill — calibration (Brier score), alpha-versus-mid, and risk-adjusted metrics — rather than raw P&L or win rate. The endpoint you'll hit returns the last 24 hours of smart-money signal events: which skill-ranked wallets moved, in which markets, on which side, at what price and size.
By the end of this post you will have: read a real HTTP 402 response and decoded its payment instructions field by field; built a paying client in six lines; run a ~40-line bot that fetches signals and prints a table; and bolted on the guardrails that keep an agent from ever spending more than you told it to.
What you need
- Node 20+ — for native
fetchand stable ESM. - A throwaway EVM private key — generated below. Never your main wallet. This key's entire job is to hold pocket change.
- ~$1 of USDC on Base sent to that key's address. At $0.01 per signal pull, $1 buys about 100 calls.
- Two packages:
viem(accounts and signing) and@x402/fetch(the payment wrapper). - No ETH. This surprises people: the payment is an EIP-3009 signature, not a transaction you broadcast. The facilitator submits it on-chain and pays the gas. Your wallet needs USDC only.
node --version # v20 or newer
mkdir x402-bot && cd x402-bot
npm init -y && npm pkg set type=module
npm install viem @x402/fetch
npm install -D tsx typescriptGenerate the throwaway key:
// keygen.ts — run once, fund the address, keep the key in your env
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
const pk = generatePrivateKey();
console.log("address: ", privateKeyToAccount(pk).address);
console.log("private key:", pk);npx tsx keygen.ts
export AGENT_PRIVATE_KEY=0x... # the key it printedSend ~$1 of USDC on Base to the printed address (from an exchange or your main wallet), and you're funded.
Step 1 — ask without paying: the raw 402
Hit the endpoint with no auth, no key, no payment, and look at what comes back:
curl -i https://api.polyrank.app/v1/agent/signals/smart-moneyHTTP/2 402 Payment Required
Content-Type: application/json
{
"x402Version": 1,
"error": "payment required: Recent smart-money signal events (last 24h)",
"accepts": [
{
"scheme": "exact",
"network": "base",
"maxAmountRequired": "10000",
"resource": "https://api.polyrank.app/v1/agent/signals/smart-money",
"description": "Recent smart-money signal events (last 24h)",
"mimeType": "application/json",
"payTo": "0xEC89C6e7028b0e30E22eB3409d2F7c273EB20164",
"maxTimeoutSeconds": 60,
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"extra": { "name": "USD Coin", "version": "2" }
}
]
}This is the entire price negotiation. Field by field:
x402Version: 1— the protocol version. Your client checks this before doing anything else. (You may also notice a legacy"x402": "0.1"mirror field in the live response — it's there for pre-spec clients during a deprecation window; spec-compliant clients key onx402Versionand ignore it.)error— why you got a 402: you didn't attach a payment.accepts[]— the list of payment options the server will take. There's one here; a client picks one it supports.scheme: "exact"— pay an exact, fixed amount up front. (The spec leaves room for other schemes later — metered, streaming — butexactis what's live.)network: "base"— the chain the payment settles on. Base mainnet.maxAmountRequired: "10000"— the price, in atomic units of the asset. USDC has 6 decimals, so10000= $0.01. This is the number your guardrails check before signing anything.resource— the URL you're buying. Bound into the payment so a quote for one endpoint can't be replayed against another.description/mimeType— human-readable what-you-get, and the content type of the 200 response.payTo— the recipient: Polyrank's treasury wallet. Verify it matches the address published at the free discovery endpoint,GET /x402/info.maxTimeoutSeconds: 60— the validity window. The authorization your client signs expires 60 seconds out, which tightly bounds replay.asset— the ERC-20 contract you pay with:0x833589…2913is native USDC on Base.extra: { name, version }— the asset's EIP-712 domain ("USD Coin", version"2"), which the client needs to construct the typed-data signature correctly.
Notice what's absent: no login URL, no key-exchange handshake, no account state. The 402 is a self-contained invoice.
Step 2 — the six-line client
Everything needed to pay that invoice automatically:
import { privateKeyToAccount } from "viem/accounts";
import { wrapFetchWithPayment } from "@x402/fetch";
const account = privateKeyToAccount(process.env.AGENT_PRIVATE_KEY as `0x${string}`);
const fetchWithPay = wrapFetchWithPayment(fetch, account);
const res = await fetchWithPay("https://api.polyrank.app/v1/agent/signals/smart-money");
console.log(await res.json());wrapFetchWithPayment returns a drop-in replacement for fetch. On a 402 it parses PaymentRequirements, signs the payment with your viem account, retries with the payment header, and hands you the 200. Your application code never sees the 402 at all.
Step 3 — a working bot in ~40 lines
The six-liner pays blindly, which you should never do in production. Here's the real version: a per-call price cap enforced before signing, a per-run budget ledger, and a table of the signals it bought.
// smart-money.ts — pay-per-request smart-money signals, no API key.
import { privateKeyToAccount } from "viem/accounts";
import { wrapFetchWithPayment } from "@x402/fetch";
const BASE_URL = "https://api.polyrank.app";
const MAX_PER_CALL = 10_000n; // $0.01 in atomic USDC (6 decimals) — refuse to sign above this
const MAX_PER_RUN = 100_000n; // $0.10 hard stop for the whole run
const account = privateKeyToAccount(process.env.AGENT_PRIVATE_KEY as `0x${string}`);
// Third arg = maxValue: the wrapper rejects any 402 asking for more, BEFORE signing.
const fetchWithPay = wrapFetchWithPayment(fetch, account, MAX_PER_CALL);
let spent = 0n;
async function paidGet(path: string, price: bigint): Promise<unknown> {
if (spent + price > MAX_PER_RUN) {
throw new Error(`run budget exhausted: ${spent} atomic USDC already spent`);
}
const res = await fetchWithPay(`${BASE_URL}${path}`);
if (!res.ok) throw new Error(`${path} -> HTTP ${res.status}`);
spent += price;
console.log(`settlement: ${res.headers.get("x-payment-response") ?? "(pending)"}`);
return res.json();
}
type Signal = {
trader: string; market: string; side: string;
price: number; size: number; tags: string[];
};
const data = await paidGet("/v1/agent/signals/smart-money", 10_000n);
const signals = (Array.isArray(data) ? data : (data as { signals: Signal[] }).signals) as Signal[];
console.table(
signals.slice(0, 15).map((s) => ({
trader: s.trader.slice(0, 10) + "…",
market: s.market.slice(0, 42),
side: s.side,
price: s.price,
size: s.size,
tags: s.tags.join(","),
}))
);
console.log(`spent this run: $${(Number(spent) / 1e6).toFixed(2)} USDC`);npx tsx smart-money.tsFirst run, you'll see one paid call, fifteen rows of signal events, and a closing line like spent this run: $0.01 USDC. That dollar figure is not an estimate — it's a transfer you can now go find on-chain.
What just happened
Five things occurred between your fetchWithPay call and the table:
- Probe. The wrapper sent a normal GET. The server answered
402with thePaymentRequirementsyou saw in Step 1. - Budget check. The wrapper compared
maxAmountRequired(10000) against themaxValueyou configured. Over budget → it throws, and nothing is signed. - Sign, locally. It built an EIP-3009
transferWithAuthorizationfor the USDC contract —fromyour throwaway address,tothe treasury,value10000, avalidBeforeabout 60 seconds out, and a fresh random 32-byte nonce — and signed it as EIP-712 typed data with your viem account. No transaction was broadcast; this is just a signature, which is why your wallet needs zero ETH. - Retry with payment. The same GET went out again with the signed authorization in the
X-PAYMENTheader (base64-encoded). The server passed it to a facilitator that verified the signature, the amount, the asset, the recipient, and that your address actually holds the funds — then ran the handler and returned200plus anX-PAYMENT-RESPONSEheader carrying the settlement details, including the transaction hash. Settlement itself happens asynchronously, so the data path stays fast. - Settle on-chain. The facilitator submitted your authorization to the USDC contract on Base, paying the gas itself.
Take the transaction hash from X-PAYMENT-RESPONSE and look it up on BaseScan. You'll find a USDC Transfer of exactly $0.01 from your throwaway address to the treasury — with the gas paid by the facilitator's address, not yours.
That's the whole trust model, and it matches how Polyrank treats its own analytics: every number traces to a public transaction. Your payment is auditable on Base; every signal you just bought traces to public Polygon transactions.
Guardrails: the runaway-agent problem, head on
The first question every developer asks about agent payments is the right one: what if my bot gets stuck in a retry loop and drains the wallet? Don't hand-wave it — bound it, in layers:
- Cap the call, before signing. The third argument to
wrapFetchWithPaymentis a ceiling in atomic units. Any 402 quoting more than that is rejected before a signature exists. The assertionmaxAmountRequired <= budgetmust happen pre-signature — checking after is checking never. If you ever hand-roll a client, this check is non-negotiable. - Cap the run. The
MAX_PER_RUNledger in the example is ten lines of arithmetic that turns "infinite retry loop" into "process exits at $0.10". Per-day caps work the same way with a persisted counter. - Cap the wallet. This is the backstop that holds even if your code is wrong: the key holds $1, so the worst case of any bug, ever, is $1. Top it up deliberately; never point an agent at a wallet whose balance you wouldn't set on fire.
- Nonce hygiene. Every EIP-3009 authorization carries a single-use random 32-byte nonce, and
@x402/fetchgenerates a fresh one per payment. Never reuse a nonce, and never re-send a previously signedX-PAYMENTheader — a signed authorization is a bearer instrument for that exact amount untilvalidBeforeexpires. Don't log payment headers; treat them like cash. The 60-second validity window bounds how long a leaked one matters, but the right amount of leaked authorizations is zero. - Watch the spend. Print the run total (the example does), and alert if calls-per-hour exceeds what your polling schedule predicts. A bot that suddenly spends 10× is telling you about a bug for pennies.
Layered like this, the failure ceiling is the minimum of the four caps — and you chose every one of them.
Cost math
Prices from the free discovery endpoint (GET /x402/info — no payment needed):
| Endpoint | What it returns | Price | $1 buys |
|---|---|---|---|
GET /v1/agent/signals/smart-money | Last-24h smart-money signal events | $0.01 | 100 calls |
GET /v1/agent/trader/:proxy | Lifetime skill metrics for a wallet | $0.005 | 200 calls |
GET /v1/agent/trader/:proxy/tags | Behavioral tags for a wallet | $0.005 | 200 calls |
GET /v1/agent/market/:conditionId | Skill-weighted consensus vs. mid | $0.01 | 100 calls |
GET /v1/agent/market/:conditionId/positioning | Top-200 positioned wallets | $0.02 | 50 calls |
GET /v1/agent/trader/:proxy/playbook | Trader Playbook (categories, stake buckets, timing, co-traders) | $0.01 | 100 calls |
GET /v1/agent/market/:conditionId/flow | 5-min flow buckets, trailing window | $0.005 | 200 calls |
POST /v1/agent/rankings/custom | Custom composite ranking (your weights + filters) | $0.05 | 20 calls |
GET /v1/agent/rankings/preset/:slug | Top-N for a named skill preset | $0.01 | 100 calls |
Concretely: polling smart-money signals every 15 minutes, around the clock, is 96 calls ≈ $0.96 a day. Enriching ten interesting traders with full skill metrics adds $0.05. When your bot stops calling, you stop paying — there is no idle subscription, no seat, no minimum.
What to build next
You now have the primitive: an HTTP client that can buy exactly the data it needs, when it needs it, with a spend ceiling you control.
- The flagship: an autonomous smart-money watch agent — polling signals, enriching traders with
GET /v1/agent/trader/:proxy, scoring markets with consensus-vs-mid, all pay-per-call. Tutorial coming soon. - The MCP route — Polyrank also ships an MCP server, so Claude and other MCP clients can query trader skill conversationally. See the MCP guide.
- Webhooks — push instead of poll for fills and signal events, so you only pay for enrichment, not discovery.
- Just browsing? The free public teaser API (and the site itself) need no wallet at all. See who's skilled — free lookup at polyrank.app.
Polyrank is an independent analytics product and is not affiliated with Polymarket. Everything here is read-only analytics — Polyrank never holds your funds or keys; the x402 wallet is yours and payments settle on a public chain. Nothing in this post is investment advice.