Monitors
Get new Reddit posts and comments that mention your keywords, by webhook or by polling.
A monitor watches every new post and comment on Reddit for your keywords. When one matches, threadapi sends it to your webhook (your own endpoint, Slack or Discord) and keeps it for you to read from the API or the dashboard.
A running monitor costs 10 credits a day. Its matches and deliveries are free.
How matching works
- Keywords match whole words, ignoring case:
rustmatches "Rust" and "RUST" but not "trust" or "rusty". A keyword with several words matches that phrase:borg backup. Quotes around a keyword are optional. - Keywords in Chinese, Japanese or Korean match anywhere in the text, since those languages don't separate words with spaces.
- A post matches on its title and text, a comment on its text.
- An item matches if it contains any keyword and no
excludeterm. - Up to 20 keywords and 20 exclude terms, each 2 to 100 characters. Up to 50 subreddits; leave
subredditsempty to watch all of Reddit. - Items marked NSFW are skipped unless the monitor has
nsfw: true.
Most matches arrive within 15 seconds of being posted. A post that Reddit is slow to make visible can take about two minutes. If Reddit stops answering threadapi for a while, items posted during that time can be missed.
Create a monitor
curl https://api.threadapi.dev/v1/monitors \
-H "Authorization: Bearer $THREADAPI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Backup tools",
"keywords": ["restic", "borg backup", "kopia"],
"exclude": ["hiring"],
"subreddits": ["selfhosted", "DataHoarder", "homelab"],
"posts": true,
"comments": true,
"webhook_url": "https://example.com/threadapi-hook"
}'| Field | Type | |
|---|---|---|
name | string | Required. Your label for the monitor. |
keywords | string[] | Required. 1 to 20 terms. |
exclude | string[] | Skip items containing any of these. |
subreddits | string[] | Only watch these, with or without r/. Empty means all of Reddit. |
posts | boolean | Watch new posts. Default true. |
comments | boolean | Watch new comments. Default true. |
nsfw | boolean | Include NSFW items. Default false. |
webhook_url | string | An https:// URL to send matches to. Optional: without one, read matches from the API. |
webhook_kind | string | generic, slack or discord. Detected from the URL when left out. |
Creating a monitor charges 10 credits for its first 24 hours and answers 201:
{
"monitor": {
"id": "c8nh5roydywdmhsndzqdd269p",
"name": "Backup tools",
"keywords": ["restic", "borg backup", "kopia"],
"exclude": ["hiring"],
"subreddits": ["selfhosted", "datahoarder", "homelab"],
"include_posts": true,
"include_comments": true,
"nsfw": false,
"webhook_url": "https://example.com/threadapi-hook",
"webhook_kind": "generic",
"status": "active",
"webhook_failing": false,
"next_charge_at": "2026-10-12T17:47:40Z",
"created_at": "2026-10-11T17:47:40Z",
"updated_at": "2026-10-11T17:47:40Z"
},
"webhook_secret": "f0c1…" // shown once
}Store webhook_secret: it signs every delivery, and the API doesn't show it again. Lost it? POST /v1/monitors/{id}/rotate-secret issues a new one.
If you already run as many monitors as your account allows, the request fails with 402 monitor_limit_reached. If your balance is under 10 credits, it fails with 402 insufficient_credits.
Webhooks
Slack and Discord
Point webhook_url at a Slack incoming webhook (hooks.slack.com/…) or a Discord channel webhook (discord.com/api/webhooks/…). Each match is posted as one message: the keyword, the subreddit, the title (or the comment's text) and a link.
Your own endpoint
threadapi sends a POST with a JSON body for each match:
{
"type": "monitor.match",
"monitor": { "id": "c8nh5roydywdmhsndzqdd269p", "name": "Backup tools" },
"match": {
"id": "cmvr60pg00cyht379zy87kca0",
"monitor_id": "c8nh5roydywdmhsndzqdd269p",
"thing_id": "t3_1x2lvbi",
"kind": "post",
"subreddit": "litrpg",
"author": "Centturion",
"title": "Book about shaman",
"body": "As title says, does anyone know progression or litrpg books about someone who is a shaman?…",
"permalink": "/r/litrpg/comments/1x2lvbi/book_about_shaman/",
"post_id": "1x2lvbi",
"created_utc": "2026-10-10T18:07:04Z",
"matched_terms": ["anyone know"],
"suppressed": false,
"created_at": "2026-10-10T18:07:15Z"
// …delivery bookkeeping fields
},
"sent_at": 1791655637
}For a comment, kind is comment, title is the title of the post it's on, and body is the comment.
Headers:
| Header | |
|---|---|
X-Threadapi-Delivery | The match ID. A retried delivery carries the same ID, so use it to drop duplicates. |
X-Threadapi-Signature | t=<unix seconds>,v1=<hex>. See below. |
User-Agent | threadapi-webhooks/1 |
Answer with any 2xx within 8 seconds. Do slow work after you answer.
Verifying deliveries
v1 is the hex HMAC-SHA256 of <t>.<raw body>, keyed with your webhook secret. Compute it over the raw bytes you received, before parsing the JSON, and compare in constant time. Reject old timestamps to stop replays.
import hashlib, hmac, time
def verify(secret: str, header: str, body: bytes, tolerance=300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, sig = parts.get("t", ""), parts.get("v1", "")
if not t.isdigit() or abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, header, rawBody, tolerance = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > tolerance) return false;
const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
const given = Buffer.from(parts.v1 ?? "", "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}Test your endpoint with POST /v1/monitors/{id}/test, which sends a sample match and reports the status your endpoint answered (5 tests a minute).
Retries
A delivery that times out, can't connect, or gets a 429 or 5xx is retried after 30 seconds, 2 minutes, 10 minutes, 30 minutes and 2 hours. Any other 4xx is not retried. After 50 failed deliveries in a row the monitor is marked webhook_failing; it keeps matching, and a successful test or a new webhook_url clears the mark. Matches stay readable from the API either way.
Webhook URLs must be https:// and resolve to a public address; threadapi doesn't follow redirects.
Reading matches
Matches are kept for 30 days. Poll for new ones, oldest first, passing the last ID you received as after:
curl "https://api.threadapi.dev/v1/monitors/c8nh5roydywdmhsndzqdd269p/matches?after=cmvr60pg00cyht379zy87kca0&limit=100" \
-H "Authorization: Bearer $THREADAPI_API_KEY"{
"items": [ /* matches, as in the webhook body */ ],
"after": null // set when more matches are waiting right now; call again with it
}order=newest lists newest first instead, paged with before. limit is 1 to 200. Reading matches is free.
Billing and limits
- Charges. 10 credits when you create or resume a monitor, then 10 every 24 hours while it runs. Pausing or deleting stops the charges at once; the current day isn't refunded.
- Out of credits. If a renewal finds less than 10 credits, the monitor stops with status
paused_balance. Add credits and resume it; resuming charges 10. - How many monitors. A free account runs 1. Buying a pack raises the limit: 10 with the $9 pack, 50 with the $29 pack, 100 with the $79 pack. The largest pack you've bought counts.
- Daily match cap. Each monitor delivers up to 200 matches a day on a free account and 1,000 after buying any pack. The count resets at 00:00 UTC. Matches over the cap are still recorded with
suppressed: true, but not sent to your webhook. If you hit the cap, narrow the keywords or subreddits.
Routes
| Route | Credits | |
|---|---|---|
POST /v1/monitors | Create | 10 |
GET /v1/monitors | List your monitors, with how many you may run | 0 |
GET /v1/monitors/{id} | One monitor, with match counts for today and the last 7 days | 0 |
PATCH /v1/monitors/{id} | Change any field from Create. "webhook_url": "" removes the webhook | 0 |
DELETE /v1/monitors/{id} | Delete; charges stop at once | 0 |
POST /v1/monitors/{id}/pause | Pause | 0 |
POST /v1/monitors/{id}/resume | Resume | 10 |
POST /v1/monitors/{id}/rotate-secret | New signing secret | 0 |
POST /v1/monitors/{id}/test | Send a sample match to the webhook | 0 |
GET /v1/monitors/{id}/matches | Matches, oldest or newest first | 0 |
Monitors are also in the dashboard under Monitors, and agents can read matches with the MCP tool get_monitor_matches.