no feed key yet — code samples show YOUR_FEED_KEY as a placeholdercheck dashboard →

[ 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. 1

    Grab your feed key

    Open /dashboard. Copy the feed key shown there. It is the only secret you need.

  2. 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. 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 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
}
})

WebSocket.

One persistent connection per process. We push every event the moment it lands; nothing is queued.

endpointwss://feed.pootracker.app/v1
event formatJSON, 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)
js
const ws = new WebSocket("wss://feed.pootracker.app/v1", {
headers: { "x-feed-key": "YOUR_FEED_KEY" },
})
!
The feed key is a bearer token. Anyone holding it can read your feed and modify your watchlist. Rotate it from /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: { "id": "<uuid>", "type": "<event.type>", ...payload }. 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.
  2. tweet.subtweet.update — outer tweet plus the full recursive subtweet chain. Fully enriched: view counts, verified status, edit metadata.
  3. 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.
  4. 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.

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.

js
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.

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: { 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.

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
curl · request
curl https://pootracker.app/api/feed/watches \
-H "x-feed-key: YOUR_FEED_KEY"
200 ok · response
{
"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.

POST/api/feed/watches
curl -X POST https://pootracker.app/api/feed/watches \
-H "x-feed-key: YOUR_FEED_KEY" \
-H "content-type: application/json" \
-d '{"handle": "vitalik"}'
201 created · single response
{
"type": "watches.add",
"ts": 1746987449023,
"data": {
"item": {
"handle": "vitalik",
"id": "295218901",
"speed": "LITE",
"added_at_ms": 1746987449000
}
}
}
201 created · bulk response
{
"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/{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"
200 ok · single response
{
"type": "watches.remove",
"ts": 1746987603120,
"data": { "handle": "vitalik", "id": "295218901" }
}
200 ok · bulk response
{
"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