PMWallets
中文

API documentation

Live fills over WebSocket and webhooks, and paid trade-history exports — the endpoints, the message format, and what every field means.

Two things are sold here, and they are bought differently. Live fills are a subscription: you subscribe to an entity, its trades are pushed as they happen, and you are billed by the hour. Trade history is a one-off purchase: you pick a date range, pay once, and download a file you keep.

Three chapters below. Pricing is what everything costs, the API is the call-by-call reference, and errors is the table to look things up in when something comes back wrong. If you only want to wire it up, skip to the API.

Pricing

Live fills: by the hour

$0.05 per entity per hour (about $36.00 a month), taken from your account balance. The first hour is charged on subscribe, one more every hour after. An empty balance pauses the subscription rather than running up a debt, and topping up does not restart it — resume it explicitly, from the current head. Cancelling stops the charges; the hour already paid for is not refunded.

Trade history: one-off

By the day, and the rate falls as the range grows. The tiers are marginal: a 90-day range is charged $0.17 for its first 30 days, $0.14 for the next 30 and $0.11 for the last 30 — $12.60 in all. The later tier never reprices the earlier days.

DaysPer day
1–30$0.17
31–60$0.14
61–90$0.11
91–180$0.07
181–365$0.04
366+$0.02

The first 90 days are the expensive part: recent activity is what tells you whether a wallet is good now.

Buying several entities at once discounts the whole order:

EntitiesOff
1
25%
310%
420%
530%
6+40%

Minimum 30 days, maximum 25 entities per order. There is nothing to renew, because you already have the data.

Balance and currency

Both come out of the same account balance, topped up before you spend it. Everything is priced and charged in USD — that is what the ledger holds and what the API quotes as priceCents. Topping up in yuan converts once, at a fixed rate of 6.5, and is credited as cents: a way to pay, not a second price list. (A quote also reports price.cny for that reason; the cents figure is the one you are charged.)

The API

Everything lives under https://api.pmwallets.com, JSON in and JSON out. What follows is in the order you will actually write it: get a key, subscribe, receive, backfill what you missed — plus the separate export path.

Authentication: one API key

Every endpoint on this page authenticates the same way: send your API key. There is nothing else to choose.

shell
# HTTP
curl https://api.pmwallets.com/v1/account/fills?limit=100 -H "Authorization: Bearer $PMW_KEY"

# WebSocket — the handshake cannot set Authorization, so the key goes in its own header
const ws = new WebSocket("wss://api.pmwallets.com/v1/ws", { headers: { "x-api-key": process.env.PMW_KEY } });

Both headers work: Authorization: Bearer and x-api-key. The second exists because a WebSocket handshake cannot set Authorization, and a program should not have to care which of our transports it is talking to.

Create and revoke them on the API keys page. The key is in that one response and nowhere else: we keep only a hash, so it cannot be recovered, only replaced. Up to 5 active keys — enough to rotate without downtime (issue the new one, move traffic across, revoke the old), and a revocation takes effect immediately.

A request carrying a key needs no Origin header. If a key is present at all it is the only credential we read: an invalid one is a 401, never a quiet fall back to something else.

The one thing that does not use a key is receiving webhooks — there we are calling you, and you verify the signature with the secret you were given when you registered the endpoint. That direction is the other way round.

Query the board: who is making money

GET/v1/leaderboardno auth

Open without a key, which gets you a 20-row sample. A key on an account with an active subscription raises it to 200 rows a page, an offset up to 100,000, and 20,000 rows a day.period picks the window (30d or 7d, default 30d), sort and dir order it, limit and offset page it.

Rows carry a handle, not an address. The address is what this product sells — anyone holding it can look the same wallet up on Polymarket for nothing. Each row carries its own revealCents, which is what that one address costs; buying it is the next section.

shell
# the board itself: who is making money, and on what. No key needed for the
# free sample; a key on an account with an active subscription raises the caps.
curl -s "https://api.pmwallets.com/v1/leaderboard?sort=realizedPnl&dir=desc&limit=50" \
  -H "Authorization: Bearer $PMW_KEY"

