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.
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.
ALCHEMY_SIGNING_KEY=replace-with-your-signing-keyStart 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 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-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. 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.
- Open the webhooks dashboard and select Create Webhook.
- Pick the webhook type that fits your case. Alchemy offers Address Activity, NFT Activity and Custom Webhooks.
- Enter the webhook URL in the Webhook URL field and finish creating the webhook.
- Select your webhook. Copy the signing key from the top right of its detail page into
ALCHEMY_SIGNING_KEYin.env.local. - Restart
npm run devso 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:
POST 200 /api/webhooks/alchemyYour app terminal prints:
Received Alchemy webhook: <number> bytesIf the line shows [401], see Troubleshooting.
Troubleshooting
The signature doesn't match
- Check that
ALCHEMY_SIGNING_KEYis 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.parseandJSON.stringifyfirst, because that can change the bytes. - Compare against a hex digest. Alchemy sends no
sha256=prefix. - Restart
npm run devafter 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 forReconnected. - Check that the webhook URL on the dashboard ends with
/api/webhooks/alchemy.
Next steps
- Read Alchemy's Webhooks quickstart.
Test Trend Micro webhooks locally
Receive Trend Micro Cloud One Conformity webhook notifications on localhost with a Horizon tunnel, and verify X-TrendMicro-Signature.
Test Amazon SNS HTTPS subscriptions locally
Receive Amazon SNS messages on localhost with a Horizon tunnel, confirm the subscription, and verify the message signature in Next.js.