# PooTracker — full developer reference > Complete plain-text mirror of https://pootracker.app/docs. > Auto-generated from app/docs/page.tsx at build time. Wherever you see `YOUR_FEED_KEY`, substitute your own. > For the short overview, see https://pootracker.app/llms.txt ## 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](/dashboard). Copy the `feed key` shown there. It is the only secret you need. 2. **Add at least one handle** — Go to [/dashboard/watches](/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. **node** ```ts 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 resolved case "tweet.expanded.update": // enriched (badges, views, editable) case "tweet.deleted.update": // tweet removed console.log(evt.type, "@" + evt.tweet.author.handle, evt.tweet.id) break case "profile.update": console.log(evt.type, "@" + evt.user.handle, evt.modifications) break case "following.update": case "unfollowing.update": console.log(evt.type, "@" + evt.user.handle, "→ @" + evt.following.handle) break } }) ``` **python** ```python import json, websocket def on_message(ws, raw): evt = json.loads(raw) t = evt["type"] if t.startswith("tweet."): print(t, "@" + evt["tweet"]["author"]["handle"], evt["tweet"]["id"]) elif t == "profile.update": print(t, "@" + evt["user"]["handle"], evt["modifications"]) elif t.startswith("following."): print(t, "@" + evt["user"]["handle"], "->", "@" + evt["following"]["handle"]) websocket.WebSocketApp( "wss://feed.pootracker.app/v1?key=YOUR_FEED_KEY", on_message=on_message, ).run_forever() ``` **shell** ```bash # inspect the socket from the cli websocat "wss://feed.pootracker.app/v1?key=YOUR_FEED_KEY" ``` ## WebSocket. One persistent connection per process. We push every event the moment it lands; nothing is queued. **endpoint**: `wss://feed.pootracker.app/v1` **event format**: `JSON, one event per message` Reconnect 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_KEY` header (recommended) - `Authorization: Bearer YOUR_FEED_KEY` header - `?key=YOUR_FEED_KEY` query string (also how the websocket accepts it) ```ts const ws = new WebSocket("wss://feed.pootracker.app/v1", { headers: { "x-feed-key": "YOUR_FEED_KEY" }, }) ``` > **⚠ warn** — The feed key is a bearer token. Anyone holding it can read your feed and modify your watchlist. Rotate it from [/dashboard](/dashboard) if it leaks. ## 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: ``. Switch on `type`. A new tweet from a watched account produces up to **four** events, in this order: 1. `tweet.update` — outer tweet only, no chain yet. Fastest. 1. `tweet.subtweet.update` — outer tweet plus the full recursive subtweet chain. Fully enriched: view counts, verified status, edit metadata. 1. `tweet.expanded.update`: fully-enriched chain. Fires only when the outer tweet carries a `poll`, `article`, or `grok`. Supersedes the earlier frames for that id. Dedupe by `tweet.id`; latest enrichment wins. 1. `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 `` stub. ```json { "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. ```json { "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. ```json { "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. ```json { "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. ```ts 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..." ``` > **✓ tip** — `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. ```json { "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: ``. 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`. ```json { "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`. ```json { "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. ```json { "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. ```json { "id": "5b0a73f2-...", "type": "watched.update", "watch": { "type": "added", "id": "12", "handle": "jack" } } ``` ## List watches. Returns every handle on your watchlist with pagination. **GET**: `/api/feed/watches?page=1&size=25` ```bash curl 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: ``, `` (X numeric id), or `` 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. **POST**: `/api/feed/watches` **by handle** ``` curl -X POST https://pootracker.app/api/feed/watches \\ -H "x-feed-key: YOUR_FEED_KEY" \\ -H "content-type: application/json" \\ -d '{"handle": "vitalik"}' ``` **by id** ``` curl -X POST https://pootracker.app/api/feed/watches \\ -H "x-feed-key: YOUR_FEED_KEY" \\ -H "content-type: application/json" \\ -d '{"id": "295218901"}' ``` **bulk (≤ 500)** ``` curl -X POST https://pootracker.app/api/feed/watches \\ -H "x-feed-key: YOUR_FEED_KEY" \\ -H "content-type: application/json" \\ -d '{"tokens": ["vitalik", "@jack", "295218901", "12"]}' ``` ``` { "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. **DELETE**: `/api/feed/watches/ · /api/feed/watches (bulk)` Single delete uses the URL param. Bulk delete uses the bare endpoint with a JSON body `` — 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. **by handle** ``` curl -X DELETE https://pootracker.app/api/feed/watches/vitalik \\ -H "x-feed-key: YOUR_FEED_KEY" ``` **by id** ``` curl -X DELETE https://pootracker.app/api/feed/watches/295218901 \\ -H "x-feed-key: YOUR_FEED_KEY" ``` **bulk (≤ 500)** ``` curl -X DELETE https://pootracker.app/api/feed/watches \\ -H "x-feed-key: YOUR_FEED_KEY" \\ -H "content-type: application/json" \\ -d '{"tokens": ["vitalik", "@jack", "295218901", "12"]}' ``` ``` { "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. ```json { "type": "error", "ts": 1746988900000, "code": "cap_reached", "message": "Cap reached (3)." } ``` Common codes: - `auth.unauthorized` — missing or bad credentials - `auth.forbidden` — authed but not allowed (e.g. trial limits) - `cap_reached` — your watchlist hit its cap - `already_watching` — handle is already on your list - `not_found` — handle does not exist on X - `internal` — something on our side; tell us