> ## Documentation Index
> Fetch the complete documentation index at: https://x-preview-mintlify-89bc24d3.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Quickstart

> This guide walks you through setting up a webhook consumer app, implementing the. Reference for the X API v2 standard tier covering webhooks.

This guide walks you through setting up a webhook consumer app, implementing the Challenge-Response Check (CRC), securing incoming events, and registering your webhook with X.

## 1. Develop a webhook consumer app

To register a webhook with your X app, you need to develop, deploy, and host a web app that receives X webhook events and responds to CRC security requests.

### URL requirements

Create a web app with a publicly accessible HTTPS URL that will act as the webhook endpoint to receive events:

* The URI **path** is up to you. These examples are all valid:
  * `https://mydomain.com/service/listen`
  * `https://mydomain.com/webhook/twitter`
* The URL **cannot** include a port specification (e.g., `https://mydomain.com:5000/webhook` will **not** work)

### What your app needs to handle

Your webhook endpoint must handle two types of HTTP requests:

| Request type | Purpose                                                                  |
| :----------- | :----------------------------------------------------------------------- |
| **GET**      | [CRC validation](#2-the-crc-check) — X verifies you control the endpoint |
| **POST**     | Event delivery — X sends JSON event payloads                             |

***

## 2. The CRC check

The Challenge-Response Check (CRC) is how X validates that the callback URL you provided is valid and that **you control it**. Your web app must correctly respond to CRC requests to register and maintain your webhook.

### When CRC is triggered

| Trigger                  | Description                                       |
| :----------------------- | :------------------------------------------------ |
| **Initial registration** | When you call `POST /2/webhooks`                  |
| **Hourly validation**    | X automatically validates your webhook every hour |
| **Manual re-validation** | When you call `PUT /2/webhooks/:webhook_id`       |

If your webhook fails a CRC check, it will be marked as `invalid` and will **stop receiving events** until it passes again.

### How the CRC works

When X sends a CRC, it makes a **GET request** to your webhook URL with a `crc_token` query parameter:

```
GET https://your-webhook-url.com/webhook?crc_token=challenge_string
```

Your application must respond with a JSON body containing a `response_token`:

```json theme={null}
{
  "response_token": "sha256=<base64_encoded_hmac_hash>"
}
```

### How to build the CRC response

1. Use the `crc_token` value from the query parameter as the **message**
2. Use your app's **OAuth 2.0 client secret** as the **key** (recommended). Your app's **OAuth 1.0 consumer secret** (API Secret Key) is also supported for existing integrations.
3. Create an **HMAC SHA-256** hash
4. **Base64 encode** the result
5. Prepend `sha256=` to the encoded string

Expressed as pseudocode:

```
response_token = "sha256=" + base64(
  hmac_sha256(OAUTH2_CLIENT_SECRET, crc_token)
)
```

<Warning>
  **Important:** Use the app's **OAuth 2.0 client secret** (or, for legacy integrations, the **OAuth 1.0 consumer secret**) to compute the CRC response. Do **not** use the OAuth 2.0 App Only Bearer Token you pass to `/2/webhooks`, and do not use any user access token. Client and consumer secrets must remain server-side.
</Warning>

<Note>
  The examples below use `WEBHOOK_SIGNING_SECRET` as a generic name for the app's **OAuth 2.0 client secret**. To keep an existing OAuth 1.0 integration working, set `WEBHOOK_SIGNING_SECRET` to your `OAUTH1_CONSUMER_SECRET` instead — the algorithm is identical.
</Note>

### Example: Python

```python title="Example" lines wrap icon="python" theme={null}
import hmac
import hashlib
import base64

def handle_crc(crc_token, webhook_signing_secret):
    """
    Respond to an X CRC check.

    Args:
        crc_token: The crc_token query parameter from the GET request
        webhook_signing_secret: Your app's OAuth 2.0 client secret
            (or OAuth 1.0 consumer secret for legacy integrations)

    Returns:
        dict with the response_token
    """
    sha256_hash = hmac.new(
        webhook_signing_secret.encode('utf-8'),
        crc_token.encode('utf-8'),
        hashlib.sha256
    ).digest()

    return {
        "response_token": "sha256=" + base64.b64encode(sha256_hash).decode('utf-8')
    }
```

### Example: Node.js

```javascript title="Example" lines wrap icon="square-js" theme={null}
const crypto = require('crypto');

// OAuth 2.0 client secret (recommended). Set to your OAuth 1.0 consumer
// secret instead to keep an existing OAuth 1.0 integration working.
function handleCrc(crcToken, webhookSigningSecret) {
  const hmac = crypto
    .createHmac('sha256', webhookSigningSecret)
    .update(crcToken)
    .digest('base64');

  return {
    response_token: `sha256=${hmac}`
  };
}
```

### Example: Flask (full endpoint)

This example shows a complete webhook endpoint that handles both CRC validation (GET) and event delivery (POST). It uses the OAuth 2.0 client secret by default and, when both secrets are configured, verifies incoming POSTs against `X-Twitter-Webhooks-Signature-OAuth2` first, falling back to the legacy `X-Twitter-Webhooks-Signature` header.

```python title="Example" expandable lines wrap icon="python" theme={null}
import os
import hmac
import hashlib
import base64
from flask import Flask, request, jsonify

app = Flask(__name__)

# Recommended: the app's OAuth 2.0 client secret.
OAUTH2_CLIENT_SECRET = os.environ["OAUTH2_CLIENT_SECRET"]

# Optional: the app's OAuth 1.0 consumer secret (API Secret Key). Only set
# this while migrating an existing OAuth 1.0 integration.
OAUTH1_CONSUMER_SECRET = os.environ.get("OAUTH1_CONSUMER_SECRET")


def _sign(secret: str, message: bytes) -> str:
    digest = hmac.new(secret.encode("utf-8"), message, hashlib.sha256).digest()
    return "sha256=" + base64.b64encode(digest).decode("utf-8")


@app.route("/webhook", methods=["GET", "POST"])
def webhook():
    if request.method == "GET":
        # CRC check — respond with HMAC of crc_token using the OAuth 2.0
        # client secret.
        crc_token = request.args.get("crc_token")
        if not crc_token:
            return "Missing crc_token", 400
        return jsonify({"response_token": _sign(OAUTH2_CLIENT_SECRET, crc_token.encode("utf-8"))}), 200

    # POST — verify signature over the raw request body.
    raw_body = request.get_data()  # bytes

    oauth2_signature = request.headers.get("X-Twitter-Webhooks-Signature-OAuth2")
    if oauth2_signature is not None:
        expected = _sign(OAUTH2_CLIENT_SECRET, raw_body)
        if not hmac.compare_digest(expected, oauth2_signature):
            return "Invalid signature", 401
    else:
        # Legacy fallback for OAuth 1.0-only apps during migration.
        legacy_signature = request.headers.get("X-Twitter-Webhooks-Signature")
        if legacy_signature is None or OAUTH1_CONSUMER_SECRET is None:
            return "Missing signature", 401
        expected = _sign(OAUTH1_CONSUMER_SECRET, raw_body)
        if not hmac.compare_digest(expected, legacy_signature):
            return "Invalid signature", 401

    event = request.get_json(silent=True)
    print("Received event:", event)
    return "", 200
```

***

## 3. Securing webhooks

X's webhook-based APIs provide two methods for confirming the security of your webhook server:

### Challenge-Response Check (CRC)

The CRC enables X to confirm ownership of the web app receiving webhook events. See [Step 2](#2-the-crc-check) above for full implementation details.

### Signature verification

Each POST request from X includes a signature header that enables you to confirm that X is the source of the incoming webhook. Two headers are possible:

| Header                                | Secret used                                | Recommendation                                                  |
| :------------------------------------ | :----------------------------------------- | :-------------------------------------------------------------- |
| `X-Twitter-Webhooks-Signature-OAuth2` | OAuth 2.0 client secret                    | Recommended. Verify this header when present.                   |
| `X-Twitter-Webhooks-Signature`        | OAuth 1.0 consumer secret (API Secret Key) | Legacy. Format unchanged. Supported for backward compatibility. |

Both headers use the format `sha256=<base64_encoded_hmac_sha256>` and are calculated over the **raw request body bytes**. If your app has both an OAuth 2.0 client secret and an OAuth 1.0 consumer secret configured, X may send both headers during migration — verify `X-Twitter-Webhooks-Signature-OAuth2` and optionally fall back to `X-Twitter-Webhooks-Signature`.

To verify a signature:

1. Read the incoming request body as raw bytes (do not re-serialize the JSON).
2. Get the signature header value from the request, preferring `X-Twitter-Webhooks-Signature-OAuth2`.
3. Create an HMAC SHA-256 hash using the corresponding secret as the key and the raw body as the message.
4. Base64 encode the hash and prepend `sha256=`.
5. Compare the computed value to the header value using a **constant-time comparison**.

Expressed as pseudocode:

```
oauth2_signature = request.headers["X-Twitter-Webhooks-Signature-OAuth2"]
expected = "sha256=" + base64(
  hmac_sha256(OAUTH2_CLIENT_SECRET, raw_request_body)
)
constant_time_compare(expected, oauth2_signature)
```

```python title="Example" expandable lines wrap icon="python" theme={null}
import hmac
import hashlib
import base64


def _sign(secret: str, payload: bytes) -> str:
    digest = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).digest()
    return "sha256=" + base64.b64encode(digest).decode("utf-8")


def verify_signature(
    payload: bytes,
    headers,
    oauth2_client_secret: str,
    oauth1_consumer_secret: str | None = None,
) -> bool:
    """
    Verify that a webhook POST request actually came from X.

    Args:
        payload: The raw request body (bytes).
        headers: A mapping of request headers.
        oauth2_client_secret: The app's OAuth 2.0 client secret (recommended).
        oauth1_consumer_secret: The app's OAuth 1.0 consumer secret. Only pass
            this when migrating from OAuth 1.0.

    Returns:
        True if the signature is valid.
    """
    oauth2_signature = headers.get("X-Twitter-Webhooks-Signature-OAuth2")
    if oauth2_signature is not None:
        expected = _sign(oauth2_client_secret, payload)
        return hmac.compare_digest(expected, oauth2_signature)

    legacy_signature = headers.get("X-Twitter-Webhooks-Signature")
    if legacy_signature is not None and oauth1_consumer_secret is not None:
        expected = _sign(oauth1_consumer_secret, payload)
        return hmac.compare_digest(expected, legacy_signature)

    return False
```

***

## 4. Register your webhook

Once your app can handle CRC checks, register your webhook URL by making a `POST /2/webhooks` request. When you make this request, X will immediately send a CRC request to your web app to verify ownership.

All webhook management endpoints require **OAuth2 App Only Bearer Token** authentication.

### Create a webhook

**`POST /2/webhooks`** — [API Reference](/x-api/webhooks/create-webhook)

```bash theme={null}
curl --request POST \
  --url 'https://api.x.com/2/webhooks' \
  --header 'Authorization: Bearer $BEARER_TOKEN' \
  --header 'Content-Type: application/json' \
  --data '{
    "url": "https://yourdomain.com/webhooks/twitter"
  }'
```

**Success response (200 OK):**

A successful response indicates the webhook was created and the initial CRC check passed.

```json theme={null}
{
  "data": {
    "id": "1234567890",
    "url": "https://yourdomain.com/webhooks/twitter",
    "valid": true,
    "created_at": "2025-01-15T12:00:00.000Z"
  }
}
```

When a webhook is successfully registered, the response includes a **webhook ID**. This ID is needed when making requests to products that support webhooks (e.g., linking to Filtered Stream, or creating subscriptions for Account Activity).

**Common failure reasons:**

| Reason                 | Description                                                                                    |
| :--------------------- | :--------------------------------------------------------------------------------------------- |
| `CrcValidationFailed`  | Your callback URL did not respond correctly to the CRC check (e.g., timed out, wrong response) |
| `UrlValidationFailed`  | The callback URL does not meet requirements (e.g., not `https`, invalid format)                |
| `DuplicateUrlFailed`   | A webhook is already registered by your application for this URL                               |
| `WebhookLimitExceeded` | Your application has reached the maximum number of allowed webhooks                            |

### View webhooks

**`GET /2/webhooks`** — [API Reference](/x-api/webhooks/get-webhook)

Retrieve all webhook configurations associated with your application.

```bash theme={null}
curl --request GET \
  --url 'https://api.x.com/2/webhooks' \
  --header 'Authorization: Bearer $BEARER_TOKEN'
```

**Response (with one webhook):**

```json title="Example response" lines wrap icon="https://mintcdn.com/x-preview-mintlify-89bc24d3/lNl-xiwjECcg_9uj/icons/xds/icon-brackets.svg?fit=max&auto=format&n=lNl-xiwjECcg_9uj&q=85&s=8d277cdd7f42dc610f2eda315f5982cd" theme={null}
{
  "data": [
    {
      "created_at": "2025-01-15T12:00:00.000Z",
      "id": "1234567890",
      "url": "https://yourdomain.com/webhooks/twitter",
      "valid": true
    }
  ],
  "meta": {
    "result_count": 1
  }
}
```

**Response (with no webhooks):**

```json theme={null}
{
  "data": [],
  "meta": {
    "result_count": 0
  }
}
```

### Delete a webhook

**`DELETE /2/webhooks/:webhook_id`** — [API Reference](/x-api/webhooks/delete-webhook)

Delete a webhook using its `webhook_id` (obtained from the create or list response).

```bash theme={null}
curl --request DELETE \
  --url 'https://api.x.com/2/webhooks/1234567890' \
  --header 'Authorization: Bearer $BEARER_TOKEN'
```

**Response:**

```json theme={null}
{
  "data": {
    "deleted": true
  }
}
```

| Failure reason     | Description                                                                |
| :----------------- | :------------------------------------------------------------------------- |
| `WebhookIdInvalid` | The provided `webhook_id` was not found or is not associated with your app |

### Validate and re-enable a webhook

**`PUT /2/webhooks/:webhook_id`** — [API Reference](/x-api/webhooks/validate-webhook)

Triggers a CRC check for the given webhook. If the check succeeds, the webhook is re-enabled with `valid: true`.

```bash theme={null}
curl --request PUT \
  --url 'https://api.x.com/2/webhooks/1234567890' \
  --header 'Authorization: Bearer $BEARER_TOKEN'
```

**Response:**

A 200 OK response indicates the CRC check was initiated. The `valid` field reflects the status after the check attempt. You can verify the current status using `GET /2/webhooks`.

```json theme={null}
{
  "data": {
    "valid": true
  }
}
```

| Failure reason        | Description                                                                |
| :-------------------- | :------------------------------------------------------------------------- |
| `WebhookIdInvalid`    | The provided `webhook_id` was not found or is not associated with your app |
| `CrcValidationFailed` | The callback URL did not respond correctly to the CRC check                |

***

## Testing with xurl

For testing purposes, the `xurl` tool supports temporary webhooks. Install the latest version of the [`xurl` project](https://github.com/xdevplatform/xurl) from GitHub, configure your authorization, then run:

```bash theme={null}
xurl webhook start
```

This will generate a temporary public webhook URL, automatically handle all CRC checks, and log any incoming subscription events. It's a great way to verify your setup before deploying. Example output:

```
Starting webhook server with ngrok...
Enter your ngrok authtoken (leave empty to try NGROK_AUTHTOKEN env var):

Attempting to use NGROK_AUTHTOKEN environment variable for ngrok authentication.
Configuring ngrok to forward to local port: 8080
Ngrok tunnel established!
  Forwarding URL: https://<your-ngrok-subdomain>.ngrok-free.app -> localhost:8080

Use this URL for your X API webhook registration: https://<your-ngrok-subdomain>.ngrok-free.app/webhook

Starting local HTTP server to handle requests from ngrok tunnel...
```

***

## Important notes

<Warning>
  * **All incoming Direct Messages** will be delivered via webhooks. DMs sent via [POST /2/dm\_conversations/with/:participant\_id/messages](/x-api/direct-messages/send-a-new-message-to-a-user) will also be delivered, so your app can track DMs sent from other clients.

  * If you have **more than one web app** sharing the same webhook URL and the same user mapped to each app, the same event will be sent to your webhook **multiple times** (once per web app).

  * In some cases, your webhook may receive **duplicate events**. Your webhook app should be tolerant of this and **deduplicate by event ID**.

  * X sends events as **POST requests** with JSON payloads. See the [Account Activity data object structure](/x-api/account-activity/introduction#account-activity-data-object-structure) for example payloads.
</Warning>

***

## Sample apps

| App                                                                                                                 | Description                                                                                                           |
| :------------------------------------------------------------------------------------------------------------------ | :-------------------------------------------------------------------------------------------------------------------- |
| [Simple webhook server](https://github.com/m-rosinsky/XWebhookTest/blob/main/app.py)                                | A single Python script that shows how to respond to the CRC check and accept POST events                              |
| [Account Activity API dashboard](https://github.com/xdevplatform/account-activity-dashboard-enterprise/tree/master) | A web app written with [bun.sh](https://bun.sh) that lets you manage webhooks, subscriptions, and receive live events |
| [xurl testing tool](https://github.com/xdevplatform/xurl)                                                           | CLI tool for temporary webhook testing — auto-handles CRC checks and logs events                                      |

***

## Next steps

<CardGroup cols={2}>
  <Card title="Filtered Stream Webhooks" icon="https://mintcdn.com/x-preview-mintlify-89bc24d3/fuIwh747uqeNjlcH/icons/xds/icon-filter.svg?fit=max&auto=format&n=fuIwh747uqeNjlcH&q=85&s=c7366d072aaafe9cc3bb754ff169510b" href="/x-api/webhooks/stream/introduction" width="24" height="24" data-path="icons/xds/icon-filter.svg">
    Receive filtered Posts via webhook
  </Card>

  <Card title="Account Activity API" icon="https://mintcdn.com/x-preview-mintlify-89bc24d3/lNl-xiwjECcg_9uj/icons/xds/icon-bell.svg?fit=max&auto=format&n=lNl-xiwjECcg_9uj&q=85&s=d09cf25842ac56b13eb27e9523d3f8f8" href="/x-api/account-activity/introduction" width="24" height="24" data-path="icons/xds/icon-bell.svg">
    Receive account events via webhook
  </Card>
</CardGroup>
