Twitter WebSocket API: real-time tweet stream
Xanguard B2B streams tweets, deleted tweets, follows and unfollows, profile changes and new followers for the Twitter/X accounts you monitor, over one WebSocket connection. Paid plans get ~200 ms median detection, measured and published live. Manage handles with the B2B REST API.
Get a key in Telegram. Pick a plan in @B2B_Xanguard_bot (from $49/mo for 50 handles); your key arrives in the payment confirmation. Paid in SOL with automatic activation; payments are final and non-refundable.
B2B Realtime WebSocket
High-volume real-time feed with TweetCatcher-compatible opcode protocol. Delivers tweets, follow detection, profile changes, and new follower alerts via a single WebSocket connection. Managed via @B2B_Xanguard_bot.
Migrating from twitterapi.io or TweetCatcher
Both migrations are available now (overview):
- twitterapi.io is a true drop-in: same routes, same response shapes, one base-URL change. Keep your
X-API-Keyheader. Guide: migrate from twitterapi.io. - TweetCatcher clients keep the same WebSocket opcode flow (HELLO, LOGIN, READY, EVENT, HEARTBEAT). Change the URL and key, then map the few payload fields that differ. Most moves take under an hour. Guide: TweetCatcher vs Xanguard.
Connection
wss://api.xanguard.tech/v1/dt/realtime/wsNo query parameter needed — authentication happens via the LOGIN opcode after connecting. Maximum 5 concurrent connections per account, counted across all B2B WebSocket URLs (/v1/dt/realtime/ws and the tweet-only /v1/dt/ws?api_key= and /twitter/tweet/websocket).
Opcodes
| Opcode | Name | Direction | Description |
|---|---|---|---|
10 | HELLO | Server → Client | Sent on connect. Includes heartbeat_interval (ms). |
2 | LOGIN | Client → Server | Send your dt_ API key to authenticate, as a text frame, within 15 seconds. |
4 | READY | Server → Client | Auth successful. Includes client_id, modules, handles (count at login) and max_handles. |
0 | EVENT | Server → Client | Realtime event data. |
1 | HEARTBEAT | Client → Server | Keep-alive. Send every heartbeat_interval ms. |
11 | HEARTBEAT_ACK | Server → Client | Response to heartbeat. |
3 | DISCONNECT | Server → Client | Connection terminated with reason. |
Handshake Flow
1. Connect → Receive HELLO:
{"op": 10, "d": {"heartbeat_interval": 30000}}2. Send LOGIN with your API key as a text frame. d is the key string, or an object {"api_key": "dt_..."} (apiKey is also accepted; optional filters are on the REST page). Any non-text frame before LOGIN ends the session with Login timeout.
{"op": 2, "d": "dt_your_api_key_here"}3. Receive READY on success. These are all of its keys; the connection limit is not included. modules can also list late_delivery, ca_search and search_*.
{"op": 4, "d": {"client_id": 1, "modules": ["realtime", "follows", "profile_watch", "followers"], "handles": 150, "max_handles": 1000}}4. Send a HEARTBEAT ({"op": 1}) every heartbeat_interval ms (30s). The server replies with HEARTBEAT_ACK (op 11). If it receives no heartbeat for 90 seconds it closes the connection (the check runs every 30s, so this happens 90 to 120 seconds after your last heartbeat, without a DISCONNECT frame) — reconnect and re-login. A server-side watchdog additionally drops any connection with no inbound frame of any kind for 180 seconds, so a half-open TCP session cannot silently stop receiving events.
Reconnects & missed events
Brief maintenance reconnects happen: the server can drop WebSocket connections for a few seconds without a DISCONNECT warning. This is normal operation, not an outage.
By default, tweet events are not replayed — there is no resume cursor, and tweets older than 30 seconds are dropped. If your use case cannot tolerate a few seconds of possible loss, run two connections from different processes/machines (max 5 concurrent per account) so one is always up, or ask support for late delivery (below). Follow/unfollow events are different: on every successful LOGIN we automatically replay the last 15 minutes of follow/unfollow events for your handles (READY → replay → live; needs the follows module), so a reconnect recovers them.
Late delivery and tweet replay (opt-in, per key, on request). Keys with the late_delivery module get two extras for twitter.post.new. (1) Late tweets up to 24 hours old are delivered with d.delayed: true and d.delay_ms (milliseconds since posting) instead of being dropped; they are still dropped if your connection is more than 30 seconds behind, if the tweet predates your adding the handle, or if you already received it. (2) Right after READY on each LOGIN, tweets detected in the last 15 minutes for your handles (newest 500) are replayed before live ones, with d.replayed: true and the live event_id. Replayed tweets carry the core fields only: no reply/quote body, entities, conversation_id or metrics, and no twitter.post.update follows them.
What a correct client does:
- Reconnect immediately when the socket closes — do not wait for the heartbeat timeout to notice. Use short backoff (1s, 2s, 5s, then cap at 30s).
- After reconnecting, send LOGIN again and wait for READY (op 4) before considering yourself live. A bare TCP/WebSocket connect without READY means you are receiving nothing.
- Keep heartbeating every
heartbeat_intervaland require HEARTBEAT_ACK; if two ACKs go missing, reconnect proactively. - Log connection drops with timestamps. If you report a gap to support, include the exact UTC window — we keep per-connection logs and can confirm what happened.
Disconnect reasons
Login, key, connection-limit and subscription problems send DISCONNECT (op 3) before closing. The socket closes without one when heartbeats stop (90 to 120 seconds after the last), after 180 seconds with no inbound frame, when a write to you blocks for 5 seconds, and on server restarts. d.reason is the short legacy string (unchanged, so existing clients that match on it keep working). Every DISCONNECT also carries d.code (stable, match on this), d.message (plain-English explanation, worth logging) and, where a retry will work later, d.retry_after in seconds.
{"op": 3, "d": {"reason": "Invalid or expired API key", "code": "key_missing",
"message": "No API key was sent (the key was empty). Check that the variable or config holding your dt_ key is set on this machine. 4 more failed logins from this IP within 60s block it for 1 hour."}}reason | code | Retry? | What to do |
|---|---|---|---|
| Invalid or expired API key | key_missing | No | The LOGIN carried an empty key. Set the key in your config/env on that machine. |
| Invalid or expired API key | key_malformed | No | Not a dt_ key. Copy the full key from @B2B_Xanguard_bot. |
| Invalid or expired API key | key_unknown | No | Typo, cut-off key, or an old key you regenerated. |
| Invalid or expired API key | key_expired | No | Subscription expired on the date in message. Renew in @B2B_Xanguard_bot; the key stays the same. |
| Invalid or expired API key | key_deactivated | No | Contact support. |
| Invalid or expired API key | key_blocked | After retry_after | This key failed 5 logins within 60 seconds and is blocked for 1 hour. Check it is the full, current key. |
| Too many failed logins | ip_blocked | After retry_after | See below. Your key is not rate limited; fix what your client sends. |
| Invalid login payload | login_invalid | No | LOGIN must be {"op":2,"d":"dt_your_key"} or {"op":2,"d":{"api_key":"dt_your_key"}}. |
| Login timeout | login_timeout | Yes | No valid LOGIN within 15 seconds of HELLO. Send LOGIN right after HELLO; op must be the number 2. |
| Too many connections (max 5) | too_many_connections | After retry_after | The key already has 5 open sockets. Close ones you no longer use (an old process or another server); dropped sockets are released within 3 minutes. |
| Subscription expired | subscription_expired | No | Your plan ended mid-session. Renew in @B2B_Xanguard_bot; the key stays the same. |
Failed-login IP block. 5 failed logins (missing, wrong or expired key) from one IP within 60 seconds block that IP for 1 hour on every B2B WebSocket URL. Each failed login's message counts down how many attempts are left. While blocked, connections get HTTP 429 with a JSON body (code: "ip_blocked", the unblock time) and a Retry-After header; on /v1/dt/realtime/ws about once a minute the connection is instead accepted just to deliver the DISCONNECT above, so the reason shows up in your client's logs. Retrying during the block does not extend it. The usual cause is a new server or deploy where the key variable is unset.
Modules
Modules are configured per client. You only receive events for enabled modules.
| Module | Events | Description |
|---|---|---|
realtime | twitter.post.new, twitter.post.update, twitter.tweet.deleted | Tweets, replies, quotes, retweets in real-time. Initial delivery via twitter.post.new; a twitter.post.update context correction when needed. Plus deleted-tweet alerts on every plan. |
follows | twitter.following.new, twitter.following.removed | When a tracked account follows or unfollows someone (follows ~1-3s, unfollows ~3-6s typical) |
profile_watch | twitter.profile.update | Bio, name, avatar, banner, location, website, verified badge, pinned post and handle changes (usually within a few seconds; pinned-post changes can take up to a minute) |
followers | twitter.follower.new | New followers of tracked accounts (usually within 30 minutes; Pro and Enterprise) |
Delivery semantics: by default the stream is real-time only — tweets older than 30 seconds are dropped (no backfill on connect; see late delivery above). On LOGIN the server replays twitter.following.new / twitter.following.removed events from the last 15 minutes for your subscribed handles, in chronological order, right after READY — replayed unfollows reuse the live event_id; a replayed follow's id can differ from the live one by a few milliseconds, so dedupe follows on task_info.handle + data.id within 15 minutes. Several events detected together can share one event_id: dedupe follow, unfollow and new-follower events on event_id + data.id, and deleted-tweet events on event_id + data.tweet_id. Deleted-tweet events are on for every plan and new-follower events on Pro and Enterprise; both switch on automatically.
Event: twitter.post.new
First delivery of a detected tweet — the fastest event, and it carries the full payload: text, media, author, mentions, and ocr_text when OCR is enabled. The only fields that can be incomplete are reply/quote context (a tweet can arrive with its in_reply_to reference or quoted_tweet body missing) and, rarely, the body of a very long post (270+ chars). In either case a twitter.post.update follows with those filled in. Store on event_id and merge the update if it arrives.
{
"op": 0,
"d": {
"event": "twitter.post.new",
"event_id": "evt_1234567890123456789",
"task_info": {"handle": "elonmusk"},
"data": {
"id": "1234567890123456789",
"created_at": 1712000000000,
"type": "post",
"text": "Hello world",
"media": [{"type": "photo", "url": "https://pbs.twimg.com/media/..."}],
"mentions": [],
"author": {
"id": "44196397",
"handle": "elonmusk",
"name": "Elon Musk",
"avatar": "https://pbs.twimg.com/profile_images/...",
"description": "CEO of Tesla, SpaceX, etc.",
"verification": {"is_verified": true, "type": "blue"},
"affiliation": null,
"stats": {"followers": 190000000, "following": 800}
},
"in_reply_to": null,
"quoted_tweet": null,
"possibly_sensitive": false,
"conversation_id": "1896324800275427532",
"entities": {
"hashtags": [{"text": "PEPE", "indices": [6, 11]}],
"urls": [{"url": "https://t.co/abc", "expanded_url": "https://example.com/article", "display_url": "example.com/article", "indices": [12, 35]}],
"symbols": [{"text": "PEPE", "indices": [6, 11]}]
},
"metrics": {"likes": 1523, "retweets": 402, "replies": 89, "quotes": 41, "views": "210443"},
"cashtags": ["PEPE"],
"ocr_text": "GIGACHAD\nCA: 7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
"extracted_cas": ["7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU"]
}
}
}Tweet types in data.type: "post", "reply", "quote", "repost"
Other fields: in_reply_to is null or {"tweet_id": "...", "user": "parent_handle"} (either may be null). media[].type is always "photo"; videos show as their preview image. mentions holds Twitter user_mention objects (screen_name, name, id_str, indices) when available, but can be null or [] even when the tweet mentions accounts, so parse @handles from text; entities has no user_mentions. created_at is epoch milliseconds (or null); tweet events have no event_time.
Entities & metrics: entities is Twitter's own entities object verbatim (hashtags, urls with expanded destinations, symbols for $cashtags) — use it instead of parsing text. metrics carries engagement counters (likes, retweets, replies, quotes, views) as of detection time; any field can be null when it was not available at detection. conversation_id groups a tweet with its thread. For quote/reply/repost events, the source tweet is in quoted_tweet (id, text, author, media) — on replies it carries the parent tweet.
Verification badges: author.verification.type is "blue" (individual), "business" (gold/org), "government" (grey/gov), or null (not verified) — don't treat is_verified alone as a blue check, it is true for all badge types. Affiliated accounts also carry author.affiliation — {"label": "...", "icon_url": "..."} with the small organization/government logo shown next to the handle; null otherwise. A badge change can take a while to show. Embedded tweets (quoted_tweet, also the parent on replies) include the quoted author's avatar alongside handle/name.
Cashtags: data.cashtags is an array of the cashtags ($TICKER) found in the tweet text — in order of appearance, deduped case-insensitively, without the leading $, case preserved (e.g. ["PEPE","doge"]). Dollar amounts ($100) are never matched. Present on every tweet event (empty array when none); for reposts the cashtags come from the original tweet's text, since that is the repost's content. Tweets made with Twitter's token/chart composer (text reads <chain>:<address>, e.g. ethereum:0x…) carry their ticker too.
OCR & contract extraction (Enterprise): On Enterprise plans every tweet event carries two extra fields inside data: ocr_text — the text read out of the tweet's image(s), or null if none — and extracted_cas — an array of contract addresses (Solana mints, pump.fun links, and EVM 0x addresses) parsed from that image text ([] if none). For quote tweets, the quoted tweet's images are included (that's usually where the CA image lives); reposts are OCR'd on the original tweet's media. This catches contract addresses posted as images to dodge text scanners, so you see the CA the moment it drops. Both fields are omitted entirely on plans without OCR.
Event: twitter.post.update
A context correction for a previously sent tweet — emitted only when a reply or quote first shipped without its full context, when a very long post (270+ chars) first shipped with a truncated body, or (for OCR-enabled accounts) when a tweet first shipped without media that was recovered afterwards. It re-delivers the same payload with type, in_reply_to, quoted_tweet corrected, the complete text in the long-post case, and, in the media-recovery case, media / ocr_text / extracted_cas filled in; every other field already arrived complete in twitter.post.new. Shares the same data.id and the same event_id (evt_<tweet_id>) as the original — dedupe on event_id and replace the stored data for this tweet.
{
"op": 0,
"d": {
"event": "twitter.post.update",
"event_id": "evt_1234567890123456789",
"task_info": {"handle": "elonmusk"},
"data": {
"id": "1234567890123456789",
"created_at": 1712000000000,
"type": "quote",
"text": "This is huge 👀",
"media": [],
"mentions": [],
"author": {
"id": "44196397",
"handle": "elonmusk",
"name": "Elon Musk",
"avatar": "https://pbs.twimg.com/profile_images/...",
"description": "CEO of Tesla, SpaceX, etc.",
"verification": {"is_verified": true, "type": "blue"},
"affiliation": null,
"stats": {"followers": 190000000, "following": 800}
},
"in_reply_to": null,
"quoted_tweet": {
"id": "9876543210987654321",
"text": "Original tweet text here",
"author": {"handle": "vitalikbuterin", "name": "Vitalik Buterin", "avatar": "https://pbs.twimg.com/profile_images/..."},
"media": ["https://pbs.twimg.com/media/..."]
},
"possibly_sensitive": false
}
}
}In an update, quoted_tweet may have no media and quoted_tweet.author.name may be null.
Non-tweet event ids (evt_f_, evt_fr_, evt_p_, evt_fl_, evt_del_) are <prefix><handle>_<ms>, where the suffix is epoch milliseconds equal to event_time.
Event: twitter.tweet.deleted
Fires when a tracked account deletes a tweet, usually within about 2 minutes of the deletion (each deletion is confirmed before it is sent). Tweets deleted together each get an event, sharing one event_id: dedupe on event_id + data.tweet_id. Guide: deleted tweets.
{
"op": 0,
"d": {
"event": "twitter.tweet.deleted",
"event_id": "evt_del_elonmusk_1712000000000",
"event_time": 1712000000000,
"task_info": {"handle": "elonmusk"},
"data": {
"tweet_id": "1234567890123456789",
"text": "the tweet as it was posted"
}
}
}data.tweet_id is the deleted tweet's id and data.text is its text as last seen (the deleted tweet is no longer readable on X). Both are null when only the fact of a deletion is known and the specific tweet could not be determined. Match tweet_id against the data.id of the twitter.post.new you received earlier.
Event: twitter.following.new
{
"op": 0,
"d": {
"event": "twitter.following.new",
"event_id": "evt_f_elonmusk_1712000000000",
"event_time": 1712000000000,
"task_info": {"handle": "elonmusk", "id": "44196397"},
"data": {
"id": "44196397",
"handle": "vitalikbuterin",
"name": "Vitalik Buterin",
"bio": "Ethereum",
"followers": 5200000,
"following": 350,
"protected": false
}
}
}Event: twitter.following.removed
Fires when a tracked account unfollows someone it previously followed. Same data shape as twitter.following.new — it identifies the account that was unfollowed. data.handle is "" and data.name is null when that profile is no longer available; bio, followers, following and protected can be null on both follow events. task_info.id is the tracked account's user id.
{
"op": 0,
"d": {
"event": "twitter.following.removed",
"event_id": "evt_fr_elonmusk_1712000000000",
"event_time": 1712000000000,
"task_info": {"handle": "elonmusk", "id": "44196397"},
"data": {
"id": "44196397",
"handle": "vitalikbuterin",
"name": "Vitalik Buterin",
"bio": "Ethereum",
"followers": 5200000,
"following": 350,
"protected": false
}
}
}Testing follow / unfollow: add a Twitter account you control via POST /v1/dt/targets, connect the WebSocket, then from that account follow (or unfollow) any other user on Twitter. Typically within a second or two (allow up to a minute on very large accounts, where Twitter itself is slow to register the follow) you'll get a twitter.following.new (or twitter.following.removed) event on the stream. Expect a short delay rather than instant delivery. Requires the follows module enabled on your account.
Event: twitter.profile.update
{
"op": 0,
"d": {
"event": "twitter.profile.update",
"event_id": "evt_p_elonmusk_1712000000000",
"event_time": 1712000000000,
"task_info": {"handle": "elonmusk"},
"data": {
"field": "description",
"prev": "Old bio text",
"updated": "New bio text"
}
}
}field is one of: "bio" or "description" (both mean the bio), "name" and "display_name" (a name change arrives under both), "avatar" and "avatar_url" (same), "banner_url", "location", "website", "verified" (prev/updated are "true"/"false"), "pinned_post" (comma-separated tweet ids, "" if none) and "handle" (rename: prev = old handle, updated = new; task_info.handle is the new handle). prev/updated are always strings, "" when empty.
Event: twitter.follower.new
{
"op": 0,
"d": {
"event": "twitter.follower.new",
"event_id": "evt_fl_elonmusk_1712000000000",
"event_time": 1712000000000,
"task_info": {"handle": "elonmusk", "id": "44196397"},
"data": {
"id": "555555555",
"handle": "newfollower",
"name": "New Follower"
}
}
}Example: JavaScript Client
Logs in, heartbeats every heartbeat_interval, waits for READY, and reconnects with backoff. It stops (instead of looping) on a bad key, because 5 failed logins within 60 seconds block the source for an hour.
// Browser, or Node.js 22+ (global WebSocket). Older Node: const WebSocket = require('ws');
const WS_URL = 'wss://api.xanguard.tech/v1/dt/realtime/ws';
const API_KEY = 'dt_YOUR_API_KEY';
const FATAL = ['Invalid or expired API key', 'Subscription expired', 'Invalid login payload'];
let backoff = 1000; // ms, doubles per failed attempt, capped at 30s
let stop = false;
function connect() {
const ws = new WebSocket(WS_URL);
let hb = null;
let lastAck = Date.now();
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
switch (msg.op) {
case 10: { // HELLO: log in, then heartbeat every heartbeat_interval ms
const interval = msg.d.heartbeat_interval;
ws.send(JSON.stringify({op: 2, d: API_KEY}));
hb = setInterval(() => {
if (Date.now() - lastAck > 2 * interval + 5000) return ws.close(); // 2 ACKs missed
ws.send(JSON.stringify({op: 1}));
}, interval);
break;
}
case 11: lastAck = Date.now(); break; // HEARTBEAT_ACK
case 4: // READY: now live
backoff = 1000;
console.log('Live:', msg.d.modules, msg.d.handles, 'handles');
break;
case 0: console.log('Event:', msg.d.event, msg.d.data); break;
case 3: // DISCONNECT
console.warn('Server disconnect:', msg.d && msg.d.reason);
if (msg.d && FATAL.includes(msg.d.reason)) stop = true; // fix the key, don't loop
break;
}
};
ws.onerror = () => ws.close();
ws.onclose = () => {
clearInterval(hb);
if (stop) return console.error('Not reconnecting: fix the API key or plan.');
const delay = backoff + Math.random() * 500;
backoff = Math.min(backoff * 2, 30000);
console.log(`Disconnected, reconnecting in ${Math.round(delay)} ms`);
setTimeout(connect, delay);
};
}
connect();Example: Python Client
Same behaviour in Python (asyncio). The heartbeat runs as a separate task, so the connection stays up past the 90-second timeout.
import asyncio, json, random, time
import websockets # pip install websockets
URL = "wss://api.xanguard.tech/v1/dt/realtime/ws"
API_KEY = "dt_YOUR_API_KEY"
FATAL = {"Invalid or expired API key", "Subscription expired", "Invalid login payload"}
class Fatal(Exception):
pass
async def heartbeat(ws, interval_s, state):
# Send op 1 every heartbeat_interval; the server drops you after 90s without one.
try:
while True:
await asyncio.sleep(interval_s)
if time.monotonic() - state["last_ack"] > 2 * interval_s + 5:
await ws.close() # two HEARTBEAT_ACKs missed: reconnect
return
await ws.send(json.dumps({"op": 1}))
except websockets.ConnectionClosed:
pass
async def run_once():
async with websockets.connect(URL) as ws:
hello = json.loads(await ws.recv()) # op 10 HELLO (op 3 if your IP is blocked)
if hello.get("op") == 3:
print("Server disconnect:", hello["d"].get("message"))
return False
interval_s = hello["d"]["heartbeat_interval"] / 1000
await ws.send(json.dumps({"op": 2, "d": API_KEY})) # op 2 LOGIN
state = {"last_ack": time.monotonic()}
hb = asyncio.create_task(heartbeat(ws, interval_s, state))
try:
async for raw in ws:
msg = json.loads(raw)
op = msg.get("op")
if op == 11: # HEARTBEAT_ACK
state["last_ack"] = time.monotonic()
elif op == 4: # READY: now live
print(f"Live: {msg['d']['modules']}, {msg['d']['handles']} handles")
state["ready"] = True
elif op == 0: # EVENT
print(f"{msg['d']['event']}: {msg['d']['data']}")
elif op == 3: # DISCONNECT
reason = (msg.get("d") or {}).get("reason")
print("Server disconnect:", reason)
if reason in FATAL:
raise Fatal(reason)
finally:
hb.cancel()
return state.get("ready", False)
async def main():
backoff = 1
while True:
try:
if await run_once():
backoff = 1 # we were live: reconnect fast
except Fatal as e:
print(f"Not reconnecting ({e}): fix the API key or plan.")
return
except (websockets.ConnectionClosed, websockets.exceptions.InvalidHandshake, OSError) as e:
print(f"Connection lost: {e}")
delay = backoff + random.random()
print(f"Reconnecting in {delay:.1f}s")
await asyncio.sleep(delay)
backoff = min(backoff * 2, 30)
asyncio.run(main())Pricing
Priced by module tier and handle count — see the full table under Xanguard B2B → Pricing.
Xanguard B2B
Xanguard B2B is a real-time Twitter/X monitoring API. It streams tweets, deleted-tweet alerts, follow/unfollow events, profile changes, and new follower detection for your tracked accounts via WebSocket and REST API. Managed via @B2B_Xanguard_bot.
Modules
| Module | Events |
|---|---|
| Realtime (tweets) | New tweets, replies, quotes, retweets |
| Deleted tweets | Alert when a tracked account deletes a tweet |
| Follows | Follow and unfollow detection |
| Profile Watch | Name, bio, avatar, banner, location, website, verified badge, pinned post and handle changes |
| Followers | New follower detection |
Bot Commands
| Command | Description |
|---|---|
/start | Dashboard & subscription |
/add @handle | Add handle to monitor |
/remove @handle | Remove handle |
/list | List all monitored handles |
/status | Subscription status |
/apikey | Issue a new API key (the old one stops working immediately) |
/referral | Referral program & payout wallet |
/help | Command reference |
Pricing
Three module tiers, priced by handle count (monthly). Paid in SOL in @B2B_Xanguard_bot with automatic activation; payments are final and non-refundable. Every product and plan: xanguard.tech/pricing.
| Handles | Starter tweets + deleted-tweet | Pro + profile + new-follower | Enterprise + follow/unfollow + OCR |
|---|---|---|---|
| 50 | $49/mo | $99/mo | $249/mo |
| 250 | $229/mo | $429/mo | $979/mo |
| 500 | $449/mo | $749/mo | $1,649/mo |
| 1000 | $849/mo | $1,349/mo | $2,849/mo |