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 onewhale_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_expiredfield above is alwaysfalse. A cursor does not go stale with age. - A
400response witherror.reasoncursor_expiredmeans you sent a cursor alongside a differenttrader,condition_id,min_grade, ormin_sizethan the one it was issued with. Clear your stored cursor and start again under the new filters.
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: thecursor 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.
4. Call the API without losing your place
Every failure comes back as the error envelope, so branch onerror.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_limitedand every503carryRetry-After, and the sleep above honors it.- A
429witherror.reasonmonthly_quota_exceededis different:Retry-Afternames 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_timeouton aGETalso carriesRetry-Afterand is retried the same way.401,402,403,423, and400never 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’scursor. A post that fails raises before the checkpoint moves, so the next attempt starts at the same event rather than past it.
- 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, changemin_gradeormin_sizeand you must clearstate["cursor"]first, or the API answers400witherror.reasoncursor_expired.state["cursor"] = ev["cursor"]runs only afterdeliverhas returned. If the destination answers500, or the network drops the post, the exception leaves the checkpoint where it was, and the same event is first on the next page.deliveredcatches an event that was already posted. That happens when you restore an older checkpoint by hand.pendingshortens 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 withevent_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. Setevent=WhaleTradesInserted to receive whale-trade frames only.
- Each frame is an
id: <seq>line and adataline holdingseq,published_at,type, andcount. Callwake.set()on each one. - Send
Last-Event-IDwhen you reconnect. Anevent: resyncframe 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_idormin_gradeon 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
429means the cap is full: wait forRetry-After, then reconnect.
Budget
A page of up to 100 events costs 1 request, whatever it holds, becauseexpand=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_expiredis alwaysfalse. A checkpoint from a week ago still resumes. Anything the feed holds back is held only until an open write finishes, andpending_beyond_horizonsays 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.deliveredcannot 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_scoreis 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_atis 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_letterwebhook 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
4xxevery 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.