Test Facebook webhooks locally
Receive Facebook Page webhook events from the Meta Graph API on localhost with a Horizon tunnel, and verify the X-Hub-Signature-256 signature.
Receive Facebook Page events on your laptop while you build, with an HTTPS URL Meta 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
- A Meta developer account and an app in the App Dashboard
- A Facebook Page you manage, and a Page access token for it
Start your app
This guide uses Page webhooks from the Meta Graph API. The webhook object is page, and the field used here is feed, which fires when something changes on a Page's feed. Meta's other webhook products (Messenger, Instagram, WhatsApp) use the same handshake and signature. See their guides in this section.
Meta checks your endpoint in two ways. First it sends a GET request with the query parameters hub.mode, hub.verify_token and hub.challenge. Your handler compares hub.verify_token with a token you chose and answers with the hub.challenge value. After that, Meta sends event notifications as POST requests. Each one carries an X-Hub-Signature-256 header: sha256= followed by an HMAC SHA-256 (a keyed hash) of the request body, made with your app's App Secret. Read the raw body with request.text() before you parse it, and compare with crypto.timingSafeEqual.
import { createHmac, timingSafeEqual } from "node:crypto";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const mode = searchParams.get("hub.mode");
const token = searchParams.get("hub.verify_token");
const challenge = searchParams.get("hub.challenge");
const isValid =
mode === "subscribe" && token === process.env.FACEBOOK_VERIFY_TOKEN && challenge;
if (!isValid) {
return new Response("Forbidden", { status: 403 });
}
return new Response(challenge, { status: 200 });
}
export async function POST(request: Request) {
const appSecret = process.env.FACEBOOK_APP_SECRET;
if (!appSecret) {
return new Response("Missing FACEBOOK_APP_SECRET", { status: 500 });
}
const body = await request.text();
const received = request.headers.get("x-hub-signature-256") ?? "";
const expected = `sha256=${createHmac("sha256", appSecret).update(body).digest("hex")}`;
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);
for (const entry of payload.entry ?? []) {
for (const change of entry.changes ?? []) {
console.log(`Received Facebook ${payload.object} event: ${change.field}`);
}
}
return new Response("ok", { status: 200 });
}Choose a verify token yourself. It can be any string. Find the App Secret in the App Dashboard under App settings, Basic.
FACEBOOK_VERIFY_TOKEN=replace-with-a-long-random-string
FACEBOOK_APP_SECRET=replace-with-your-app-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, so the callback URL you saved in Meta would point at 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. Meta requires HTTPS, and every Horizon tunnel has it. Keep this terminal open.
Add the webhook in the App Dashboard
- Open your app in the App Dashboard and add the Webhooks product if it isn't there yet.
- Select the Page object.
- Set Callback URL to
https://my-app.hrzn.run/api/webhooks/facebook. - Set Verify token to the same value as
FACEBOOK_VERIFY_TOKEN. - Select the
feedfield and subscribe to it. Turn on Include Values if you want the new field values inside the payload. - Save. Meta sends the verification
GETrequest to your URL at this point.
Then install your app on the Page. Send POST /<PAGE_ID>/subscribed_apps with subscribed_fields=feed and a Page access token. You can send it from the Graph API Explorer. The feed field needs the pages_manage_metadata and pages_show_list permissions. The person behind the Page access token needs the CREATE_CONTENT, MANAGE or MODERATE task on the Page.
Meta only delivers events for objects your app is installed on. Subscribing to feed in the dashboard isn't enough. You also need the subscribed_apps call for each Page.
Send a test event
In the Webhooks product, select Test next to the feed field. A dialog shows sample data. Select Send to My Server to send it to your callback URL.
To send a real event, post on your Page.
Check it works
When you save the callback URL, the Horizon terminal prints a GET line with status 200. Your handler echoed the challenge.
When you send the test event, it prints one POST line:
POST 200 /api/webhooks/facebookYour app terminal prints a line like:
Received Facebook page event: feedTroubleshooting
Meta can't validate the callback URL or verify token
The Horizon terminal shows a GET line with [403], or no line at all.
- Check that
FACEBOOK_VERIFY_TOKENis identical to the Verify token you typed in the App Dashboard. - Check that the callback URL ends with
/api/webhooks/facebook. - Check that the handler answers with the
hub.challengevalue and nothing else. - Restart
npm run devafter you edit.env.local.
The signature doesn't match
The Horizon line shows [401] for a POST request.
- Check that
FACEBOOK_APP_SECRETis the right secret. Use the App Secret from the same app that sends the webhooks. - Compute the HMAC over the raw body. Don't run
JSON.parseandJSON.stringifyfirst, because that can change the bytes. - Restart
npm run devafter you edit.env.local.
The URL changed after a restart
You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-app and update the callback URL in the App Dashboard if it differs. -s needs a subdomain you reserved, 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 the
npm run devterminal is running on port 3000.
Next steps
- Read Meta's guide to Webhooks for Pages.
- Read Meta's Webhooks getting started for retries and batching.
- Building a bot? See Test Facebook Messenger webhooks locally.
Test Zoom webhooks locally
Receive Zoom webhook events on localhost with a Horizon tunnel, answer the endpoint URL validation challenge, and verify x-zm-signature.
Test Facebook Messenger webhooks locally
Receive Messenger Platform webhook events on localhost with a Horizon tunnel, and verify the X-Hub-Signature-256 signature.