# Account monitoring

> Watch any public X account, with no follower minimum, and get its new tweets pushed to a signed webhook or pulled from an event feed within 60 s, 5 s or 1 s depending on the plan. Monthly plans, managed under /monitors.

HTML page: https://www.twittrapi.com/docs/monitoring

{/* Plans and prices: TwitterDash config/monitor-plans.ts. Behaviour: twittrapi-monitor (internal/api, internal/delivery) and the gateway route TwitterPayment/monitor.go. */}

Account monitoring watches X accounts for you and sends every new tweet they post to your webhook as soon as it is detected. You add the accounts once; there is no polling on your side and no charge per request. Each plan is a flat monthly fee for a number of watched accounts and a speed class.

Video: [Account monitoring: new tweets by webhook](https://www.youtube.com/watch?v=d7bCXQV-uIw)

## How it works

1. You book a plan in the dashboard (Monitors). The monthly fee comes out of your prepaid balance.
2. You add the accounts to watch with `POST /monitors/accounts`.
3. You set a webhook URL with `PUT /monitors/webhook`, or read new tweets from `GET /monitors/events`, or both.
4. We check the accounts' timelines at the interval of your speed class. Every new tweet becomes one event, signed and sent to your webhook, and kept for 7 days in the event feed.

Any public account can be watched, whatever its size: there is no follower minimum, and an account with 50 followers is checked as often as one with 5 million. Tweets that already existed when an account was added are not sent; delivery starts with the first tweet after monitoring begins. An account watched by several customers is checked once and fanned out to all of them, so a popular account is not slower than a niche one.

## Speed classes

| Class | Guarantee | Typical |
| --- | --- | --- |
| Standard | We check at least every 60 s | 15–60 s |
| Fast | We check at least every 5 s | 2–5 s |
| Real-time | We check at least every 1 s | 0.5–1 s |

The guarantee is about how often we look: an account in the Real-time class is never checked less often than once per second, even when that costs us more than the plan brings in. When there is spare capacity we check more often than the guarantee.

**Detection latency** is the time from the tweet being posted to the event being created. It has two parts:

- **X's own delay**: the time X takes before a new tweet shows up in its timelines. It is usually short, but it is outside our control and we cannot guarantee it.
- **Our polling interval**: at most the guarantee of your class (60 s, 5 s or 1 s), on average about half of it.

Webhook delivery adds the round trip to your server. Every event carries `tweetCreatedAt` (from X) and `detectedAt` (when we saw it), so you can measure the latency yourself.

## Plans and prices

Monthly prices in USD, charged from your balance:

| Accounts | Standard (60 s) | Fast (5 s) | Real-time (1 s) |
| --- | --- | --- | --- |
| 10 | $5 | $9 | $19 |
| 50 | $15 | $29 | $59 |
| 250 | $49 | $99 | $199 |
| 1,000 | $149 | $299 | $599 |
| 5,000 | $499 | $999 | $1,999 |

- The plan renews every month from your balance. If the balance does not cover the renewal, the plan is **paused**: your watched accounts are kept, no events are created and adding accounts returns `402`. Top up and renew in the dashboard to resume.
- An upgrade (more accounts or a faster class) applies at once; you pay the prorated difference for the rest of the period. A downgrade applies at the end of the period.
- Requests to `/monitors/*` are not billed per call. They count toward your normal [rate limit](https://www.twittrapi.com/docs/helpful-infos/rate-limits).

## API

Base URL `https://api.twittrapi.com`, authenticated with your normal `X-API-KEY` header. Request and response bodies are JSON. Errors have the form `{"error": "..."}`.

| Method | Path | |
| --- | --- | --- |
| `GET` | `/monitors` | Plan and webhook |
| `GET` | `/monitors/accounts` | Watched accounts and plan usage |
| `POST` | `/monitors/accounts` | Watch accounts |
| `DELETE` | `/monitors/accounts/{usernameOrId}` | Stop watching an account |
| `GET` | `/monitors/webhook` | Current webhook |
| `PUT` | `/monitors/webhook` | Set or change the webhook (`POST` works too) |
| `DELETE` | `/monitors/webhook` | Remove the webhook |
| `POST` | `/monitors/webhook/test` | Send a test event |
| `GET` | `/monitors/events` | Pull API: events after a given event |
| `GET` | `/monitors/deliveries` | The latest events with their delivery status (`limit` up to 200, default 50) |

### Watch accounts

```bash
curl -X POST 'https://api.twittrapi.com/monitors/accounts' \
  --header 'X-API-KEY: YOUR_API_KEY' \
  --header 'Content-Type: application/json' \
  --data '{"usernames": ["XDevelopers", "elonmusk"]}'
```

The body takes `username`, `userId`, `usernames` or `userIds` (with or without `@`), up to 100 accounts per request. Each account gets its own result:

```json
{
  "data": [
    { "input": "XDevelopers", "userId": "2244994945", "username": "XDevelopers", "status": "added" },
    { "input": "elonmusk", "userId": "44196397", "username": "elonmusk", "status": "already_watching" }
  ],
  "plan": {
    "planId": "m50-realtime",
    "maxAccounts": 50,
    "usedAccounts": 12,
    "latencyClass": "realtime",
    "status": "active"
  }
}
```

A failed account has an `error` instead of a `status`: not found on X, protected, not a valid username, or the plan's account limit reached. With a single account the HTTP status reflects the result (`201` added, `404`, `422` protected, `402` limit reached); with several it is `200` and you read the results one by one. Without a plan, or with a paused plan, the whole request returns `402`.

`GET /monitors/accounts` lists the watched accounts with `userId`, `username`, `state`, `latencyClass`, `subscribedAt` and `monitoringSince`. A new account has the state `pending` until monitoring of it starts, then `active`; `monitoringSince` is set from then on.

### Set the webhook

```bash
curl -X PUT 'https://api.twittrapi.com/monitors/webhook' \
  --header 'X-API-KEY: YOUR_API_KEY' \
  --header 'Content-Type: application/json' \
  --data '{"url": "https://example.com/hooks/twittrapi"}'
```

```json
{
  "webhook": {
    "id": 7,
    "url": "https://example.com/hooks/twittrapi",
    "secret": "whsec_3f1c...",
    "active": true,
    "failureCount": 0,
    "disabledAt": null,
    "disabledReason": null,
    "lastSuccessAt": null,
    "lastFailureAt": null
  }
}
```

The first `PUT` creates a signing secret (`whsec_` followed by 48 hex characters). Changing the URL keeps the secret; send `"rotateSecret": true` to get a new one. Setting the webhook again also re-enables it after it was disabled.

The URL must be `https`, without credentials, on a public host: private, loopback and link-local addresses (and host names that resolve to them) are refused, and redirects are not followed.

`POST /monitors/webhook/test` sends a signed `test` event and answers with `{"ok": true, "statusCode": 200, "latencyMs": 84}` (or `ok: false` and an `error`).

## Webhook events

Each new tweet is one `POST` to your URL with this JSON body:

```json
{
  "event": "tweet",
  "id": "1849203",
  "monitoredUserId": "2244994945",
  "tweetId": "2019881223666233717",
  "tweetCreatedAt": "2026-02-06T21:09:45Z",
  "detectedAt": "2026-02-06T21:09:46.212Z",
  "tweet": {
    "id": "2019881223666233717",
    "url": "https://x.com/XDevelopers/status/2019881223666233717",
    "text": "Officially launching X API Pay-Per-Use …",
    "createdAt": "2026-02-06T21:09:45Z",
    "lang": "en",
    "author": { "id": "2244994945", "username": "XDevelopers", "name": "Developers", "...": "..." },
    "counts": { "likes": 0, "retweets": 0, "replies": 0, "quotes": 0, "bookmarks": 0, "views": 0 },
    "media": [],
    "replyTo": null,
    "quoted": null,
    "retweetOf": null,
    "conversationId": "2019881223666233717"
  }
}
```

- `tweet` is a tweet in the [simple format](https://www.twittrapi.com/docs/simple-format), the same object you get from the read endpoints with `format=simple`. A retweet has `retweetOf`, a reply has `replyTo`.
- `id` is the event ID. It increases with every event and is what you pass as `since` to the pull API.
- `monitoredUserId` is the watched account the event belongs to.

Headers:

| Header | |
| --- | --- |
| `X-TwittrAPI-Event` | `tweet` (or `test` for the test event) |
| `X-TwittrAPI-Delivery` | Event ID, the same in every retry. Use it to drop duplicates. |
| `X-TwittrAPI-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256>` |
| `Content-Type` | `application/json` |
| `User-Agent` | `TwittrAPI-Monitor/1.0` |

Answer with any `2xx` status within 5 seconds. Do the work after answering (put the event on a queue), not before.

## Verify the signature

`v1` is HMAC-SHA256 with your webhook secret as key over the string `<t>.<raw body>`: the timestamp from the header, a dot, and the request body exactly as received. Compute it on the raw bytes, before any JSON parsing, compare it in constant time, and reject timestamps more than 5 minutes away from your clock so a captured request cannot be replayed.

### Node.js

```js
import crypto from "node:crypto";
import express from "express";

const secret = process.env.TWITTRAPI_WEBHOOK_SECRET;
const app = express();

function verify(header, rawBody, toleranceSec = 300) {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.trim().split("=", 2)),
  );
  const t = Number(parts.t);
  if (!t || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.`)
    .update(rawBody)
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post("/hooks/twittrapi", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.get("X-TwittrAPI-Signature") ?? "", req.body)) {
    return res.sendStatus(401);
  }
  res.sendStatus(204);
  const event = JSON.parse(req.body);
  // queue event.tweet for processing; dedupe on req.get("X-TwittrAPI-Delivery")
});

