Horizon

Test Alchemy webhooks locally

Receive Alchemy Notify webhook events on localhost with a Horizon tunnel, and verify the X-Alchemy-Signature header.

Receive Alchemy events on your laptop while you build, with a URL Alchemy 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 Alchemy account

Start your app

Create a route handler that checks the X-Alchemy-Signature header. Alchemy computes an HMAC (a keyed hash) of the raw request body with SHA-256 and your webhook's signing key, and sends the hex digest. Read the raw body with request.text(), not a re-serialized JSON body.

app/api/webhooks/alchemy/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";

export async function POST(request: Request) {
  const signingKey = process.env.ALCHEMY_SIGNING_KEY;
  if (!signingKey) {
    return new Response("Missing ALCHEMY_SIGNING_KEY", { status: 500 });
  }

  const body = await request.text();
  const received = request.headers.get("x-alchemy-signature") ?? "";
  const expected = createHmac("sha256", signingKey).update(body, "utf8").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 });
  }

  console.log(`Received Alchemy webhook: ${body.length} bytes`);

  return new Response("ok", { status: 200 });
}

Alchemy's own example compares with ===. timingSafeEqual does the same check without leaking timing.

Add the signing key to .env.local. You copy it in a later step.

.env.local
ALCHEMY_SIGNING_KEY=replace-with-your-signing-key

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 your Alchemy webhook would point at a dead URL after a restart. 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. Keep this terminal open.

Add the webhook in Alchemy

Your webhook URL is the tunnel URL plus the route path: https://my-app.hrzn.run/api/webhooks/alchemy.

  1. Open the webhooks dashboard and select Create Webhook.
  2. Pick the webhook type that fits your case. Alchemy offers Address Activity, NFT Activity and Custom Webhooks.
  3. Enter the webhook URL in the Webhook URL field and finish creating the webhook.
  4. Select your webhook. Copy the signing key from the top right of its detail page into ALCHEMY_SIGNING_KEY in .env.local.
  5. Restart npm run dev so Next.js loads the variable.

Every webhook has its own signing key. Don't use the Auth Token from the top of the webhooks dashboard. That token manages webhooks through the API.

Send a test event

On your webhook, select the Test Webhook button. Alchemy sends a test event to your URL.

Alchemy retries failed deliveries with exponential backoff for non-200 responses. Its docs list manual retries as coming soon.

Check it works

Your Horizon terminal prints one line for the test event:

Output
  POST    200  /api/webhooks/alchemy

Your app terminal prints:

Output
Received Alchemy webhook: <number> bytes

If the line shows [401], see Troubleshooting.

Troubleshooting

The signature doesn't match

  • Check that ALCHEMY_SIGNING_KEY is the signing key of this webhook, with no extra spaces or newline. It isn't the Auth Token.
  • Compute the HMAC over the raw body. Don't run JSON.parse and JSON.stringify first, because that can change the bytes.
  • Compare against a hex digest. Alchemy sends no sha256= prefix.
  • Restart npm run dev after you edit .env.local.

The request returns 404

The Horizon line shows [404]. The webhook URL doesn't match your route. The file app/api/webhooks/alchemy/route.ts serves /api/webhooks/alchemy, and Alchemy sends POST requests.

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 the URL stays the same. -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 webhook URL on the dashboard ends with /api/webhooks/alchemy.

Next steps

On this page