# ...and the same endpoint filtered. Every filter is a floor or an exact match;
# they combine with AND, and nothing here changes what a row CONTAINS.
curl -s "https://api.pmwallets.com/v1/leaderboard?\
minPnl=5000&\
minWinLo=0.55&\
minEligible=30&\
style=maker&\
category=sports&\
status=active&\
maxWallets=1&\
sort=winLo&dir=desc&limit=50" -H "Authorization: Bearer $PMW_KEY"

The filters. Each is a floor or an exact match, and they combine with AND:

minPnlnumberRealised PnL floor, in USDC. May be negative, which is how you find the losers.
minRoinumberROI floor against money spent buying. 0.2 is 20%.
minWinLonumber 0–1The Wilson 95% LOWER bound on the win rate, not the win rate: 10–0 has a lower bound of 0.69. It is the only filter here that accounts for sample size.
minEligibleintSettled markets floor, default 5. Lowering it lets in wallets with two or three trades to their name.
minFillsintFills in the window.
minVolumenumberUSDC spent buying, in the window.
maxWalletsintHow many addresses the entity may span. 1 means a single address with nothing to reconcile.
stylemaker | taker | two-sidedBy the share of fills made rather than taken. A maker's edge usually cannot be copied.
statusactive | quiet | dormantBy how recent the last fill is; dormant is nothing for seven days.
categoryenumWhat they mostly trade: crypto, crypto-updown, sports, politics, geopolitics, economy, culture, esports, science-tech, other, unknown — by share of their volume.

Sort keys: realizedPnl, roiOnBuys, winLo, nFills, fillsPerDay, grossBuyUsdc, eligible, conditions, unrealizedPnl, lastFill. A bad parameter is a 400 that names what was expected — never a silently ignored one.

json
// GET /v1/leaderboard  →  200
{
  "periodId": "30d",
  "tier": "subscriber",
  "pricing": {
    "revealCents": 100,              // the cheapest band; what a row costs is on the row
    "subscriptionCentsPerHour": 5,
    "bands": [                       // from = the pnlExTop the band starts at; null = no floor
      { "band": 0, "from": null, "cents": 0 },
      { "band": 1, "from": 0.01, "cents": 100 },
      { "band": 5, "from": 5000, "cents": 500 }
    ]
  },
  "limits": { "maxRows": 200, "maxOffset": 100000, "capped": false,
              "dailyRows": 20000, "usedToday": 1250 },
  "rows": [
    {
      "entityId": "7KQ2MF9X4B1C",    // a HANDLE until you buy the address; then the address itself
      "label": "0x4b96…984e",        // masked until then
      "revealed": false,
      "revealCents": 300,            // what THIS address costs, from its own band
      "subscribed": false,
      "realizedPnl": 48213.5, "unrealizedPnl": 1204.0, "roiOnBuys": 0.184,
      "wins": 312, "losses": 89, "eligible": 401,
      "wr": 0.778, "lo": 0.735, "hi": 0.815,   // Wilson 95% bounds on the win rate
      "pnlExTop": 41902.1,           // profit with its single best market removed — the price band
      "style": "maker", "makerShare": 0.91,
      "wallets": 2, "nFills": 18422,
      "categories": [{ "category": "sports", "share": 0.62 }],
      "status": "active",
      "activity": { "lastFillTs": "2026-09-24 01:05:47", "fills24h": 412, "fills7d": 2980 },
      "dataSource": "live", "dataPeriodId": "30d"
    }
  ]
}

Buy an address

POST/v1/account/revealsAPI key

Priced in six bands by pnlExTop — realised PnL with the single best market removed. A wallet that made everything on one lucky market and a wallet that made it fifty times over should not cost the same. Band 0 is free: take away the best market and it made nothing, so we do not charge for it. Each row's revealCents is its price, and pricing.bands is the whole ladder.

