Horizon

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.

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 curl requests

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.

app/api/webhooks/x/route.ts
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.

.env.local
X_CLIENT_SECRET=replace-with-your-oauth2-client-secret

Start the app on port 3000.

npm run dev

Start 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-app
Output
HORIZON: Tunnel connected
  URL          https://my-app.hrzn.run (reserved)
  Forwarding   http://localhost:3000
  Request log  https://hrzn.run/dashboard/tunnels/my-app

Your 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:

Output
  GET     200  /api/webhooks/x

To send a real event, post from the subscribed account. X sends a tweet_create_events payload. The Horizon terminal prints:

Output
  POST    200  /api/webhooks/x

Your app terminal prints a line like:

Output
Received X events for user 1234567890: tweet_create_events

To 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_SECRET is the OAuth 2.0 client secret of the app that registers the webhook. Restart npm run dev after 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/x and 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-OAuth2 with the OAuth 2.0 client secret. The legacy X-Twitter-Webhooks-Signature header uses the OAuth 1.0 consumer secret.
  • Compute the HMAC over the raw body. Don't run JSON.parse and JSON.stringify first, 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 for Reconnected.
  • 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 xurl tool has a xurl webhook start command that runs a temporary webhook and handles the CRC check for you.

On this page