# Guide to X (Twitter) Webhooks: Features and Best Practices

X (formerly Twitter) webhooks let your application react to account activity in real time: a post is created, a Direct Message arrives, someone follows an account you manage. Instead of polling the API, X pushes each activity to your endpoint as a JSON POST through the Account Activity API, delivered over the V2 Webhooks API.

This guide covers how X webhooks work, the events you can subscribe to, the two-part security model (the Challenge-Response Check plus per-delivery signatures), the limitations worth knowing before you build, and the best practices that keep an integration reliable in production.

## What are X (Twitter) webhooks?

X webhooks are HTTP callbacks that deliver real-time event notifications tied to specific user accounts. When an account you're subscribed to posts, likes, follows, or sends a Direct Message, X sends a POST request with a JSON body describing the activity. This is the push-based alternative to repeatedly calling the API to check for new activity.

Webhooks are registered and managed through the V2 Webhooks API (`/2/webhooks`), and the events themselves come from the Account Activity API (AAA), which ties activity to the users who have authorized your app. Access requires an approved developer account with a Project and App, and your endpoint has to satisfy X's registration checks before it will receive anything.

## X webhook features

| Feature | Details |
| --- | --- |
| Webhook configuration | V2 Webhooks API (`POST`/`GET`/`DELETE /2/webhooks`), OAuth 2.0 App-Only bearer auth |
| Endpoint validation | Challenge-Response Check (CRC) at registration, roughly hourly, and on demand |
| Signature scheme | HMAC-SHA256 over the raw body, keyed with the consumer secret, base64-encoded, `sha256=` prefix |
| Signature header | `x-twitter-webhooks-signature` |
| Replay protection | None; the signature carries no timestamp |
| Endpoint requirements | Public HTTPS, no port in the URL, respond within 10 seconds with a 200 |
| Event source | Account Activity API, per-user subscriptions |
| Retry behavior | Not documented for V2; treat delivery as best-effort |
| Access tiers | Pay-Per-Use (1 webhook, 3 subscriptions) or Enterprise (5+ webhooks, 5,000+ subscriptions) |
| Official verification SDK | None |

## Account Activity events

The Account Activity API delivers events as JSON objects keyed by activity type. The names follow a consistent convention: lowercase, underscore-separated, plural, with an `_events` suffix. The one exception is `user_event` (singular), which carries account-level revocation activity.

| Activity | Fires when |
| --- | --- |
| `tweet_create_events` | A subscribed user posts, replies, quotes, or Retweets |
| `tweet_delete_events` | A subscribed user deletes a post |
| `favorite_events` | A subscribed user likes a post |
| `follow_events` | A subscribed user follows, or is followed by, another account |
| `block_events` | A subscribed user blocks or unblocks an account |
| `mute_events` | A subscribed user mutes or unmutes an account |
| `direct_message_events` | A Direct Message is sent to or from a subscribed user |
| `direct_message_indicate_typing_events` | Someone starts typing in a DM conversation with a subscribed user |
| `direct_message_mark_read_events` | A DM conversation is marked read |
| `user_event` | Account authorization is revoked (subscription should be removed) |

Each POST is scoped to one subscribed user and may contain one or more activity objects. Subscribe only to the activity types your integration needs, and consult X's Account Activity API reference for the authoritative, current catalog.

## Setting up X webhooks

Configuration is API-only. There's no dashboard for registering a webhook endpoint.

### Register a webhook endpoint

Register your endpoint against the V2 Webhooks API using an OAuth 2.0 App-Only bearer token:

```bash
curl -X POST "https://api.x.com/2/webhooks" \
  -H "Authorization: Bearer $APP_BEARER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://your-app.example.com/webhook"}'

```

At registration, X immediately issues a Challenge-Response Check (see below) against the URL. If your endpoint doesn't answer the CRC correctly, registration fails and the webhook is marked `invalid`. You can list webhooks with `GET /2/webhooks`, remove one with `DELETE /2/webhooks/:webhook_id`, and force a fresh CRC with `PUT /2/webhooks/:webhook_id`.

### Subscribe a user

Registering the webhook only creates the delivery endpoint. To actually receive a user's activity, add a subscription in that user's OAuth 1.0a context:

```bash
curl -X POST "https://api.x.com/2/account_activity/webhooks/:webhook_id/subscriptions/all" \
  -H "Authorization: OAuth ..." # OAuth 1.0a user context

```

The subscription authorizes X to send that user's Account Activity to your registered webhook.

## Securing X webhooks

