Test X (Twitter) webhooks locally
Receive X Account Activity API webhook events on localhost with a Horizon tunnel, and pass the CRC check and signature verification.
Receive X account events on your laptop while you build, with an HTTPS URL X can reach.
X's docs say the Account Activity API is being deprecated in favor of the X Activity API. Check that your developer account has access before you start. The webhook handshake and signature below come from X's current webhooks docs.
Before you begin
- Node.js 18 or later
- A Horizon account and the CLI (see Getting started)
- A reserved subdomain for
-s. Reserve one on the Subdomains page. - A Next.js app that uses the App Router and runs on port 3000
- An X developer app with its OAuth 2.0 client secret, and an App Only Bearer Token
- A way to send
curlrequests
Start your app
X checks your endpoint with a Challenge-Response Check (CRC). It sends a GET request with a crc_token query parameter. Your handler answers with JSON that holds response_token: sha256= followed by the base64 encoding of an HMAC SHA-256 (a keyed hash) of crc_token, made with your OAuth 2.0 client secret. X runs the CRC when you register the webhook, again every hour, and when you re-validate it. A failed CRC marks the webhook invalid and stops delivery.
Events arrive as POST requests. X signs the raw body and sends the result in the X-Twitter-Webhooks-Signature-OAuth2 header as sha256=<base64 HMAC SHA-256>, made with the same client secret. X also sends the legacy X-Twitter-Webhooks-Signature header, signed with the OAuth 1.0 consumer secret. This page uses the OAuth 2.0 header, which X recommends.
import { createHmac, timingSafeEqual } from "node:crypto";
function sign(clientSecret: string, message: string) {
return `sha256=${createHmac("sha256", clientSecret).update(message).digest("base64")}`;
}
export async function GET(request: Request) {
const clientSecret = process.env.X_CLIENT_SECRET;
const crcToken = new URL(request.url).searchParams.get("crc_token");
if (!clientSecret || !crcToken) {
return new Response("Missing crc_token or X_CLIENT_SECRET", { status: 400 });
}
return Response.json({ response_token: sign(clientSecret, crcToken) });
}
export async function POST(request: Request) {
const clientSecret = process.env.X_CLIENT_SECRET;
if (!clientSecret) {
return new Response("Missing X_CLIENT_SECRET", { status: 500 });
}
const body = await request.text();
const received = request.headers.get("x-twitter-webhooks-signature-oauth2") ?? "";
const expected = sign(clientSecret, body);
const receivedBuffer = Buffer.from(received);
const expectedBuffer = Buffer.from(expected);
const isValid =
receivedBuffer.length === expectedBuffer.length &&
timingSafeEqual(receivedBuffer, expectedBuffer);
if (!isValid) {
return new Response("Invalid signature", { status: 401 });
}
const payload = JSON.parse(body);
const eventTypes = Object.keys(payload).filter((key) => key.endsWith("_events"));
console.log(`Received X events for user ${payload.for_user_id}: ${eventTypes.join(", ")}`);
return new Response("ok", { status: 200 });
}Add your client secret to .env.local. Find it in the X Developer Portal on your app's keys page.
X_CLIENT_SECRET=replace-with-your-oauth2-client-secretStart the app on port 3000.
npm run devStart a tunnel
Use -s with a subdomain you reserved. Without it, the subdomain is random and changes every run, and X would check a dead URL after a restart. Reserve it first on the Subdomains page. Reserved subdomains are a paid feature, see Pricing.
hrzn tunnel http://localhost:3000 -s my-appHORIZON: Tunnel connected
URL https://my-app.hrzn.run (reserved)
Forwarding http://localhost:3000
Request log https://hrzn.run/dashboard/tunnels/my-appYour public URL is https://my-app.hrzn.run. X needs an HTTPS URL without a port, and a Horizon URL fits that. Keep this terminal open.
Register the webhook
X has no dashboard form for this. You register the webhook with the API. Send a POST request to /2/webhooks with your App Only Bearer Token:
curl --request POST \
--url 'https://api.x.com/2/webhooks' \
--header "Authorization: Bearer $BEARER_TOKEN" \
--header 'Content-Type: application/json' \
--data '{"url": "https://my-app.hrzn.run/api/webhooks/x"}'X sends a CRC request to your URL right away. If your handler answers correctly, X returns the webhook ID.
Subscribe an account
Link the webhook to an account so X sends that account's events. Send POST /2/account_activity/webhooks/:webhook_id/subscriptions/all. X requires OAuth 1.0a user context for this call, with the access token of the account you subscribe. See X's Account Activity API docs for the full request.
Check it works
The registration call makes X send a CRC request. In the terminal that runs hrzn, you see a GET line with status 200:
GET 200 /api/webhooks/xTo send a real event, post from the subscribed account. X sends a tweet_create_events payload. The Horizon terminal prints:
POST 200 /api/webhooks/xYour app terminal prints a line like:
Received X events for user 1234567890: tweet_create_eventsTo ask X to run the CRC again, send PUT /2/webhooks/:webhook_id. To list your webhooks, send GET /2/webhooks.
Troubleshooting
Registration fails because the CRC check fails
X marks the webhook invalid, and no events arrive.
- Check that
X_CLIENT_SECRETis the OAuth 2.0 client secret of the app that registers the webhook. Restartnpm run devafter you edit.env.local. - Check that the JSON body is
{"response_token": "sha256=..."}and that the token is base64, not hex. - Check that the URL ends with
/api/webhooks/xand that the Horizon tunnel is running.
The signature doesn't match
The Horizon line shows [401] for a POST request.
- Check the header name. This page verifies
X-Twitter-Webhooks-Signature-OAuth2with the OAuth 2.0 client secret. The legacyX-Twitter-Webhooks-Signatureheader uses the OAuth 1.0 consumer secret. - Compute the HMAC over the raw body. Don't run
JSON.parseandJSON.stringifyfirst, because that can change the bytes.
Events stopped arriving
X re-runs the CRC every hour. If it fails, X marks the webhook invalid and stops delivery. Fix the handler, then send PUT /2/webhooks/:webhook_id to re-validate. If you restarted the tunnel without -s, the URL changed and the old one is dead. Restart with -s my-app, see Pricing.
Nothing reaches your app
- Check that the Horizon terminal is still running. If its last line is
Connection lost. Reconnecting…, wait forReconnected. - Check that you subscribed the account. A registered webhook with no subscription gets no account events.
Next steps
- Read X's webhooks quickstart.
- Read X's Account Activity API docs for every event type.
- X's
xurltool has axurl webhook startcommand that runs a temporary webhook and handles the CRC check for you.
Test WhatsApp Cloud API webhooks locally
Receive WhatsApp Business Cloud API webhooks, such as incoming messages and status updates, on localhost with a Horizon tunnel.
Test Autodesk Platform Services webhooks locally
Receive Autodesk Platform Services webhook events on localhost with a Horizon tunnel, and verify the x-adsk-signature header in Next.js.