app.listen(3000);
```

### Python

```python
import hashlib, hmac, os, time

from flask import Flask, abort, request

SECRET = os.environ["TWITTRAPI_WEBHOOK_SECRET"].encode()
app = Flask(__name__)

def verify(header: str, raw_body: bytes, tolerance: int = 300) -> bool:
    parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
    t, sig = parts.get("t", ""), parts.get("v1", "")
    if not t.isdigit() or not sig or abs(time.time() - int(t)) > tolerance:
        return False
    expected = hmac.new(SECRET, t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig)

@app.post("/hooks/twittrapi")
def hook():
    if not verify(request.headers.get("X-TwittrAPI-Signature", ""), request.get_data()):
        abort(401)
    event = request.get_json()
    # queue event["tweet"]; dedupe on request.headers["X-TwittrAPI-Delivery"]
    return "", 204
```

### Go

```go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

var secret = []byte(os.Getenv("TWITTRAPI_WEBHOOK_SECRET"))

func verify(header string, body []byte, tolerance time.Duration) bool {
	var t, sig string
	for _, part := range strings.Split(header, ",") {
		k, v, _ := strings.Cut(strings.TrimSpace(part), "=")
		switch k {
		case "t":
			t = v
		case "v1":
			sig = v
		}
	}
	ts, err := strconv.ParseInt(t, 10, 64)
	if err != nil || sig == "" {
		return false
	}
	if d := time.Since(time.Unix(ts, 0)); d > tolerance || d < -tolerance {
		return false
	}
	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte(t + "."))
	mac.Write(body)
	return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(sig))
}