X's security model has two independent parts. The CRC proves you control the endpoint and hold the consumer secret; it runs at registration and on an ongoing basis. The per-delivery signature proves each individual POST came from X. You need both.

A critical detail for both: the key is your app's consumer secret (the API secret key), never the bearer token or an access token.

### The Challenge-Response Check (CRC)

X sends a `GET` to your webhook URL with a `crc_token` query parameter at registration, roughly every hour, and whenever you trigger a `PUT /2/webhooks/:webhook_id`. You must respond promptly with a JSON body containing a `response_token`: an HMAC-SHA256 of the `crc_token`, keyed with your consumer secret, base64-encoded and prefixed with `sha256=`. Fail it and X marks the webhook `invalid` and stops delivering events until it passes again.

```javascript
const crypto = require("crypto");

const CONSUMER_SECRET = process.env.X_CONSUMER_SECRET; // API secret key

function sign(message) {
  return (
    "sha256=" +
    crypto.createHmac("sha256", CONSUMER_SECRET).update(message).digest("base64")
  );
}

// CRC handshake: X sends GET /webhook?crc_token=...
app.get("/webhook", (req, res) => {
  res.status(200).json({ response_token: sign(req.query.crc_token) });
});

```

### Verifying the signature on each delivery

Every POST carries an `x-twitter-webhooks-signature` header. Recompute the HMAC over the exact raw request body (verify before any JSON parsing or re-serialization would alter the bytes) and compare with a constant-time comparison.

```javascript
function verifySignature(rawBody, signatureHeader) {
  const expected = sign(rawBody);
  const received = Buffer.from(signatureHeader || "");
  const computed = Buffer.from(expected);
  return (
    received.length === computed.length &&
    crypto.timingSafeEqual(received, computed)
  );
}

app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const signature = req.headers["x-twitter-webhooks-signature"];
  if (!verifySignature(req.body, signature)) {
    return res.status(403).send("Invalid signature");
  }

  const event = JSON.parse(req.body);
  res.sendStatus(200); // acknowledge fast
  processQueue.add(event); // then process asynchronously
});

```

The same two flows in Python (Flask):

```python
import base64
import hashlib
import hmac
import os
from flask import Flask, request, jsonify

app = Flask(__name__)
CONSUMER_SECRET = os.environ["X_CONSUMER_SECRET"].encode()  # API secret key

def sign(message: bytes) -> str:
    digest = hmac.new(CONSUMER_SECRET, message, hashlib.sha256).digest()
    return "sha256=" + base64.b64encode(digest).decode()

@app.get("/webhook")
def crc():
    crc_token = request.args["crc_token"]
    return jsonify(response_token=sign(crc_token.encode()))

@app.post("/webhook")
def webhook():
    signature = request.headers.get("x-twitter-webhooks-signature", "")
    if not hmac.compare_digest(signature, sign(request.get_data())):
        return "Invalid signature", 403

    # process request.get_json() asynchronously
    return "", 200

```

## X webhook limitations and pain points

### No documented retry behavior

The Problem: X does not document any retry schedule for V2 webhook deliveries. In practice this means you should treat delivery as best-effort: if your endpoint is down, slow, or returns a non-200, the event may simply be gone. There's no delivery log to recover it from either.

Why It Happens: X's V2 webhook documentation focuses on registration, the CRC, and signature verification, and doesn't publish delivery-retry semantics the way many billing providers do.

Workarounds:

* Keep the endpoint highly available and acknowledge every delivery with a 200 as fast as possible.
* Reconcile against the API periodically for the activity you can't afford to miss, rather than trusting webhooks as a system of record.
* Log every raw delivery on receipt so you have your own record independent of X.

How Hookdeck Can Help: Hookdeck accepts each delivery on your behalf, acknowledges X immediately, and durably queues the event. If your downstream service is unavailable, Hookdeck retries on a schedule you control and preserves failed events for replay, so a momentary outage on your side no longer means lost activity.

### No timestamp means no replay protection

The Problem: The signature scheme is an HMAC over the body only. There is no timestamp in the signed material, so a captured, validly signed payload can be replayed later and it will still verify. Signature verification alone doesn't tell you the request is fresh.

Why It Happens: X's scheme predates the timestamped designs (like Standard Webhooks) that bake replay windows into the signature, and it wasn't updated to add one.

Workarounds:

* Make event processing idempotent so replaying a payload has no additional effect.
* Track an activity identifier from the payload and drop anything you've already handled.
* Terminate TLS correctly and restrict the endpoint to reduce the chance of capture in the first place.

