Horizon

Test Instagram webhooks locally

Receive Instagram API webhook events like comments and messages on localhost with a Horizon tunnel, and verify the X-Hub-Signature-256 signature.

Receive Instagram comments, mentions and messages 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 app with the Instagram API set up
  • An Instagram professional account that is public
  • An Instagram User access token or Facebook Page token for that account

Start your app

Instagram webhooks fire for fields such as comments, mentions, messages and message_reactions. Each field needs its own permission. For example, messages needs instagram_business_manage_messages. Comment and mention webhooks need Advanced Access, and your app has to be in Live mode to receive them from other people's accounts.

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.

app/api/webhooks/instagram/route.ts
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.INSTAGRAM_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.INSTAGRAM_APP_SECRET;
  if (!appSecret) {
    return new Response("Missing INSTAGRAM_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 Instagram 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.

.env.local
INSTAGRAM_VERIFY_TOKEN=replace-with-a-long-random-string
INSTAGRAM_APP_SECRET=replace-with-your-app-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, 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-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. Meta requires HTTPS, and every Horizon tunnel has it. Keep this terminal open.

Add the webhook in the App Dashboard

  1. Open your app in the App Dashboard and open the Webhooks product.
  2. Set Callback URL to https://my-app.hrzn.run/api/webhooks/instagram.
  3. Set Verify token to the same value as INSTAGRAM_VERIFY_TOKEN.
  4. Turn on Include Values if you want the new field values inside the payload.
  5. Subscribe to the fields you need, such as comments and messages, then save. Meta sends the verification GET request at this point.

Then subscribe the Instagram account to your app. Send POST /me/subscribed_apps?subscribed_fields=comments,messages&access_token=<ACCESS_TOKEN>. Use the Instagram User access token or the Facebook Page token. Meta answers {"success": true}.

Send a test event

In the Webhooks product, select Test next to any field. A dialog shows sample data. Select Send to My Server to send it to your callback URL.

To send a real event, comment on a post from the subscribed account's profile, or message the account.

Check it works

When you save the callback URL, the Horizon terminal prints a GET line with status 200.

When you send the test event, it prints one POST line:

Output
  POST    200  /api/webhooks/instagram

Your app terminal prints a line like:

Output
Received Instagram event: comments

A comment event looks like this. Fields can differ for other events.

{
  "entry": [
    {
      "time": 1520383571,
      "changes": [
        { "field": "comments", "value": { "verb": "update", "object_id": "10211885744794461" } }
      ],
      "id": "10210299214172187"
    }
  ],
  "object": "user"
}

Troubleshooting

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 INSTAGRAM_VERIFY_TOKEN is identical to the Verify token you typed in the App Dashboard.
  • Check that the callback URL ends with /api/webhooks/instagram.
  • Check that the handler answers with the hub.challenge value and nothing else.
  • Restart npm run dev after you edit .env.local.

The signature doesn't match

The Horizon line shows [401] for a POST request.

  • Check that INSTAGRAM_APP_SECRET is the right secret. With Instagram API with Instagram login, the dashboard shows an Instagram app secret next to the Meta App Secret. Meta's docs say "your app's App Secret" without naming which one, so try the other secret if one fails.
  • Compute the HMAC over the raw body. Don't run JSON.parse and JSON.stringify first, because that can change the bytes.
  • Restart npm run dev after you edit .env.local.

Comment or mention webhooks never arrive

Check these three things from Meta's requirements: your app is Live, the Instagram professional account is public, and your app has Advanced Access for the comment or mention permission.

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 for Reconnected.
  • Check that the npm run dev terminal is running on port 3000.

Next steps

On this page