shell
# buy the address behind a handle. maxPriceCents is REQUIRED and is a
# ceiling: it is the price you were shown, and the charge can never exceed it.
curl -X POST https://api.pmwallets.com/v1/account/reveals \
  -H "Authorization: Bearer $PMW_KEY" -H "content-type: application/json" \
  -d '{"entityId":"7KQ2MF9X4B1C","maxPriceCents":300}'

# what you already own, newest purchase first
curl -s https://api.pmwallets.com/v1/account/reveals -H "Authorization: Bearer $PMW_KEY"
json
// POST /v1/account/reveals  →  200
{
  "entityId": "0x4b96e2d59f0dabde95ae6f55f2d2d6d345ba984e",   // yours now
  "label": "0x4b96e2d59f0dabde95ae6f55f2d2d6d345ba984e",
  "priceCents": 300,
  "alreadyOwned": false            // true = you had it; nothing was charged
}

// 409 — the band moved between the page and the click. Nothing was charged.
{ "statusCode": 409, "error": "price_changed", "priceCents": 500, "agreedCents": 300 }

// 402 — not enough balance. Nothing was charged.
{ "statusCode": 402, "error": "insufficient_balance", "priceCents": 300, "balanceCents": 120 }

// GET /v1/account/reveals  →  200
[
  { "entityId": "0x4b96…984e", "priceCents": 300, "entitled": true,
    "createdAt": "2026-09-20T10:23:00.000Z", "paidAt": "2026-09-20T10:23:00.000Z" },
  // a FREE unlock whose wallet has since moved into a paid band: the address is masked again
  { "entityId": "9XQ4MF7K2B1C", "label": "0x8a1f…22ce", "priceCents": 0, "entitled": false,
    "revealCents": 200, "createdAt": "2026-09-18T08:00:00.000Z", "paidAt": null }
]
entityIdhandleThe 12-character handle from the board. A raw address is refused with a 400 — otherwise this endpoint answers "is this address on the board" for a list of candidates, which is the very thing being sold. An address you already bought is the exception.
maxPriceCentsint ≥ 0Required, and a ceiling: the price you were shown. Bands are recomputed every few hours, and a band that moved up gives you a 409 with the new price rather than a charge you never agreed to. Zero is a real cap (the free band), not a missing one.

A paid unlock is permanent — a fact cannot be taken back, re-opening it never costs again, and a lapsed subscription does not withdraw it. ⚠ A free unlock lasts as long as the wallet is free: if it later earns its way into a paid band, seeing it again costs that band's price (said before the click, on the button that gives it away). In GET /v1/account/reveals such a row goes back to a handle and carries entitled: false with the new price.

There is a daily cap on how many DISTINCT entities one account may ask about (403 past it). It is what stops an account being used to walk the whole board; asking again about one you own does not count.

Subscribe to an entity

POST/v1/account/subscriptionsAPI key

entityId takes either name an entity has: the handle the board gives you (12 characters, e.g. 7KQ2MF9X4B1C), or its 0x address if you already hold it. The board shows a handle and a masked address, because an address is readable on Polymarket by anyone who has it; the full address comes back on an entity you subscribe to, and in the fills pushed to you. channels takes ws, webhook, or both. A subscription delivers only what happens after it starts — the cursor is the current chain head, and anything earlier is what the export path is for.

shell
# the full 40-hex address the leaderboard shows — an abbreviated one is a 400"""
ENTITY=0x9d84ce0306f8551e02efef1680475fc0f1dc1344

curl -X POST https://api.pmwallets.com/v1/account/subscriptions \
  -H "Authorization: Bearer $PMW_KEY" -H 'content-type: application/json' \
  -d "{\"entityId\":\"$ENTITY\",\"channels\":[\"ws\",\"webhook\"]}"

# 409 if the entity has had no fill in 7 days — resend with "acceptInactive": true
# 402 if the balance will not cover the first hour; body carries price and balance

