Build a Reddit and X Brand Mention Monitor with Webhooks
Build a Reddit keyword monitor and brand mention monitoring API pipeline: watch Reddit and X, send new mentions to a signed webhook, and know the monthly cost.
By Tarun Khatri · · 10 min read
A brand mention monitor needs three parts: a scheduled search across Reddit and X, a record of what it has already seen so it only reports new mentions, and a way to tell you, usually a webhook. This tutorial builds one with Weir's monitor and webhook endpoints. You get a Reddit keyword monitor and an X watcher in one object, a small receiver in Python or Node that checks the signature, and a cost estimate at realistic volumes. There is also a do-it-yourself polling version if you'd rather run the schedule yourself.
Disclosure: Weir is our product. The polling approach at the end works with any search API, including the official ones.
What you'll build
Weir monitor (daily or hourly)
-> searches Reddit and X for your brand
-> keeps only results it has not reported before
-> POSTs a signed JSON delivery to your URL
Your receiver
-> verifies the signature
-> forwards each mention to Slack, a database, or a ticket queue
You need a Weir API key (created under API keys) in WEIR_API_KEY, and a public HTTPS URL for the receiver. For local testing, any tunnel that gives you a public URL works.
Step 1: Create the webhook
Create the webhook first, so the monitor can point at it.
curl --request POST \
--url https://api.weirapp.io/v1/webhooks \
--header "Authorization: Bearer $WEIR_API_KEY" \
--header 'Content-Type: application/json' \
--data '{
"name": "Brand mentions",
"url": "https://hooks.example.com/weir",
"events": ["monitor.result.created"]
}'
The response's data contains the webhook's id and a secret. The secret is shown once and can't be recovered, so put it in WEIR_WEBHOOK_SECRET now.
events defaults to ["monitor.result.created"], the event sent when a run finds something new. You can also subscribe to monitor.run.failed, but as of October 2026 the docs say no deliveries are sent for it yet. To catch failed runs, read the monitor's /runs.
Step 2: Create the monitor
A monitor's target.kind decides what it watches. For brand monitoring across both platforms you want search, which takes a query and a sources list:
curl --request POST \
--url https://api.weirapp.io/v1/monitors \
--header "Authorization: Bearer $WEIR_API_KEY" \
--header 'Content-Type: application/json' \
--data '{
"name": "Acme mentions",
"target": {
"kind": "search",
"query": "\"acme analytics\"",
"sources": ["reddit", "x"]
},
"schedule": "daily",
"webhookId": "PASTE-THE-WEBHOOK-ID"
}'
The fields, from the API's OpenAPI contract:
| Field | Values |
|---|---|
target.kind |
search, subreddit, subreddit_comments, reddit_user, x_user, reddit_thread, x_thread |
target.sources |
reddit, x, or both (for search) |
schedule |
manual, hourly (paid tiers only), daily (the default), weekly |
webhookId |
The webhook's id, or null. You can add it later with PATCH /v1/monitors/{id} |
A few things that matter for brand monitoring:
- Quote multi-word brand names. An exact phrase cuts most of the noise, and you pay for every result returned, irrelevant ones included.
- "New" means new content, not a new id. Weir deduplicates results by a hash of the content, so a repost with a fresh id doesn't alert you twice for the same text.
- Reddit search covers post titles and bodies, not comments. That's a limit of Reddit's own search. If your brand mostly comes up in comment threads in a few communities, add a
subreddit_commentsmonitor for each one, with your brand as itsquery. - Monitors run on Weir's schedule, not yours. On the free tier you get one monitor and one webhook, with daily or weekly schedules. Hourly needs a paid tier, which starts after any $5 purchase.
GET /v1/limitsshows your account's numbers.
To test it without waiting for the schedule, trigger a run. The call returns 202 straight away and the run happens in the background:
curl --request POST \
--url "https://api.weirapp.io/v1/monitors/$MONITOR_ID/run" \
--header "Authorization: Bearer $WEIR_API_KEY"
# A moment later: what it found, and how the run went
curl --url "https://api.weirapp.io/v1/monitors/$MONITOR_ID/results?limit=10" \
--header "Authorization: Bearer $WEIR_API_KEY"
curl --url "https://api.weirapp.io/v1/monitors/$MONITOR_ID/runs" \
--header "Authorization: Bearer $WEIR_API_KEY"
Each stored result has a normalised shape: source, itemType, url, title, body, authorHandle, communityName, and firstSeenAt. Creating a monitor, triggering a run, and reading results and runs cost nothing. You pay only for new results a run collects (see the cost section below).
Step 3: Verify the webhook signature
Each delivery is a POST with a JSON body and three headers:
weir-signature: <hex HMAC-SHA256>
weir-timestamp: <unix seconds>
weir-event-id: evt_...
The signature is HMAC-SHA256(secret, "{timestamp}.{eventId}.{rawBody}"), hex encoded. Three rules decide whether your check works:
- Hash the raw body bytes. If you parse the JSON and serialise it again, the keys can come out in a different order, and a correct secret will still fail.
- Reject a timestamp more than five minutes from now, so a captured delivery can't be replayed later.
- Compare in constant time.
The body looks like this. results holds up to 25 of the new items, each in its platform's own API shape (the same shape search_reddit or search_x returns):
{
"id": "evt_...",
"type": "monitor.result.created",
"createdAt": "2026-10-19T09:00:12.000Z",
"data": {
"monitorId": "...",
"runId": "...",
"kind": "search",
"itemType": "post",
"newResults": 3,
"results": []
}
}
A Python receiver (Flask)
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["WEIR_WEBHOOK_SECRET"].encode()
seen_events = 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())
@app.post("/weir")
def weir_webhook():
raw = request.get_data() # the exact bytes that were signed
headers = request.headers
if not verify(
raw,
headers.get("weir-signature", ""),
headers.get("weir-timestamp", ""),
headers.get("weir-event-id", ""),
):
abort(400)
event = json.loads(raw)
if event["id"] in seen_events: # deliveries are at least once
return "", 200
seen_events.add(event["id"])
if event["type"] == "monitor.result.created":
for item in event["data"]["results"]:
if "subreddit" in item: # a Reddit post
print(f"Reddit r/{item['subreddit']}: {item['title']} {item['url']}")
else: # an X post
print(f"X @{item['author']['username']}: {item['text'][:120]} {item['url']}")
return "", 200
The Reddit and X items keep their own field names: a Reddit post has title, selftext, subreddit, author and url, and an X post has text, author.username and url. That's why the loop checks for subreddit.
A Node receiver (Express)
This is the same check in TypeScript, as in Weir's monitor docs. Note express.raw, not express.json:
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
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))
);
}
const app = express();
app.post("/weir", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifyWeir(
req.body,
req.header("weir-signature") ?? "",
req.header("weir-timestamp") ?? "",
req.header("weir-event-id") ?? "",
);
if (!ok) return res.status(400).end();
res.status(200).end(); // acknowledge first, then work
const event = JSON.parse(req.body.toString("utf8"));
for (const item of event.data?.results ?? []) {
console.log(
item.subreddit
? `r/${item.subreddit}: ${item.title}`
: `@${item.author.username}: ${item.text}`,
item.url,
);
}
});
app.listen(3000);
Send yourself a real, signed test delivery with POST /v1/webhooks/{id}/test. It's free and arrives as a webhook.test event.
Delivery behaviour to design for
These come from the webhook docs:
- At least once. The same delivery can arrive twice, with the same
weir-event-id. Deduplicate on it. - Respond within 10 seconds. Return a
2xxfirst and do slow work, like posting to Slack, afterwards or on a queue. - Retries back off over about nine hours. A
4xxother than429is not retried, and redirects are not followed. - 20 failures in a row disables the webhook. One success resets the count.
- One delivery per run, up to 25 items. If a run finds more, read them with
GET /v1/monitors/{id}/results?limit=100, which returns up to 100 of the monitor's stored results.
What it costs per month
Weir bills per result returned. From the repo's billing table (packages/billing/src/packs.ts), as of October 2026:
| Item | Credits |
|---|---|
| Each monitor run (minimum) | 1 |
| Each new X post | 1 |
| Each new Reddit post or comment | 3 |
| Results already reported | 0 |
| Creating monitors and webhooks, triggering runs, reading results, webhook deliveries | 0 |
You get 5,000 free credits a month. Packs start at $5 for 25,000 credits, and purchased credits never expire. Two worked examples (the mention volumes are assumptions; plug in your own):
A small brand, daily schedule. Say 40 new X posts and 6 new Reddit posts a day. Each run costs 1 + 40 × 1 + 6 × 3 = 59 credits, or about 1,770 a month. That fits inside the free grant.
A busier brand, hourly schedule. Say 300 new X posts and 30 new Reddit posts a day. That's 24 run minimums plus 300 + 90 result credits = 414 credits a day, or about 12,420 a month. After the 5,000 free credits, 7,420 are left to pay for: about $1.48 a month at the $5 pack's rate ($0.20 per 1,000 credits), or $1.24 at the $20 pack's rate. Hourly also needs a paid tier, which the first $5 purchase unlocks.
For comparison, the X part of the busier example is 9,000 posts a month. At the official X API's pay-per-usage rate of $0.005 per post read, that's $45 a month, before you've built your own deduplication. The official API is still the right choice if you need X's guarantees or a contract with X directly. X API pricing in 2026 covers its numbers in detail.
One limit to know: a run stores at most 100 new results. If your brand gets more than that between two runs, the extra results are marked as truncated in the run summary. Either narrow the query or move to a shorter schedule.
Alternative: poll the search endpoints yourself
You can skip monitors and run the schedule yourself, from cron, a queue worker, or a serverless function. That makes sense if you already have a scheduler, need intervals shorter than an hour, or want the filtering logic in your own code.
X has a clean resume point. POST /v1/x/search takes sinceId, which means "only posts newer than this id". Store the newest id you've seen and pass it back each time:
import os
import requests
API = "https://api.weirapp.io"
HEADERS = {"Authorization": f"Bearer {os.environ['WEIR_API_KEY']}"}
def new_x_mentions(since_id: str | None) -> tuple[list[dict], str | None]:
body = {
"phrase": "acme analytics",
"onlyOriginal": True, # skip replies; drop this to include them
"excludeWords": ["hiring"],
"sort": "latest",
"limit": 50,
}
if since_id:
body["sinceId"] = since_id
r = requests.post(f"{API}/v1/x/search", json=body, headers=HEADERS, timeout=30)
if r.status_code == 402:
raise SystemExit("Out of credits: a person has to top up; retrying will not help.")
r.raise_for_status()
posts = r.json()["data"]
newest = max((p["id"] for p in posts), key=int, default=since_id)
return posts, newest
Reddit has no sinceId. Reddit's search takes sort and time but no resume id, so ask for the newest posts in a short window and drop the ids you've already seen:
seen_reddit_ids: set[str] = set() # persist this between runs
def new_reddit_mentions() -> list[dict]:
body = {"query": '"acme analytics"', "sort": "new", "time": "day", "limit": 25}
r = requests.post(f"{API}/v1/reddit/search", json=body, headers=HEADERS, timeout=30)
r.raise_for_status()
fresh = [p for p in r.json()["data"] if p["id"] not in seen_reddit_ids]
seen_reddit_ids.update(p["id"] for p in fresh)
return fresh
The trade-off is cost. Polling pays for every result returned, including ones you've already seen. With the time: "day" window above, an hourly poll returns the same Reddit posts many times, at 3 credits each. A monitor charges only for new results, because the deduplication happens on Weir's side. Polling also deduplicates by id, so an edited or reposted mention can be missed or counted twice. If you poll, keep the window just longer than your interval and the limit small. For 429 responses, wait for the Retry-After delay before retrying. A 402 means the account is out of credits, and only a top-up fixes it.
A note on terms
Everything here reads public posts only: Weir doesn't reach private accounts, DMs, or anything behind a login wall. If you store mentions, check the platforms' terms and your local privacy rules on keeping user content, especially if you keep it long term or combine it with other data. This post isn't legal advice.
Doing this with Weir
You can run the whole thing on the free tier: one search monitor across Reddit and X, one webhook, and 5,000 credits a month. Create an account (no card needed), then follow Monitors and webhooks for the full field reference. Rates and packs are on pricing. If you'd rather ask an AI assistant about your mentions than wire up a receiver, connect Weir over MCP. Your agent can then read your monitor's results with list_monitor_results.
Sources
- Weir docs, Monitors and webhooks: https://docs.weirapp.io/monitors
- Weir docs, Billing and credits: https://docs.weirapp.io/billing
- X API pay-per-usage pricing: https://docs.x.com/x-api/getting-started/pricing