func main() {
	http.HandleFunc("POST /hooks/twittrapi", func(w http.ResponseWriter, r *http.Request) {
		body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
		if err != nil || !verify(r.Header.Get("X-TwittrAPI-Signature"), body, 5*time.Minute) {
			http.Error(w, "invalid signature", http.StatusUnauthorized)
			return
		}
		w.WriteHeader(http.StatusNoContent)
		// queue body for processing; dedupe on r.Header.Get("X-TwittrAPI-Delivery")
	})
	http.ListenAndServe(":3000", nil)
}
```

## Retries and auto-disable

A delivery fails when your server does not answer with `2xx` within 5 seconds (timeouts, connection errors, `3xx`, `4xx` and `5xx` all count). Failed deliveries are retried with exponential backoff: after 10 s, 20 s, 40 s and so on, doubling up to at most 2 hours between attempts, with ±20 % jitter. A delivery that has not succeeded 24 hours after the tweet was detected is given up and marked `failed`.

Retries can arrive out of order and, rarely, more than once. Use `X-TwittrAPI-Delivery` to drop duplicates and `tweetCreatedAt` to order.

After **20 failed deliveries in a row** the webhook is disabled (`active: false`, with `disabledAt` and `disabledReason`) and we email you. Monitoring continues: new events are still created and stay available through the pull API. Fix your endpoint, then send `PUT /monitors/webhook` again to re-enable it.

## Pull API

If you cannot receive webhooks, or to catch up after downtime, read the events directly:

```bash
curl 'https://api.twittrapi.com/monitors/events?since=1849203&limit=100' \
  --header 'X-API-KEY: YOUR_API_KEY'