Two replies are worth knowing about before you meet them. An entity with no fill in 7 days comes back as a 409 carrying the timestamp of its last one; resend with acceptInactive: true to subscribe anyway. That gate exists so nobody pays by the hour to follow a wallet that has stopped trading. A balance that will not cover the first hour is a 402, with the price and your balance in the body.

States are active / paused / canceled. An exhausted balance moves a subscription to paused. At most 200 subscriptions may be active at once; both subscribing and resuming are refused with a 400 past that.

List subscriptions

GET/v1/account/subscriptionsAPI key

Returns the active and paused ones, newest first. Canceled subscriptions are not in it — to know whether an entity is still being pushed to you, ask whether it is in this list. Each carries id (what cancel and resume take), entityId, channels, status, and fromBlock — the block this subscription started delivering from.

Cancel a subscription

DELETE/v1/account/subscriptions/:idAPI key

Billing stops here, and the hour already paid for is not refunded. Cancelling is final: the same entity can be subscribed to again at any time (POST /v1/account/subscriptions), but that is a new subscription with a new fromBlock, and the gap between is not backfilled. Returns the cancelled subscription, with status set to canceled.

Resume after running out of balance

POST/v1/account/subscriptions/:id/resumeAPI key

There is no manual pause — cancel a subscription you no longer want. The only pause is automatic: when the balance cannot cover the next hour at renewal, the subscription becomes paused (pausedReason: "insufficient_balance"), delivery stops and nothing more is charged. Use this after you top up. It charges another hour and restarts from the current head — fills from while it was paused are not backfilled; that span is what the export path is for. A canceled subscription cannot be resumed (409); subscribe again instead. A balance that still will not cover the first hour is a 402, and going past the 200-subscription limit is a 400.

shell
# what is still being pushed — active and paused only, newest first
curl -s https://api.pmwallets.com/v1/account/subscriptions -H "Authorization: Bearer $PMW_KEY"

# stop one. Billing stops here; the hour already paid for is not refunded
curl -s -X DELETE https://api.pmwallets.com/v1/account/subscriptions/$SUB_ID \
  -H "Authorization: Bearer $PMW_KEY"

# restart one that ran out of balance. Charges another hour and resumes from the
# CURRENT head — the gap while it was paused is not backfilled
curl -s -X POST https://api.pmwallets.com/v1/account/subscriptions/$SUB_ID/resume \
  -H "Authorization: Bearer $PMW_KEY"

Typical delay from the block being mined is under a second; the latency page publishes the measured p50 and p95 rather than a promise.

WebSocket

GETwss://api.pmwallets.com/v1/wsAPI key

There is nothing to subscribe to on the socket itself. It carries the fills of whatever entities your account is already subscribed to, and anything you send is ignored. This is the low-latency channel, and what a copy-trading bot normally connects to: one persistent connection, no DNS and TLS handshake per event, and no need to run a public HTTPS endpoint of your own. The browser feed on this site uses the same stream.

node
import WebSocket from "ws";

const ws = new WebSocket("wss://api.pmwallets.com/v1/ws", {
  headers: {
    "x-api-key": process.env.PMW_KEY,       // a handshake cannot set Authorization
  },
});

The trade-off is that the socket itself never retries. Frames are best effort: one handed to an open socket counts as sent, one with no socket to hand it to is dropped, and neither is resent. What the socket drops is not lost, though — keep a cursor and pull the gap back from GET /v1/account/fills, which is what the next section is about. Take the webhook as well if you would rather have the fills pushed to you than fetch them, and deduplicate either way on eventId.

1 connection per account. Opening a second one takes over: the older connection is closed with code 1000 and the newest becomes the live one, so a forgotten tab can never hold your only stream.

Knowing what you missed, and fetching it

Every frame carries session and seq. Within a session the numbers are consecutive, so a skip means we dropped a frame — which happens deliberately when a consumer falls behind, with the socket still open and nothing else to tell you. A new session means you reconnected. Either way you have missed something and know it.

