Skip to main content
By the end you have a process that posts every new large Polymarket trade from an S or A wallet to a Slack channel, a Discord channel, or a pager. Each alert is one line: the wallet’s grade, side, size, price, market, and review score. It survives a restart, a backlog, and an outage at the destination without skipping a trade. It never posts the same trade twice either, except in the one crash window named at the end of this page. You need a Pro API key. See Authentication.

1. How the bot hears about a new trade

Three things are involved, and only one of them carries trades. Event replay is the source of truth: it hands you every trade after a position you stored, so nothing is lost. The webhook and the SSE stream carry a count and no trade, so their only job is to tell the process to read the feed now instead of at the next timer. Reading the feed forward from your stored position until it has nothing left is what the code below calls a drain. Run the feed on a timer alone if polling latency is acceptable. Add a webhook or the stream when you want the alert within seconds of the trade being stored.

2. What the feed returns

Event replay returns one whale_trades_inserted event per large trade recorded after your cursor. Ask it for only the trades you post about, and for the whole trade on every event: min_grade=A&min_size=25000&expand=trade turns a page of 100 matching trades into 1 request, where fetching each trade separately took 101. Two things share the name cursor_expired and they mean different things:
  • The meta.retention.cursor_expired field above is always false. A cursor does not go stale with age.
  • A 400 response with error.reason cursor_expired means you sent a cursor alongside a different trader, condition_id, min_grade, or min_size than the one it was issued with. Clear your stored cursor and start again under the new filters.
Changing expand is safe. It is the one parameter a cursor is not tied to, so you can switch it on or off without starting again. sequence is the trade’s raw id, and it does not always increase. A trade whose write finished late arrives after higher ids. Events arrive in the order their writes completed, which is why the position you store is cursor and never the largest id you have seen.

3. The checkpoint

Two facts live in one file, written atomically: the cursor of the last event the process finished with, and the last 1,000 event ids it delivered. A crash leaves the previous checkpoint intact rather than a half-written one.
A first run has no cursor. Without one the feed starts at the newest events rather than at the beginning of history. To start from a known point instead, read the feed once beforehand and store the cursor it returns.

4. Call the API without losing your place

Every failure comes back as the error envelope, so branch on error.code. A permanent code stops the process and prints the message, while a transient one sleeps for Retry-After and retries the same request. The checkpoint moves only after a 2xx.
  • 429 rate_limited and every 503 carry Retry-After, and the sleep above honors it.
  • A 429 with error.reason monthly_quota_exceeded is different: Retry-After names the first of next month, so the process stops instead of sleeping. Turn on pay as you go on Developers, or wait for the reset.
  • 408 request_timeout on a GET also carries Retry-After and is retried the same way.
  • 401, 402, 403, 423, and 400 never clear on a retry, so the process exits with the message. The checkpoint is untouched and the next start resumes from it.

5. Drain the feed, acknowledge after delivery

One event at a time: read the trade off the event, post the alert, and only then record that event’s cursor. A post that fails raises before the checkpoint moves, so the next attempt starts at the same event rather than past it.
What each line buys you:
  • A backlog of 300 trades after an hour offline costs 3 pages and 3 requests. The API applies the filter, and the trade rides on every event, so an older matching trade is never pushed off the page by newer ones and never costs a request of its own.
  • state["cursor"] = page["next_cursor"] after a page that is not full moves the checkpoint past every row the filter examined and rejected, so the next poll does not re-read them. Because the filters are tied to the cursor, change min_grade or min_size and you must clear state["cursor"] first, or the API answers 400 with error.reason cursor_expired.
  • state["cursor"] = ev["cursor"] runs only after deliver has returned. If the destination answers 500, or the network drops the post, the exception leaves the checkpoint where it was, and the same event is first on the next page.
  • delivered catches an event that was already posted. That happens when you restore an older checkpoint by hand.
  • pending shortens the wait when the feed says trades are being held back.

6. Filter on the server, then post

min_grade=A keeps S and A wallets, and min_size=25000 keeps trades of $25,000 and up. Both run before the page is built, so every row on the page is a row you want to post. expand=trade then puts the whole Whale trade object on each event, carrying the wallet’s grade as it stood when the page was read. post_alert reads trader["grade"] directly because min_grade=A guarantees it: a wallet with no grade never passes that filter. username has no such guarantee, so it is read with .get. The post checks what the destination answered. Slack answers 200 with the body ok, and Discord answers 204. Anything else raises, and the checkpoint stays where it is.

7. Trigger the drain from a webhook or the stream

Both carry a count and no trade, so treat either one as a signal to drain now instead of at the next timer.

Webhook

Create a destination with event_types: ["whale_trades_inserted"], then verify it. Webhooks covers the setup and the signature check. Answer 2xx within 15 seconds and do the work afterwards: the drain runs in the loop above, not inside the request handler.

SSE stream

Stream pushes the live feed over Server-Sent Events. Set event=WhaleTradesInserted to receive whale-trade frames only.
  • Each frame is an id: <seq> line and a data line holding seq, published_at, type, and count. Call wake.set() on each one.
  • Send Last-Event-ID when you reconnect. An event: resync frame means the missed window is no longer retained, which costs you nothing here: your position is in the feed, not in the stream, so the next drain covers the gap.
  • Do not set condition_id or min_grade on the stream. A whale-trade frame carries neither field, and the stream drops any frame that does not carry the field you filtered on, so either one would silence it completely.
  • Each API key may hold only a small number of open streams. A 429 means the cap is full: wait for Retry-After, then reconnect.

Budget

A page of up to 100 events costs 1 request, whatever it holds, because expand=trade puts the trade on every event. Idle, the loop above polls every 15 seconds, which is 4 requests a minute against an account budget of 100 a minute shared with everything else using the key. See Rate limits for all three budgets. When you do hit a 429, api_get sleeps for Retry-After and resumes at the same event, so a rate limit delays an alert and never drops one.

Coverage and delivery

  • Coverage is durable. The feed is the stored record of large trades and your cursor is a position in it, not a cache entry, so any server can resume from it and meta.retention.cursor_expired is always false. A checkpoint from a week ago still resumes. Anything the feed holds back is held only until an open write finishes, and pending_beyond_horizon says when that is happening.
  • Delivery is at least once, not exactly once. The checkpoint is written after the post returned 2xx, so a crash between those two steps posts that one trade again on restart. delivered cannot catch it, because the id was never written to the file. Posting exactly once is not possible to a destination with no idempotency key.
  • The grade is whatever it was when you fetched, not at the fill. A wallet graded B at the fill and A an hour later matches if the drain runs an hour later. recorded_review_score is the one field frozen when the trade is stored.

What this does not do

  • Place an order. No endpoint on this API places, cancels, or reads one.
  • Alert before the fill. Every alert describes a trade that already executed on Polymarket. traded_at is the fill time, and the event fires when 0xinsider stores the row.
  • See every fill. The feed carries large trades only, so a fill too small to be recorded as a whale trade never reaches the bot.
  • Resend a dead_letter webhook delivery on its own. Requeue it with Redeliver a webhook delivery, or leave it and let the next drain cover the same trades.
  • Stop on a broken destination. A webhook URL that answers 4xx every time holds the loop at the same event, posting once every 30 seconds, until you fix the URL. The log line names the cursor it is holding at.
Copy-trade signals matches the same feed against a follow list. Insider scanner filters trades on their suspicion score instead.