```

```json
{
  "data": [
    {
      "event": "tweet",
      "id": "1849204",
      "monitoredUserId": "2244994945",
      "tweetId": "2019881223666233718",
      "tweetCreatedAt": "2026-02-06T21:12:03Z",
      "detectedAt": "2026-02-06T21:12:04.017Z",
      "tweet": { "id": "2019881223666233718", "...": "..." },
      "status": "delivered",
      "attempts": 1,
      "deliveredAt": "2026-02-06T21:12:04.231Z",
      "lastError": null
    }
  ],
  "nextSince": "1849204",
  "hasMore": false
}
```

- `since` is an event ID (exclusive) or an RFC 3339 time. Without it you get the oldest events still kept.
- `limit` is 1–500, default 100. Events come oldest first.
- Store `nextSince` and pass it as `since` on the next call. When `hasMore` is `true`, call again right away.
- `status` is `stored` (no webhook set), `pending` (webhook delivery still being tried), `delivered` or `failed`.

Events are kept for **7 days**, then deleted. We do not keep an archive of tweets beyond that.

## Limits

| | |
| --- | --- |
| Accounts per plan | 10, 50, 250, 1,000 or 5,000 |
| Accounts per `POST /monitors/accounts` | 100 |
| Accounts that can be watched | Any public account, no follower minimum (protected accounts return `422`) |
| Webhooks | One per API account, `https` only, public host |
| Webhook timeout | 5 s |
| Retries | Up to 24 h per event, backoff up to 2 h |
| Auto-disable | After 20 failed deliveries in a row |
| Event retention (pull API) | 7 days |
| Pull API page size | 1–500 events (default 100) |
| Request body | 64 KB |
| Requests to `/monitors/*` | Your normal rate limit, no per-call charge |

## FAQ

**Are replies and retweets included?**
We do not filter: every tweet of the account that X returns to us is sent. Retweets carry `retweetOf`, replies `replyTo` and quotes `quoted`, so you can filter on your side if you only want original tweets.

**Do small accounts get the same speed?**
Yes. There is no follower threshold and no separate slow lane: every public account you add is checked at the guaranteed interval of your class, whether it has 50 followers or 5 million.

**Can I watch protected accounts?**
No. Only public accounts can be watched.

**Do I get old tweets when I add an account?**
No. Delivery starts with the first tweet after monitoring of the account begins. Use the [user tweets endpoint](https://www.twittrapi.com/docs/reference) for history.

**What happens to deleted tweets?**
An event is sent when the tweet is detected. Deletions are not sent as separate events.

**Is the 1 second a guarantee of end-to-end latency?**
No. It guarantees how often we check (at least once per second for Real-time). The time X needs before a new tweet appears is added on top and is outside our control. Compare `tweetCreatedAt` and `detectedAt` to see the actual latency.

**What if my balance runs out?**
The plan is paused at renewal: watched accounts are kept, no events are created. Top up and renew in the dashboard and monitoring continues.

**Does the same account cost more if many people watch it?**
No. Price depends only on the number of accounts in your plan and the speed class.

**How do I test my endpoint?**
`POST /monitors/webhook/test` sends a signed test event with `X-TwittrAPI-Event: test`. Your handler should verify and accept it like a real one.