js
// Persist these across reconnects — comparing against a session you just overwrote
// can never detect anything.
let session = load("session"), seq = load("seq") ?? 0;
let lastBlock = load("block") ?? 0, lastLogIndex = load("logIndex") ?? 0;

// One message at a time: a replay is awaited, and a second frame arriving meanwhile
// would advance the cursor past the gap being repaired. Note the catch — chaining
// onto a rejected promise skips every later .then(), so a single failed replay would
// leave the socket open and silently processing nothing at all.
let queue = Promise.resolve();
ws.onmessage = (e) => {
  queue = queue
    .then(() => handleFrame(JSON.parse(e.data)))
    .catch((err) => { report(err); ws.close(); });   // reconnect and replay rather than go quiet
};

async function handleFrame(m) {
  if (m.type === "hello") {
    // A NEW session means frames were missed while the socket was down. Replay BEFORE
    // adopting it — overwriting session here is what silently swallows the outage.
    // a failing replay must not be swallowed: leave the cursor where it is and throw, so the
    // handler above closes the socket and the next connection tries again from the same place
    if (session !== null && m.session !== session) await replayFrom(lastBlock, lastLogIndex);
    session = m.session; seq = m.seq; save();
    return;
  }
  if (m.type !== "fill") return;

  if (m.session !== session || m.seq !== seq + 1) {   // dropped frame, or a session we never saw
    await replayFrom(lastBlock, lastLogIndex);
    session = m.session;
  }
  seq = m.seq;

  if (!seen.has(m.data.eventId)) { seen.add(m.data.eventId); handle(m.data); }
  lastBlock = m.data.block; lastLogIndex = m.data.logIndex;
  save();
}

Then fetch the gap. GET /v1/account/fills returns the fills of everything you subscribe to after a cursor, oldest first, keyset paged — the same rows, with the same eventId, so deduplicating on it makes replay safe to over-fetch.

js
// walk forward until the page is short; every row carries the same eventId as the stream
async function replayFrom(block, logIndex) {
  for (;;) {
    const r = await fetch(
      `https://api.pmwallets.com/v1/account/fills?sinceBlock=${block}&sinceLogIndex=${logIndex}&limit=500`,
      { headers: { "x-api-key": PMW_KEY } },
    );
    if (!r.ok) throw new Error(`replay failed: HTTP ${r.status}`);   // do NOT advance the cursor
    const page = await r.json();

    for (const row of page.rows) if (!seen.has(row.eventId)) { seen.add(row.eventId); handle(row); }
    if (!page.next) return;
    ({ sinceBlock: block, sinceLogIndex: logIndex } = page.next);
  }
}

Webhooks

Register an HTTPS endpoint on your feed and we POST each fill to it. The signing secret is shown once, as you register it — there is no way to read it again, so store it then. 1 endpoint per account; delete it to point deliveries somewhere else. To reach two systems, fan out on your own side, where you can see which one is broken.

Registering and deleting are done on the site, not with an API key. A webhook is a delivery destination, and a key that can change where fills are delivered is a key that can take the stream over for good — the webhook outlives the revocation of the key that created it.

Each request carries x-pmw-signature: HMAC-SHA256 of the raw body, keyed with your secret, hex encoded. Verify it against the raw bytes — parsing and re-serialising the JSON changes them, and then every signature fails.

node
import { createHmac, timingSafeEqual } from "node:crypto";

app.post("/hook", express.raw({ type: "application/json" }), (req, res) => {
  const sent = Buffer.from(req.header("x-pmw-signature") ?? "", "hex");
  const mine = createHmac("sha256", process.env.PMW_SECRET).update(req.body).digest();

  // compare the RAW body, before any JSON parsing: re-serialising changes the bytes
  if (sent.length !== mine.length || !timingSafeEqual(sent, mine)) return res.sendStatus(401);

  res.sendStatus(200);          // 2xx first — we retry anything else
  void handle(JSON.parse(req.body.toString()));
});