How Hookdeck Can Help: Hookdeck deduplicates deliveries using configurable rules before they reach your application, so a replayed or duplicated payload is filtered at the edge. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### The hourly CRC keeps your endpoint on the hook

The Problem: The CRC isn't a one-time registration step. X re-issues it roughly every hour, and a single failed check flips the webhook to `invalid` and halts delivery. An endpoint that can serve POSTs but mishandles the GET CRC (or loses access to the consumer secret) silently stops receiving events.

Why It Happens: The recurring CRC is how X continuously confirms you still control the endpoint and hold the signing secret.

Workarounds:

* Implement and test the `GET` CRC handler as carefully as the `POST` handler; don't treat it as an afterthought.
* Alert on `invalid` webhook status via `GET /2/webhooks` so you catch a failing CRC before activity piles up as lost.
* Keep the consumer secret available to the CRC handler in every environment the endpoint runs in.

How Hookdeck Can Help: Point X at Hookdeck and Hookdeck answers the CRC and verifies signatures at the edge, keeping the endpoint validated while your application only ever handles clean, verified events.

### Ten-second response window

The Problem: Your endpoint must return a 200 within 10 seconds. Any synchronous work in the handler (database writes, downstream API calls, enrichment) eats into that budget, and a slow response counts as a failure.

Why It Happens: X caps how long it will wait for an acknowledgement to keep delivery throughput predictable across all subscribers.

Workarounds:

* Acknowledge first, process later: return 200 as soon as the signature verifies, then hand the event to a queue.
* Keep zero blocking I/O in the request path before you respond.

How Hookdeck Can Help: Hookdeck absorbs the delivery in milliseconds and forwards to your endpoint on a configurable timeout and delivery rate, decoupling X's 10-second window from however long your processing actually takes.

### API-only configuration and tiered limits

The Problem: There's no UI for webhook management, and Account Activity access is tiered: Pay-Per-Use allows a single webhook and three subscriptions, while higher subscription counts require Enterprise access. Building past a small proof of concept means scripting the API and planning around the subscription ceiling.

Why It Happens: X exposes webhook lifecycle purely through the V2 API and gates activity volume behind access tiers.

Workarounds:

* Treat webhook and subscription configuration as code: script registration, subscription, and teardown so environments are reproducible.
* Track your subscription count against your tier limit and request higher access before you hit the ceiling.

How Hookdeck Can Help: Hookdeck gives you a dashboard and API over your webhook connections, plus the observability, filtering, and routing that X's raw API leaves you to build.

## Best practices

### Always run both the CRC and per-delivery verification

The CRC validates the endpoint; the `x-twitter-webhooks-signature` check validates each POST. Skipping the signature check leaves your handler acting on any unauthenticated request that reaches the URL. Do both, and key both off the consumer secret.

### Verify against the raw body

Recompute the HMAC over the exact bytes X sent, before parsing. Re-serializing the JSON can change whitespace or key order and break the comparison. Use `express.raw` (Node) or `request.get_data()` (Flask) to hold the untouched body.

### Use constant-time comparison

Compare the received and computed signatures with `crypto.timingSafeEqual` / `hmac.compare_digest`, never `==`. String comparison can leak timing information about how much of the signature matched.

### Acknowledge fast, process asynchronously

Return 200 within milliseconds and defer real work to a queue. This keeps you inside the 10-second window and means a slow downstream never turns into a failed delivery. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Process idempotently and reconcile

With no documented retries, no replay protection, and no delivery log, webhooks are a notification mechanism, not a source of truth. Dedupe on an activity identifier, make side effects idempotent, and reconcile against the API for anything you can't afford to lose.

### Monitor webhook status

Poll `GET /2/webhooks` for an `invalid` status so a failing CRC surfaces as an alert rather than as silently missing activity.

## Conclusion

X (Twitter) webhooks turn Account Activity into something the rest of your stack can react to without polling. The security model is more demanding than most: a recurring Challenge-Response Check plus a per-delivery HMAC signature, both keyed with your consumer secret, both easy to get wrong. And the delivery guarantees are thin: X publishes no retry schedule, the signature carries no replay window, and there's a hard 10-second response budget.

That combination puts the operational burden on you. Answering the CRC reliably, verifying every signature against the raw body, acknowledging fast, deduplicating, and reconciling for the activity you can't miss are all your responsibility unless you offload them.

For teams that would rather build on top of X's activity than babysit its delivery mechanics, [Hookdeck](https://hookdeck.com) answers the CRC, verifies signatures, deduplicates, and durably queues every event at the edge, so your application only ever sees clean, verified activity.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle X (Twitter) webhooks reliably in minutes.