# Monitors



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](https://stage.threadapi.dev/app/monitors?utm_source=docs).

A running monitor costs **10 credits a day**. Its matches and deliveries are free.

How matching works [#how-matching-works]

* Keywords match whole words, ignoring case: `rust` matches "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 `exclude` term.
* Up to 20 keywords and 20 exclude terms, each 2 to 100 characters. Up to 50 subreddits; leave `subreddits` empty 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 [#create-a-monitor]

```bash
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`:

```jsonc
{
  "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 [#webhooks]

Slack and Discord [#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 [#your-own-endpoint]

threadapi sends a `POST` with a JSON body for each match:

```jsonc
{
  "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 [#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.

<Tabs items={["Python", "Node.js"]}>
  <Tab value="Python">
    ```python
    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)
    ```
  </Tab>

  <Tab value="Node.js">
    ```js
    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);
    }
    ```
  </Tab>
</Tabs>

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 [#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 [#reading-matches]

Matches are kept for 30 days. Poll for new ones, oldest first, passing the last ID you received as `after`:

```bash
curl "https://api.threadapi.dev/v1/monitors/c8nh5roydywdmhsndzqdd269p/matches?after=cmvr60pg00cyht379zy87kca0&limit=100" \
  -H "Authorization: Bearer $THREADAPI_API_KEY"
```

```jsonc
{
  "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 [#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 [#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](https://stage.threadapi.dev/app/monitors?utm_source=docs), and agents can read matches with the MCP tool `get_monitor_matches`.