Delivery is at least once. We wait 5 seconds for a 2xx and retry up to 3 times; a crash between sending and recording can also replay a delivery. So the same fill can arrive twice and your handler must be idempotent — deduplicate on eventId, which is stable across every retry.

Redirects are not followed and only public addresses are accepted, so an endpoint on localhost or a private range is rejected when you register it.

The fill event

Both channels deliver the same object. The webhook body and the WebSocket frame are byte-identical.

json
{
  "type": "fill",
  "data": {
    "eventId": "137:93912410:0x8f2c…a91b:0x4d0e…77c3:41",
    "chain": 137,
    "entityId": "0x9a3f…21e8",
    "wallet": "0x5b71…0cd2",
    "ts": "2026-09-16 14:02:11",
    "block": 93912410,
    "blockHash": "0x8f2c…a91b",
    "txHash": "0x4d0e…77c3",
    "logIndex": 41,
    "exchange": "pm_ctf_v2",
    "side": "BUY",
    "role": "taker",
    "tokenId": "3200000000…",
    "price": "0.570000",
    "shares": "3200000000",
    "usdc": "1824000000",
    "fee": "0"
  }
}
eventIdstringStable id for this fill, unique per delivery: chain, block, block hash, tx, log index. Deduplicate on this.
chainnumber137 (Polygon). Present so an id is never ambiguous if we add a chain.
entityId0x addressThe entity you subscribed to — the trader, not necessarily the address that signed.
wallet0x addressThe address that actually traded. One entity usually has several.
tsstring (UTC)Block timestamp, UTC. Not when we sent it — see the latency page for that gap.
blocknumberBlock the fill was included in.
blockHash0x hashHash of that block. If a reorg re-includes the same tx, the hash differs and it is a new event.
txHash0x hashTransaction hash.
logIndexnumberPosition of the log inside the block.
exchangestringWhich Polymarket exchange contract matched it, e.g. pm_ctf_v2.
side"BUY" | "SELL"Direction from this wallet's point of view.
role"maker" | "taker"Whether this wallet was the maker or the taker. Only takers pay a fee.
tokenIdstring (uint256)The outcome token (ERC-1155 id), as a decimal string — it does not fit in a JS number.
pricestring (decimal)Price per share in USDC, as a decimal string.
sharesstring (integer)Integer, 6 decimals implied (micro-shares). 3200000000 is 3,200 shares.
usdcstring (integer)Integer, 6 decimals implied (micro-USDC). 1824000000 is $1,824.
feestring (integer)Cash fee charged by the contract, micro-USDC. Zero for makers.

Amounts are integers with six implied decimals, sent as strings. They are the values the contracts emitted; we do not convert them to floats, because a file people reconcile against a chain explorer must not have been rounded on the way out. Divide by 1,000,000 for display.

On a reorg we do not retract anything. If a transaction is re-included in a competing block you receive a second event with a different blockHash — which is why the block hash is part of eventId and why deduplicating on the transaction hash alone would quietly drop a real fill.

Exports: price a range first

POST/v1/account/exports/quoteAPI key

Charges nothing, stores nothing, and reads nothing about the wallets you name — the price is a function of the date range and how many entities it covers. It does not report a row count: an exact count over a range identifies a wallet, and a free quote would then be a way to test an address against a board handle. The real count is on the order once it is bought.

shell
curl -X POST https://api.pmwallets.com/v1/account/exports/quote \
  -H 'content-type: application/json' \
  -H "Authorization: Bearer $PMW_KEY" \
  -d '{"entityIds":["0x9a3f…21e8"],"from":"2026-06-01","to":"2026-08-29"}'
json
{
  "window":   { "from": "2026-05-04", "to": "2026-09-19", "days": 139 },
  "from": "2026-06-01", "to": "2026-08-29", "days": 90,
  "sellable": true,
  "entities": 1,
  "orderPriceCents": 1260,
  "perEntityCents": 1260,
  "discount": 1,
  "price": { "cents": 1260, "usd": 12.6, "cny": 82 },
  "breakdown": [
    { "fromDay": 1,  "toDay": 30, "days": 30, "perDayCents": 17, "cents": 510 },
    { "fromDay": 31, "toDay": 60, "days": 30, "perDayCents": 14, "cents": 420 },
    { "fromDay": 61, "toDay": 90, "days": 30, "perDayCents": 11, "cents": 330 }
  ]
}

