[ docs · v1 ]
Build on the feed.
Connect to one websocket. Watch any X account. Get every tweet, edit, and follow changes in milliseconds.
Welcome.
PooTracker is a single websocket that streams every X event from the accounts on your watchlist — tweets, deletes, profile edits, follow / unfollow. The wire format is small, the latency is 174ms median, and there is no batching.
This page is everything you need to wire it up.
Quick start.
- 1
Grab your feed key
Open /dashboard. Copy the
feed keyshown there. It is the only secret you need. - 2
Add at least one handle
Go to /dashboard/watches and paste a handle. You can paste a comma-separated list to add many in one go.
- 3
Open the socket
Connect, listen for events, do your thing.
import WebSocket from "ws"const ws = new WebSocket("wss://feed.pootracker.app/v1?key=YOUR_FEED_KEY")ws.on("open", () => console.log("up"))ws.on("message", (raw) => {const evt = JSON.parse(raw.toString())switch (evt.type) {case "tweet.update": // new tweet (no chain yet)case "tweet.subtweet.update": // chain resolvedcase "tweet.expanded.update": // enriched (badges, views, editable)case "tweet.deleted.update": // tweet removedconsole.log(evt.type, "@" + evt.tweet.author.handle, evt.tweet.id)breakcase "profile.update":console.log(evt.type, "@" + evt.user.handle, evt.modifications)breakcase "following.update":case "unfollowing.update":console.log(evt.type, "@" + evt.user.handle, "→ @" + evt.following.handle)break}})
WebSocket.
One persistent connection per process. We push every event the moment it lands; nothing is queued.
wss://feed.pootracker.app/v1JSON, one event per messageReconnect freely — the socket is stateless on resume; your watchlist drives what comes through it.
Authentication.
One key, used everywhere. Your feed key authenticates both the websocket and the REST endpoints below.
The REST endpoints accept the key any of these ways:
x-feed-key: YOUR_FEED_KEYheader (recommended)Authorization: Bearer YOUR_FEED_KEYheader?key=YOUR_FEED_KEYquery string (also how the websocket accepts it)
const ws = new WebSocket("wss://feed.pootracker.app/v1", {headers: { "x-feed-key": "YOUR_FEED_KEY" },})
Heartbeats.
We send a ping frame every 25 seconds. Standard websocket clients reply with pong automatically. If a connection misses two heartbeats it is closed and you should reconnect.
Event flow.
Every frame is one JSON object: { "id": "<uuid>", "type": "<event.type>", ...payload }. Switch on type.
A new tweet from a watched account produces up to four events, in this order:
tweet.update— outer tweet only, no chain yet. Fastest.tweet.subtweet.update— outer tweet plus the full recursive subtweet chain. Fully enriched: view counts, verified status, edit metadata.tweet.expanded.update: fully-enriched chain. Fires only when the outer tweet carries apoll,article, orgrok. Supersedes the earlier frames for that id. Dedupe bytweet.id; latest enrichment wins.tweet.deleted.update— if the tweet is later deleted.
Plain tweets (no quote / retweet / reply, no poll / article / grok) emit only tweet.update.
tweet.update
Fired first, the moment a watched account posts. The outer tweet is here; subtweet is always null. If it's a retweet, quote, or reply, the immediate target is summarised in retweet / quoted / reply as a {id, handle} stub.
{"id": "4db00731-4922-a06a-7f55-62ecfc4a2774","type": "tweet.update","tweet": {"id": "2020042171265741068","type": "TWEET","subtweet": null,"created_at": 1770450558855,"author": {"id": "12","handle": "jack","verified": false,"private": false,"sensitive": false,"restricted": false,"joined_at": 1749675194000,"profile": {"name": "jack","affiliates": false,"pinned": ["1979056517203501375"],"location": null,"avatar": "https://pbs.twimg.com/...","banner": "https://pbs.twimg.com/...","url": null,"badge": { "type": null, "affiliation": null },"description": { "text": "", "urls": [] },"about": null},"metrics": {"likes": 30, "media": 22, "friends": 18, "tweets": 274,"following": 18, "followers": 60,"affiliates": null, "highlights": null}},"reply": null, "quoted": null, "retweet": null,"poll": null, "card": null, "article": null, "grok": null,"editable": null,"body": {"text": "Hello","translation": { "available": false, "result": null },"urls": [], "mentions": [], "components": []},"media": { "images": [], "videos": [], "thumbnails": [] },"metrics": { "likes": 0, "quotes": 0, "replies": 0, "retweets": 0, "advanced": null }}}
tweet.subtweet.update
Fires after tweet.update with the full recursive subtweet chain inlined. Walk the .subtweet field until it's null to traverse from outer to inner.
For deep chains (3+ levels), this event may fire twice: a shallow version immediately, then a deeper version once the full chain is resolved. Each fires at most once per depth — same-depth duplicates are suppressed.
{"id": "5ed60577-b558-2af5-09f8-be40d793c953","type": "tweet.subtweet.update","tweet": {"id": "2020009889364578588","type": "RETWEET","subtweet": {"id": "2019742617756483940","type": "TWEET","subtweet": null,"created_at": 1770379139733,"author": {"id": "1564287237623730176","handle": "LRH_Superfan","verified": true,"private": false,"sensitive": false,"restricted": false,"joined_at": 1661126400000,"profile": {"name": "LRH Superfan","affiliates": false,"pinned": [],"location": null,"avatar": "https://pbs.twimg.com/profile_images/1564287237623730176/avatar.jpg","banner": "https://pbs.twimg.com/profile_banners/1564287237623730176/banner.jpg","url": null,"badge": { "type": "BLUE", "affiliation": null },"description": { "text": "posting through it", "urls": [] },"about": null},"metrics": {"likes": 102844, "media": 1872, "friends": 388, "tweets": 24108,"following": 388, "followers": 14209,"affiliates": null, "highlights": null}},"reply": null, "quoted": null, "retweet": null,"poll": null, "card": null, "article": null, "grok": null, "community": null,"editable": null,"body": {"text": "Full body split found in the Epstein files","translation": { "available": false, "result": null },"urls": [], "mentions": [], "components": []},"media": { "images": [], "videos": [], "thumbnails": [] },"metrics": {"likes": 7488, "quotes": 42, "replies": 311, "retweets": 188,"advanced": { "views": 790212 }}},"created_at": 1770442862250,"author": {"id": "12","handle": "jack","verified": false,"private": false,"sensitive": false,"restricted": false,"joined_at": 1142974088000,"profile": {"name": "jack","affiliates": false,"pinned": ["1979056517203501375"],"location": "Bluesky","avatar": "https://pbs.twimg.com/profile_images/12/avatar.jpg","banner": "https://pbs.twimg.com/profile_banners/12/banner.jpg","url": null,"badge": { "type": null, "affiliation": null },"description": { "text": "bitcoin, nostr", "urls": [] },"about": null},"metrics": {"likes": 30, "media": 22, "friends": 18, "tweets": 274,"following": 18, "followers": 6500000,"affiliates": null, "highlights": null}},"reply": null, "quoted": null,"retweet": { "id": "2019742617756483940", "handle": "LRH_Superfan" },"poll": null, "card": null, "article": null, "grok": null, "community": null,"editable": {"latest": "2020009889364578588","until_ms": 1770446462250,"remaining": 5,"history": ["2020009889364578588"]},"body": {"text": "Full body split found in the Epstein files","translation": { "available": false, "result": null },"urls": [], "mentions": [], "components": []},"media": { "images": [], "videos": [], "thumbnails": [] },"metrics": {"likes": 0, "quotes": 0, "replies": 0, "retweets": 188,"advanced": { "views": 790212 }}}}
Badge values: BLUE (verified individual), BUSINESS (gold), GOVERNMENT (gray), AFFILIATE (with an affiliation sub-object), or null for unverified accounts.
tweet.expanded.update
The fully-enriched tweet frame. Carries populated poll, card, article, grok, and community blocks wherever they exist; supersedes any earlier tweet.update / tweet.subtweet.update for the same tweet.id.
Emit gate. Fires only when the outer tweet itself has a poll, article, or grok. The chain isn't walked; each tweet in a chain qualifies independently.
{"id": "8e36c5ad-7f6a-4f1d-b4a2-1e9e9c1f3c40","type": "tweet.expanded.update","tweet": {"id": "2031423671164617203","type": "TWEET","subtweet": null,"created_at": 1773164119889,"author": {"id": "25401953","handle": "steipete","verified": true,"private": false,"sensitive": false,"restricted": false,"joined_at": 1237503245000,"profile": {"name": "Peter Steinberger","affiliates": false,"pinned": ["2023154018714100102"],"location": "Vienna","avatar": "https://pbs.twimg.com/profile_images/1131851609774985216/OcsssQ9J.png","banner": "https://pbs.twimg.com/profile_banners/25401953/1517485003","url": { "name": "steipete.me", "url": "https://steipete.me", "tco": "https://t.co/VeCkD9BBZx" },"badge": { "type": "BLUE", "affiliation": null },"description": { "text": "Building tools.", "urls": [] },"about": null},"metrics": {"likes": 59690, "media": 8404, "friends": 2286, "tweets": 136372,"following": 2286, "followers": 427544,"affiliates": null, "highlights": null}},"reply": null, "quoted": null, "retweet": null,"poll": null,"card": null,"article": {"id": "2031400000000000000","title": "Why we built X","thumbnail": "https://pbs.twimg.com/article_cover/2031400000000000000.jpg","created_at": 1773160000000,"updated_at": 1773162000000,"body": {"text": "Why we built X\nWe started this because every existing tool was either too slow or too expensive. Read the breakdown at example.com.\nSpeed: sub-200ms\nCost: flat-rate\nScale: unlimited accounts\nFinal note: ship fast.","components": [{"type": "text","variant": "header-one","lines": [{ "text": "Why we built X", "styles": [], "urls": [] }]},{"type": "text","variant": "paragraph","lines": [{"text": "We started this because every existing tool was either too slow or too expensive. Read the breakdown at example.com.","styles": [{ "from": 0, "to": 10, "text": "bold" }],"urls": [{ "from": 103, "to": 114, "url": "https://example.com" }]}]},{"type": "text","variant": "unordered-list","lines": [{ "text": "Speed: sub-200ms", "styles": [], "urls": [] },{ "text": "Cost: flat-rate", "styles": [], "urls": [] },{ "text": "Scale: unlimited accounts", "styles": [], "urls": [] }]},{"type": "media","variant": "image","url": "https://pbs.twimg.com/media/inline_diagram.jpg","thumbnail": "https://pbs.twimg.com/media/inline_diagram.jpg","caption": "Architecture diagram"},{"type": "tweet","tweet": {"id": "1979056517203501375","url": "https://x.com/i/web/status/1979056517203501375","object": null}},{ "type": "divider" },{"type": "text","variant": "paragraph","lines": [{ "text": "Final note: ship fast.", "styles": [], "urls": [] }]}]}},"grok": null,"community": null,"editable": {"latest": "2031423671164617203","until_ms": 1773167719000,"remaining": 5,"history": ["2031423671164617203"]},"body": {"text": "https://t.co/jgZkBvYOPt","translation": { "available": false, "result": null },"urls": [{"name": "x.com/i/article/2031…","url": "https://x.com/i/article/2031400000000000000","tco": "https://t.co/jgZkBvYOPt"}],"mentions": [],"components": []},"media": { "images": [], "videos": [], "thumbnails": [] },"metrics": {"likes": 11009, "quotes": 784, "replies": 568, "retweets": 1754,"advanced": { "views": 6029873 }}}}
The same tweet.id won't re-fire within a 5-minute window, so back-to-back edits collapse to a single frame.
tweet.deleted.update
Fires when a watched account deletes their tweet on X. Payload is the last known state of the tweet before deletion — metrics may be slightly stale.
{"id": "47bf3570-4c8a-7ce4-6450-5127d2607ae0","type": "tweet.deleted.update","tweet": {"id": "2019997006387900709","type": "TWEET","subtweet": null,"created_at": 1770436201430,"author": {"id": "12","handle": "jack","verified": false,"private": false,"sensitive": false,"restricted": false,"joined_at": 1142974088000,"profile": {"name": "jack","affiliates": false,"pinned": ["1979056517203501375"],"location": "Bluesky","avatar": "https://pbs.twimg.com/profile_images/12/avatar.jpg","banner": "https://pbs.twimg.com/profile_banners/12/banner.jpg","url": null,"badge": { "type": null, "affiliation": null },"description": { "text": "bitcoin, nostr", "urls": [] },"about": null},"metrics": {"likes": 30, "media": 22, "friends": 18, "tweets": 273,"following": 18, "followers": 6500000,"affiliates": null, "highlights": null}},"reply": null, "quoted": null, "retweet": null,"poll": null, "card": null, "article": null, "grok": null, "community": null,"editable": null,"body": {"text": "deleted this one","translation": { "available": false, "result": null },"urls": [], "mentions": [], "components": []},"media": { "images": [], "videos": [], "thumbnails": [] },"metrics": {"likes": 412, "quotes": 8, "replies": 64, "retweets": 19,"advanced": { "views": 79506 }}}}
Walking the chain.
Subtweet order is encoded by the recursive nesting itself. The outermost tweet is the one your watched account posted; each .subtweet step descends one hop toward what was referenced. Always newest → oldest in real time.
function walkChain(tweet, fn, depth = 0) {fn(tweet, depth)if (tweet.subtweet) walkChain(tweet.subtweet, fn, depth + 1)}ws.on("message", (raw) => {const evt = JSON.parse(raw.toString())if (evt.type === "tweet.subtweet.update") {walkChain(evt.tweet, (t, d) => {console.log(`L${d} ${t.type} @${t.author.handle} "${t.body.text}"`)})}})// L0 RETWEET @jack ""// L1 QUOTE @Richard_Harambe "Honestly this is the best part"// L2 QUOTE @jaketapper "The only part of the internet meme..."// L3 TWEET @ElizLanders "Here's what the White House says..."
tweet.created_at is strictly decreasing as depth increases — useful as a sanity check when ingesting noisy chains.profile.update
Fires when one of your watched accounts edits their profile. The modifications array tells you exactly which dotted fields changed — profile.avatar, profile.name, metrics.followers, and so on. Both user (new) and before (old) carry the complete user object so you can diff anything.
{"id": "777d5317-a34b-402b-cf0d-122e1ef1b56d","type": "profile.update","user": {"id": "12","handle": "jack","verified": false,"private": false,"sensitive": false,"restricted": false,"joined_at": 1142974088000,"profile": {"name": "jack","affiliates": false,"pinned": ["1979056517203501375"],"location": "Bluesky","avatar": "https://pbs.twimg.com/profile_images/12/avatar.jpg","banner": "https://pbs.twimg.com/profile_banners/12/banner.jpg","url": { "name": "primal.net/jack", "url": "https://primal.net/jack", "tco": "https://t.co/NewS" },"badge": { "type": null, "affiliation": null },"description": { "text": "bitcoin, nostr", "urls": [] },"about": null},"metrics": {"likes": 30, "media": 22, "friends": 18, "tweets": 274,"following": 18, "followers": 6500000,"affiliates": null, "highlights": null}},"before": {"id": "12","handle": "jack","verified": false,"private": false,"sensitive": false,"restricted": false,"joined_at": 1142974088000,"profile": {"name": "jack","affiliates": false,"pinned": ["1979056517203501375"],"location": "Bluesky","avatar": "https://pbs.twimg.com/profile_images/12/avatar.jpg","banner": "https://pbs.twimg.com/profile_banners/12/banner.jpg","url": { "name": "cash.app/$jack", "url": "https://cash.app/$jack", "tco": "https://t.co/OldS" },"badge": { "type": null, "affiliation": null },"description": { "text": "bitcoin, nostr", "urls": [] },"about": null},"metrics": {"likes": 30, "media": 22, "friends": 18, "tweets": 274,"following": 18, "followers": 6500000,"affiliates": null, "highlights": null}},"modifications": ["profile.url"]}
"profile.url" appears in modifications when the user's website changes (e.g. https://google.com → https://x.com). The diff compares the resolved expanded URL only, so t.co rotations don't surface as a modification.
user.profile.url is set when the user has a website, null otherwise. Shape: { name: display_url, url: expanded_url, tco: t.co_short }. Some profiles only expose a bare URL; in that case name and tco are empty strings, so fall back to a stripped form of profile.url.url for display.
following.update / unfollowing.update
Two related event types — one for follows, one for unfollows. user is the watched account that performed the action.
following.update carries the target on following.
{"id": "114bb2a1-8f88-9df3-f9d1-7874f0bc8d2a","type": "following.update","user": {"id": "12","handle": "jack","verified": false,"private": false,"sensitive": false,"restricted": false,"joined_at": 1142974088000,"profile": {"name": "jack","affiliates": false,"pinned": ["1979056517203501375"],"location": "Bluesky","avatar": "https://pbs.twimg.com/profile_images/12/avatar.jpg","banner": "https://pbs.twimg.com/profile_banners/12/banner.jpg","url": null,"badge": { "type": null, "affiliation": null },"description": { "text": "bitcoin, nostr", "urls": [] },"about": null},"metrics": {"likes": 30, "media": 22, "friends": 19, "tweets": 274,"following": 19, "followers": 6500000,"affiliates": null, "highlights": null}},"following": {"id": "1233323","handle": "vercel","verified": true,"private": false,"sensitive": false,"restricted": false,"joined_at": 1287008784000,"profile": {"name": "Vercel","affiliates": false,"pinned": [],"location": "San Francisco","avatar": "https://pbs.twimg.com/profile_images/1233323/avatar.jpg","banner": "https://pbs.twimg.com/profile_banners/1233323/banner.jpg","url": { "name": "vercel.com", "url": "https://vercel.com", "tco": "https://t.co/V3rcL" },"badge": { "type": "BUSINESS", "affiliation": null },"description": { "text": "Frontend cloud.", "urls": [] },"about": null},"metrics": {"likes": 12345, "media": 8932, "friends": 412, "tweets": 12482,"following": 412, "followers": 524100,"affiliates": null, "highlights": null}}}
unfollowing.update carries the target on unfollowing.
{"id": "e4208898-0206-32e8-0fb1-77aa661b0e3e","type": "unfollowing.update","user": {"id": "12","handle": "jack","verified": false,"private": false,"sensitive": false,"restricted": false,"joined_at": 1142974088000,"profile": {"name": "jack","affiliates": false,"pinned": ["1979056517203501375"],"location": "Bluesky","avatar": "https://pbs.twimg.com/profile_images/12/avatar.jpg","banner": "https://pbs.twimg.com/profile_banners/12/banner.jpg","url": null,"badge": { "type": null, "affiliation": null },"description": { "text": "bitcoin, nostr", "urls": [] },"about": null},"metrics": {"likes": 30, "media": 22, "friends": 17, "tweets": 274,"following": 17, "followers": 6500000,"affiliates": null, "highlights": null}},"unfollowing": {"id": "11348282","handle": "nasa","verified": true,"private": false,"sensitive": false,"restricted": false,"joined_at": 1229040000000,"profile": {"name": "NASA","affiliates": false,"pinned": [],"location": "Pale Blue Dot","avatar": "https://pbs.twimg.com/profile_images/11348282/avatar.jpg","banner": "https://pbs.twimg.com/profile_banners/11348282/banner.jpg","url": { "name": "nasa.gov", "url": "https://nasa.gov", "tco": "https://t.co/NaSa" },"badge": { "type": "GOVERNMENT", "affiliation": null },"description": { "text": "Explore the universe.", "urls": [] },"about": null},"metrics": {"likes": 24812, "media": 31204, "friends": 392, "tweets": 91402,"following": 392, "followers": 78400000,"affiliates": null, "highlights": null}}}
profile.pinned.update / profile.unpinned.update
Fires when a watched account pins or unpins a tweet. pinned / unpinned is an array of full tweet objects (with chain + enrichment, same shape as tweet.expanded.update) so you don't need a follow-up lookup.
{"id": "0123abcd-9f4c-4b21-83d1-aa72ef0c2210","type": "profile.pinned.update","user": {"id": "12","handle": "jack","verified": false,"private": false,"sensitive": false,"restricted": false,"joined_at": 1142974088000,"profile": {"name": "jack","affiliates": false,"pinned": ["1979056517203501375"],"location": "Bluesky","avatar": "https://pbs.twimg.com/profile_images/12/avatar.jpg","banner": "https://pbs.twimg.com/profile_banners/12/banner.jpg","url": null,"badge": { "type": null, "affiliation": null },"description": { "text": "bitcoin, nostr", "urls": [] },"about": null},"metrics": {"likes": 30, "media": 22, "friends": 18, "tweets": 274,"following": 18, "followers": 6500000,"affiliates": null, "highlights": null}},"pinned": [{"id": "1979056517203501375","type": "TWEET","subtweet": null,"created_at": 1764118000000,"author": {"id": "12","handle": "jack","verified": false,"private": false,"sensitive": false,"restricted": false,"joined_at": 1142974088000,"profile": {"name": "jack","affiliates": false,"pinned": ["1979056517203501375"],"location": "Bluesky","avatar": "https://pbs.twimg.com/profile_images/12/avatar.jpg","banner": "https://pbs.twimg.com/profile_banners/12/banner.jpg","url": null,"badge": { "type": null, "affiliation": null },"description": { "text": "bitcoin, nostr", "urls": [] },"about": null},"metrics": {"likes": 30, "media": 22, "friends": 18, "tweets": 274,"following": 18, "followers": 6500000,"affiliates": null, "highlights": null}},"reply": null, "quoted": null, "retweet": null,"poll": null, "card": null, "article": null, "grok": null, "community": null,"editable": null,"body": {"text": "the goal is freedom","translation": { "available": false, "result": null },"urls": [], "mentions": [], "components": []},"media": {"images": ["https://pbs.twimg.com/media/F8jJpEt.jpg"],"videos": [],"thumbnails": ["https://pbs.twimg.com/media/F8jJpEt.jpg"]},"metrics": {"likes": 87241, "quotes": 1203, "replies": 4129, "retweets": 9874,"advanced": { "views": 12483290 }}}]}
watched.update
Fires on your own socket when you add or remove an account from your watchlist via the REST API. Lets you keep a local UI in sync with the server-side list without a separate fetch.
watch.type is "added" or "removed". watch.id is the X numeric id, watch.handle is the X handle.
{"id": "5b0a73f2-...","type": "watched.update","watch": {"type": "added","id": "12","handle": "jack"}}
List watches.
Returns every handle on your watchlist with pagination.
/api/feed/watches?page=1&size=25curl https://pootracker.app/api/feed/watches \-H "x-feed-key: YOUR_FEED_KEY"
{"type": "watches.snapshot","ts": 1746987449023,"data": {"items": [{"handle": "elonmusk","id": "44196397","speed": "LITE","added_at_ms": 1746987448849}],"page": { "current": 1, "size": 25, "total_pages": 1, "total_items": 1 }}}
speed is one of LITE, PRO, EDGE, PRIVATE — derived from your membership tier at the time the watch was added.
Add a watch.
Three input shapes accepted: {handle}, {id} (X numeric id), or {tokens: [...]} for bulk — up to 500 mixed handles and ids in one request. The missing field of each entry is resolved automatically. On conflict the single-add response returns an error envelope (cap_reached, already_watching, not_found); the bulk response always 201s with per-item failed and skipped arrays.
/api/feed/watchescurl -X POST https://pootracker.app/api/feed/watches \-H "x-feed-key: YOUR_FEED_KEY" \-H "content-type: application/json" \-d '{"handle": "vitalik"}'
{"type": "watches.add","ts": 1746987449023,"data": {"item": {"handle": "vitalik","id": "295218901","speed": "LITE","added_at_ms": 1746987449000}}}
{"type": "watches.bulk_add","ts": 1746987449023,"data": {"added": [{ "handle": "vitalik", "id": "295218901", "speed": "LITE", "added_at_ms": 1746987449000 },{ "handle": "jack", "id": "12", "speed": "LITE", "added_at_ms": 1746987449000 }],"failed": [{ "input": "doesnotexist", "code": "not_found", "message": "X handle not found." }],"skipped": [{ "input": "vitalik", "code": "already_watching", "message": "Already watching @vitalik." }],"counts": { "requested": 4, "added": 2, "failed": 1, "skipped": 1 }}}
Remove a watch.
Drops the handle off your watchlist. Path param accepts either the handle or the X numeric id — digits resolve to id, anything else resolves to handle.
/api/feed/watches/{handle_or_id} · /api/feed/watches (bulk)Single delete uses the URL param. Bulk delete uses the bare endpoint with a JSON body {tokens: [...]} — up to 500 mixed handles and ids. Tokens that aren't on your watchlist come back in failed with code not_watching instead of failing the whole request.
curl -X DELETE https://pootracker.app/api/feed/watches/vitalik \-H "x-feed-key: YOUR_FEED_KEY"
{"type": "watches.remove","ts": 1746987603120,"data": { "handle": "vitalik", "id": "295218901" }}
{"type": "watches.bulk_remove","ts": 1746987603120,"data": {"removed": [{ "handle": "vitalik", "id": "295218901", "speed": "LITE", "added_at_ms": 1746000000000 },{ "handle": "jack", "id": "12", "speed": "LITE", "added_at_ms": 1746000000000 }],"failed": [{ "input": "doesnotexist", "code": "not_watching", "message": "Not on watchlist." }],"counts": { "requested": 3, "removed": 2, "failed": 1 }}}
Error envelope.
Every error from the REST API and the websocket uses the same shape. Inspect code to branch programmatically; message is for humans.
{"type": "error","ts": 1746988900000,"code": "cap_reached","message": "Cap reached (3)."}
Common codes:
auth.unauthorized— missing or bad credentialsauth.forbidden— authed but not allowed (e.g. trial limits)cap_reached— your watchlist hit its capalready_watching— handle is already on your listnot_found— handle does not exist on Xinternal— something on our side; tell us