Monitor a Twitter Account in Real Time (or Close): Tweet Alert Webhooks to Discord and Slack
How to monitor a Twitter account in real time or on a schedule: a tweet alert webhook to Discord or Slack, signature checks in Python and Node, and costs.
By Tarun Khatri · · 11 min read
To monitor a Twitter account in real time, with posts pushed to you within seconds, you need a streaming product: X's own Activity API or Filtered Stream, or a third party's real-time feed. If a delay of minutes to an hour is fine, a scheduled watcher is simpler and cheaper. It checks the account, keeps only what's new, and POSTs a tweet alert webhook to your server. This tutorial builds the scheduled version with a Weir monitor, a signed webhook receiver in Python (FastAPI) and Node, and forwarding to Discord or Slack. It also shows a polling loop for tighter intervals, and gives the cost of each option.
Disclosure: Weir is our product, and its monitors are scheduled, not streaming. If you need seconds, the options in the first section are the better fit, and they're listed with their real prices.
Monitor a Twitter account in real time, or on a schedule?
| Option | How it works | Delay | Price (as of October 2026) |
|---|---|---|---|
X Activity API, post.create |
X pushes an event per post, by stream or webhook | Real time | $0.005 per post event |
X Filtered Stream, from: rule |
Persistent connection with rules | About 4-5 seconds P99, per X's docs | Pay-per-use |
| TwitterAPI.io real-time | Webhook filter rules and WebSocket streams | Sub-second, per the vendor | From $29 a month |
| SocialData user tweets monitor | Webhook "shortly after a user posts" | Not stated as a number | $0.0069 an hour, about $4.99 a month per monitor |
Weir x_user monitor (ours) |
Scheduled check, new posts only, signed webhook | Up to an hour (paid) or a day (free) | 1 credit per run plus 1 per new post |
| Weir search polling (ours) | Your own loop with sinceId |
Your interval | 1 credit per poll plus 1 per new post |
X's Activity API is the most direct real-time route. post.create is a public event, so you can subscribe to any public account's posts with an app-only bearer token, and self-serve accounts get up to 1,500 subscriptions. Each delivered post event is billed as a post read, which is $0.005 on the rate card. For one account that posts ten times a day, that's about $1.50 a month. If you need alerts within seconds, use it.
Use a scheduled monitor when "within the hour" is enough, for example to watch competitors, track a few accounts for a newsletter, or alert a team channel. You also get deduplication and a signed webhook without running a stream connection.
Step 1: create the webhook
You need a Weir API key in WEIR_API_KEY and a public URL for the receiver. For local testing, any tunnel that gives you a public URL works.
curl --request POST \
--url https://api.weirapp.io/v1/webhooks \
--header "Authorization: Bearer $WEIR_API_KEY" \
--header 'Content-Type: application/json' \
--data '{
"name": "X account alerts",
"url": "https://hooks.example.com/weir",
"events": ["monitor.result.created"]
}'
The response's data includes the webhook's id and its secret. The secret is returned once and can't be retrieved again, so store it as WEIR_WEBHOOK_SECRET straight away.
Step 2: create an x_user monitor
A monitor with target.kind set to x_user watches one account's posts. handle is required and accepts a username, an @handle or a profile URL:
curl --request POST \
--url https://api.weirapp.io/v1/monitors \
--header "Authorization: Bearer $WEIR_API_KEY" \
--header 'Content-Type: application/json' \
--data '{
"name": "XDevelopers posts",
"target": { "kind": "x_user", "handle": "XDevelopers" },
"schedule": "hourly",
"webhookId": "PASTE-THE-WEBHOOK-ID"
}'
| Field | Values |
|---|---|
name |
1 to 120 characters |
target |
{"kind": "x_user", "handle": "..."} |
schedule |
manual, hourly, daily (the default), weekly |
webhookId |
The webhook's id, or null. You can add it later with PATCH /v1/monitors/{id} |
What to know about how it behaves, from the monitor docs and tier limits:
- Hourly needs a paid tier. The free tier allows one monitor and one webhook, on a
dailyorweeklyschedule. Any purchase from $5 moves you to Tier 1: 10 monitors, 5 webhooks, and hourly schedules. Without that, the example above gets rejected; use"daily"instead.GET /v1/limitsshows your account's limits. - It follows the account, not the name. The handle is resolved once and the account is followed by its numeric id after that, so a rename doesn't break the monitor.
- It reads the timeline. Each run reads the account's newest timeline posts: its own posts and reposts. Replies aren't included.
- "New" means new content. Results are deduplicated by a hash of the content, and only new ones are stored, charged and delivered.
- The first run reports what's already there. With nothing to compare against yet, the first run treats the posts currently on the timeline as new. Expect the first webhook to carry the account's recent posts, so trigger a run before you connect the Discord or Slack forwarding.
To test without waiting for the schedule:
curl --request POST --url "https://api.weirapp.io/v1/monitors/$MONITOR_ID/run" \
--header "Authorization: Bearer $WEIR_API_KEY" # returns 202; the run happens in the background
curl --url "https://api.weirapp.io/v1/monitors/$MONITOR_ID/runs" \
--header "Authorization: Bearer $WEIR_API_KEY" # how it went
POST /v1/webhooks/{id}/test sends a real signed webhook.test delivery. It's free, and it's the quickest way to check your receiver.
Step 3: a tweet alert webhook receiver in Python
Each delivery is a POST with a JSON body and three headers: weir-signature, weir-timestamp and weir-event-id. The signature is HMAC-SHA256(secret, "{timestamp}.{eventId}.{rawBody}"), hex encoded. Hash the raw bytes, not re-serialised JSON. Reject timestamps more than five minutes from now, and compare in constant time.
For an x_user monitor, the body looks like this. results holds up to 25 X posts in the same shape the API returns:
{
"id": "evt_...",
"type": "monitor.result.created",
"createdAt": "2026-10-19T09:00:12.000Z",
"data": {
"monitorId": "...",
"runId": "...",
"kind": "x_user",
"itemType": "post",
"newResults": 1,
"results": [
{
"id": "2111172163776708859",
"text": "New guide to the 𝕏 Chat API from @santiagomed: ...",
"url": "https://x.com/XDevelopers/status/2111172163776708859",
"createdAt": "2026-10-16T19:07:22.000Z",
"author": { "username": "XDevelopers", "name": "Developers" }
}
]
}
}
The post in results is a real one, trimmed, from a call on 19 October 2026.
The receiver verifies the signature, returns 200 straight away, and forwards in the background, because Weir expects a response within 10 seconds:
import hashlib
import hmac
import json
import os
import time
import httpx
from fastapi import BackgroundTasks, FastAPI, Request, Response
app = FastAPI()
SECRET = os.environ["WEIR_WEBHOOK_SECRET"].encode()
DISCORD_URL = os.environ.get("DISCORD_WEBHOOK_URL")
SLACK_URL = os.environ.get("SLACK_WEBHOOK_URL")
seen_events: set[str] = set() # use a database table in production
def verify(raw: bytes, signature: str, timestamp: str, event_id: str) -> bool:
try:
if abs(time.time() - int(timestamp)) > 300:
return False
except ValueError:
return False
message = f"{timestamp}.{event_id}.".encode() + raw
expected = hmac.new(SECRET, message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.encode(), signature.encode())
def forward(posts: list[dict]) -> None:
lines = [f"@{p['author']['username']}: {p['text'][:280]}\n{p['url']}" for p in posts]
text = "\n\n".join(lines)[:1900] # one message per delivery; Discord caps content at 2,000 chars
with httpx.Client(timeout=10) as client:
if DISCORD_URL:
client.post(DISCORD_URL, json={"content": text})
if SLACK_URL:
client.post(SLACK_URL, json={"text": text})
@app.post("/weir")
async def weir_webhook(request: Request, background: BackgroundTasks) -> Response:
raw = await request.body() # the exact bytes that were signed
headers = request.headers
event_id = headers.get("weir-event-id", "")
if not verify(raw, headers.get("weir-signature", ""), headers.get("weir-timestamp", ""), event_id):
return Response(status_code=400)
if event_id in seen_events: # deliveries are at least once
return Response(status_code=200)
seen_events.add(event_id)
event = json.loads(raw)
if event["type"] == "monitor.result.created" and event["data"]["results"]:
background.add_task(forward, event["data"]["results"])
return Response(status_code=200)
Run it with uvicorn app:app --port 8000. Discord's execute webhook takes a JSON content field of up to 2,000 characters. A Slack incoming webhook takes {"text": "..."}. Sending one message per delivery, not one per post, keeps you clear of both services' rate limits when a run finds several posts.
Step 4: the same receiver in Node
This uses Express. Use express.raw, not express.json, so the body arrives as the bytes that were signed. Node 18 and later have fetch built in.
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const seen = new Set<string>(); // use a database table in production
function verifyWeir(raw: Buffer, signature: string, timestamp: string, eventId: string): boolean {
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = createHmac("sha256", process.env.WEIR_WEBHOOK_SECRET!)
.update(`${timestamp}.${eventId}.`)
.update(raw)
.digest("hex");
return (
signature.length === expected.length &&
timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
);
}
async function forward(posts: Array<{ text: string; url: string; author: { username: string } }>) {
const text = posts
.map((p) => `@${p.author.username}: ${p.text.slice(0, 280)}\n${p.url}`)
.join("\n\n")
.slice(0, 1900);
const sends: Promise<Response>[] = [];
if (process.env.DISCORD_WEBHOOK_URL) {
sends.push(
fetch(process.env.DISCORD_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: text }),
}),
);
}
if (process.env.SLACK_WEBHOOK_URL) {
sends.push(
fetch(process.env.SLACK_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
}),
);
}
await Promise.allSettled(sends);
}
const app = express();
app.post("/weir", express.raw({ type: "application/json" }), (req, res) => {
const eventId = req.header("weir-event-id") ?? "";
const ok = verifyWeir(
req.body,
req.header("weir-signature") ?? "",
req.header("weir-timestamp") ?? "",
eventId,
);
if (!ok) return res.status(400).end();
res.status(200).end(); // acknowledge first, then work
if (seen.has(eventId)) return;
seen.add(eventId);
const event = JSON.parse(req.body.toString("utf8"));
if (event.type === "monitor.result.created" && event.data.results.length > 0) {
void forward(event.data.results);
}
});
app.listen(8000);
Delivery rules to design for
From the webhook docs:
- At least once. A retried delivery keeps its
weir-event-id, so deduplicate on it, as both receivers above do. - Retries back off over about nine hours. A
4xxother than429isn't retried, and redirects aren't followed. - 20 failures in a row disables the webhook.
- One delivery per run, up to 25 items. If
newResultsis larger, read the rest withGET /v1/monitors/{id}/results.
A tighter loop: poll search with fromUsers and sinceId
If an hour is too slow but you don't need seconds, run your own loop. POST /v1/x/search takes fromUsers (up to 10 usernames in one call) and sinceId, which means "only posts newer than this id". Store the newest id you've seen and send it back on each poll:
import os
import time
import requests
API = "https://api.weirapp.io"
HEADERS = {"Authorization": f"Bearer {os.environ['WEIR_API_KEY']}"}
def poll(accounts: list[str], since_id: str | None, max_pages: int = 5) -> tuple[list[dict], str | None]:
body = {"fromUsers": accounts, "sort": "latest", "limit": 20}
if since_id:
body["sinceId"] = since_id
posts, cursor = [], None
for _ in range(max_pages):
r = requests.post(f"{API}/v1/x/search", headers=HEADERS, timeout=30,
json={**body, **({"cursor": cursor} if cursor else {})})
if r.status_code == 402:
raise SystemExit("Out of credits; retrying won't help.")
r.raise_for_status()
page = r.json()
posts += page["data"]
cursor = page["meta"]["nextCursor"]
if not cursor or not page["data"]: # caught up
break
newest = max((p["id"] for p in posts), key=int, default=since_id)
return posts, newest
since_id = None # persist this between restarts
while True:
new_posts, since_id = poll(["XDevelopers"], since_id)
for post in reversed(new_posts): # oldest first
print(post["createdAt"], post["url"])
time.sleep(300) # every 5 minutes
On the first poll there's no sinceId, so the loop picks up the latest page and sets the baseline. If an account posts more than about 20 times between polls, the loop follows the cursor, up to max_pages.
Two things to know about this approach. First, it uses X search, which isn't the timeline. In our test, a fromUsers search returned the account's own posts but not its reposts, while the timeline (and so the x_user monitor) includes both. Second, the deduplication is by id and is yours to handle, whereas a monitor deduplicates by content on our side.
Search counts as 2 requests against your per-minute rate limit. The free tier's 30 a minute allows about 15 searches a minute, so a once-a-minute poll covering ten accounts uses very little of it.
What each approach costs
Assume one account that posts 10 times a day, and a 30-day month. Weir charges 1 credit per post, plus a 1-credit minimum per successful call or run, including calls that find nothing (billing).
| Approach | Credits a day | Credits a month | Money a month |
|---|---|---|---|
| Weir monitor, daily | 1 + 10 = 11 | ~330 | Free (within 5,000 monthly credits) |
| Weir monitor, hourly | 24 + 10 = 34 | ~1,020 | Free credits cover it; hourly needs a one-time purchase from $5 |
| Polling every 15 minutes | 96 + 10 = 106 | ~3,180 | Free |
| Polling every 5 minutes | 288 + 10 = 298 | ~8,940 | About $0.79 for the 3,940 above the free grant, at $0.20 per 1,000 |
| Polling every minute | 1,440 + 10 = 1,450 | ~43,500 | About $7.70 for the 38,500 above the free grant |
X Activity API, post.create |
10 events | 300 events | $1.50 |
The x_user monitor also bills a one-time 2-credit profile lookup on its first run, and that run charges for the posts already on the timeline.
The numbers make the trade-off clear. For a few accounts on an hourly cadence, a monitor costs almost nothing. Polling every minute costs more than X's own push API, which is real time. If seconds matter, use the Activity API or a vendor's stream. If an hour is fine, use a monitor. Polling sits in between.
Doing this with Weir
Create a free account (no card needed), create a webhook and an x_user monitor on the daily schedule, and point the receiver above at Discord or Slack. Monitors and webhooks has the full field reference, and pricing has the packs. To watch keywords across Reddit and X instead of one account, see the brand mention monitor tutorial. For search filters to narrow a polling loop, see X advanced search operators.
Sources
- X Activity API introduction: https://docs.x.com/x-api/activity/introduction
- X Filtered Stream introduction: https://docs.x.com/x-api/posts/filtered-stream/introduction
- X API pricing: https://docs.x.com/x-api/getting-started/pricing
- TwitterAPI.io: https://twitterapi.io/
- SocialData monitoring pricing: https://docs.socialdata.tools/monitoring/pricing/
- SocialData overview: https://docs.socialdata.tools/getting-started/overview/
- Discord execute webhook: https://docs.discord.com/developers/resources/webhook
- Slack incoming webhooks: https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks
- Weir monitors and webhooks: https://docs.weirapp.io/monitors
- Weir billing and tiers: https://docs.weirapp.io/billing