A range we cannot sell comes back with sellable: false and a reason — below_minimum or no_data — rather than an error, so a picker can show the range and explain it instead of rejecting the form. window is the span we actually hold; anything you ask for is clamped into it.

GET /v1/account/exports/window returns that span with the four quick ranges (30, 60, 90 days, and everything) already priced.

Exports: buy, then download

POST/v1/account/exportsAPI key

Same body as the quote. The price is recomputed here from the rate card — whatever your quote said is a display, never an input to the charge. The call returns as soon as the order is paid; a separate worker produces the file, so poll the order until it is ready.

The states are paidbuilding ready. A build that fails transiently goes back to paid and is retried; one that cannot be produced ends as refunded, with the reason, and your balance is restored — we do not keep money for a file that does not exist.

shell
# same body as the quote — the price is recomputed here, never taken from the client
curl -X POST https://api.pmwallets.com/v1/account/exports \
  -H 'content-type: application/json' -H "Authorization: Bearer $PMW_KEY" \
  -d '{"entityIds":["0x9a3f…21e8"],"from":"2026-06-01","to":"2026-08-29"}'
# → {"id":"7c1e…","priceCents":1260,"days":90,"rows":12483,"status":"paid"}

# poll until ready
curl https://api.pmwallets.com/v1/account/exports/7c1e… -H "Authorization: Bearer $PMW_KEY"
# → {"id":"7c1e…","status":"ready","rows":12483,"fileBytes":412887,…}

# then mint a link (valid 15 minutes, minted fresh each time)
curl https://api.pmwallets.com/v1/account/exports/7c1e…/download -H "Authorization: Bearer $PMW_KEY"
# → {"url":"https://…r2.cloudflarestorage.com/…","expiresInSec":900,"fileName":"pmwallets-fills-…csv.gz"}

Download links are signed and last 15 minutes. A fresh one is minted on every call and none is ever stored, so the order record is a record of a purchase rather than a working link for whoever reads it. Ask for another whenever you need one; the file does not expire, only the link does.

The file is a gzipped CSV, oldest first, with the same columns as the live feed — so a file and a stream can be reconciled row for row on eventId.

csv
eventId,chain,entityId,wallet,ts,block,blockHash,txHash,logIndex,exchange,side,role,tokenId,price,shares,usdc,fee
137:93912410:0x8f2c…:0x4d0e…:41,137,0x9a3f…21e8,0x5b71…0cd2,2026-09-16 14:02:11,93912410,0x8f2c…,0x4d0e…,41,pm_ctf_v2,BUY,taker,3200000000…,0.570000,3200000000,1824000000,0

This is the full on-chain ledger, not a sample of an API. Polymarket’s own trade endpoint stops at 10,000 historical trades per wallet; that cap does not exist here because the rows are read from the Polygon logs we ingest, and an entity’s multiple addresses are already resolved into one file.

Errors

StatusWhat happenedWhat to do
401The key is missing, invalid, or deletedUse a valid key
402Out of balance. The body carries priceCents / balanceCents (priceCentsPerHour for a subscription)Top up and retry
403A wallet outside what your tier may openA subscription opens any wallet on the board
409The entity has had no fill in 7 daysResend with acceptInactive: true
409You already subscribe to this entityCancel it, or use the one you have
400not_sellable — the range is under 30 days, or we hold nothing for itWiden the range
400too_large — the order is too bigNarrow the range, or split the entities
400not_ready — the file is still building; the body carries the statusPoll the order until ready
400Already at 200 active subscriptionsCancel one first

Questions we get asked · How the numbers are computed · Measured push latency