Skip to content
cheapxapi

Twitter webhooks: tweets as they post.

X does send webhooks. Register an HTTPS URL with its Webhooks API, then subscribe through the X Activity API to receive an account’s new posts at $0.005 each. Webhooks for keyword rules are Enterprise only.

Without a developer account, poll a search instead and forward each new tweet to your own URL. Below: what each X product delivers and costs, a Python poller, and a calculator for which is cheaper at your volume.

What X delivers in real time.

Three X API products push data as it happens. Pick by what you watch: one account’s activity, or every public post that matches a rule.

Real-time X API products compared on what they watch, access and price
ProductWatchesAccess and deliveryPrice
X Activity APIOne account per subscription. Any public account’s posts, deletes, profile changes and live broadcasts. Replies, mentions, likes and DMs only for users who authorized your app.Pay-per-use, up to 1,500 subscriptions. Delivered by webhook or a persistent HTTP stream.$0.005 per post event, $0.010 per follow or DM received. Deletes and sent DMs are not billed.
Account Activity APIEverything that happens to accounts that authorized your app: posts, mentions, replies, likes, follows, DMs.Pay-per-use: 3 accounts and 1 webhook. X is deprecating it in favour of the X Activity API.Delivered posts count toward the monthly Post cap.
Filtered streamPublic posts that match your rules: keywords, hashtags, accounts, languages.Pay-per-use: 1,000 rules over one persistent HTTP connection, about 4 to 5 seconds P99 latency. Webhook delivery is Enterprise only.$0.005 per post, within the monthly Post cap.

Source: X’s developer documentation and pay-per-usage pricing page. Pay-per-use accounts can read up to 3 million posts a month across all endpoints.

What an X webhook needs.

The keys come from the X Developer Console; how to get them. The CRC answer is a few lines in any language.

  1. 1.A developer account, a Project and App in the Developer Console, and prepaid credits.
  2. 2.A public HTTPS URL with no port number in it.
  3. 3.A GET handler for the Challenge-Response Check (CRC): X sends a crc_token and expects an HMAC-SHA256 of it, signed with your app’s client secret. X repeats the check, and a webhook that fails it stops receiving events.
  4. 4.A POST handler that returns 200 within 10 seconds.
  5. 5.Signature checks on every POST: the X-Twitter-Webhooks-Signature-OAuth2 header holds an HMAC-SHA256 of the raw body, made with the same secret.
import base64, hashlib, hmac

def crc_response(crc_token: str, client_secret: str) -> dict:
    digest = hmac.new(client_secret.encode(), crc_token.encode(), hashlib.sha256).digest()
    return {"response_token": "sha256=" + base64.b64encode(digest).decode()}

No developer account? Poll and forward.

The script runs a search every minute and posts each tweet it has not seen to your webhook URL, oldest first. A normal poll is one request; it reads further pages only when more new tweets arrived than one page of 20 holds.

One query can watch many accounts (from:a OR from:b) or any keyword, and the price is per request, not per account. On its first run it only records the current tweets, so your endpoint is not flooded; seen.json lets it resume after a restart.

  1. 1. Get an API key and set it as CHEAPXAPI_KEY.
  2. 2. Set WEBHOOK_URL to the endpoint that should receive tweets.
  3. 3. pip install requests, change QUERY and run it.
import json, os, time, requests

API = "https://api.cheapxapi.com/twitter/tweet/advanced_search"
HEADERS = {"x-api-key": os.environ["CHEAPXAPI_KEY"]}
WEBHOOK_URL = os.environ["WEBHOOK_URL"]
QUERY = "from:NASA OR from:SpaceX"
INTERVAL = 60   # seconds between polls
MAX_PAGES = 5   # caps what one burst of new tweets can cost
STATE = "seen.json"

def search(cursor=""):
    params = {"query": QUERY, "queryType": "Latest"}
    if cursor:
        params["cursor"] = cursor
    res = requests.get(API, headers=HEADERS, params=params, timeout=75)
    res.raise_for_status()
    return res.json()

def new_tweets(seen):
    fresh, cursor = {}, ""
    for _ in range(MAX_PAGES):
        page = search(cursor)
        tweets = page.get("tweets", [])
        for t in tweets:
            if t["id"] not in seen:
                fresh[t["id"]] = t
        # A page that reaches a known tweet means everything older was seen.
        if any(t["id"] in seen for t in tweets) or not page.get("has_next_page") or not page.get("next_cursor"):
            break
        cursor = page["next_cursor"]
    return sorted(fresh.values(), key=lambda t: int(t["id"]))  # oldest first

def save(seen):
    with open(STATE, "w") as f:
        json.dump(seen[-2000:], f)

if os.path.exists(STATE):
    with open(STATE) as f:
        seen = json.load(f)
else:
    seen = [t["id"] for t in search().get("tweets", [])]
    save(seen)
    print(f"Watching {QUERY!r}, skipped {len(seen)} existing tweets", flush=True)

while True:
    try:
        for t in new_tweets(set(seen)):
            requests.post(WEBHOOK_URL, timeout=10, json={
                "id": t["id"], "url": t["url"], "author": t["author"]["userName"],
                "created_at": t["createdAt"], "text": t["text"],
            }).raise_for_status()
            seen.append(t["id"])
            save(seen)
    except requests.RequestException as err:
        print("Will retry next poll:", err, flush=True)
    time.sleep(INTERVAL)

Endpoint reference: Advanced search. Query syntax: search operators.

Polling or X: which costs less.

Polling costs a fixed amount per poll until a poll needs a second page. X charges per post delivered. For a quiet account X is cheaper; for a busy keyword or many accounts in one query, polling is.

One query, polled at $0.0006 a request
EveryRequests a day30 days
15 seconds5,760$103.68
30 seconds2,880$51.84
1 minute1,440$25.92
5 minutes288$5.18
15 minutes96$1.73

When fewer than 20 new tweets arrive per interval.

Cost over 30 days, 300 tweets a day
ProviderBarValue
Polling cheapxapiPolls every minute$25.92
X API stream or webhook$0.005 per post delivered$45.00

Polling every minute costs $25.92 over 30 days even when nothing is posted, and only more when one poll needs a second page. X’s per-post price is lower below about 173 tweets a day at this interval.

Which to use.

  • Seconds matter, or you need DMs: use X’s own webhooks or filtered stream. A search cannot read DMs, and polling is never faster than its interval.
  • A few quiet accounts: the X Activity API at $0.005 a post usually costs less than polling every minute.
  • Busy keywords or many accounts, a minute’s delay is fine: poll one search with the script above. No developer account, no public endpoint for X to check, and a burst of tweets adds a request per page, not a charge per tweet.

Questions.

Does Twitter (X) have webhooks?

Yes. You register an HTTPS URL with X’s V2 Webhooks API, pass its CRC check, and then attach a product that sends events to it: the X Activity API for events on specific accounts, the older Account Activity API for accounts that authorized your app, or filtered stream webhooks for keyword rules on Enterprise plans.

Can I get a webhook when a specific account tweets?

Yes. Subscribe to the post.create event in the X Activity API with that account’s user ID; it works for any public account and costs $0.005 per post delivered. Without a developer account, poll a from: search and forward new tweets to your URL, as the script on this page does.

Can I get a webhook for a keyword or hashtag?

Through X, keyword webhooks are Enterprise only. Pay-per-use accounts get the same rules through the filtered stream, which you read over a persistent HTTP connection instead of a webhook. The alternative is polling a search for the keyword.

Does cheapxapi send webhooks?

No. cheapxapi answers requests; it does not push data. The script on this page runs on your machine, polls a search and posts each new tweet to the webhook URL you choose.

How fast does a polled tweet arrive?

Within one polling interval plus the time the tweet takes to show up in X search. X documents about 4 to 5 seconds P99 latency for its filtered stream; when seconds matter, use that.