Test Zendesk webhooks locally
Receive Zendesk webhook requests on localhost with a Horizon tunnel, and verify the X-Zendesk-Webhook-Signature header.
Receive Zendesk events on your laptop while you build, with a URL Zendesk 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 Zendesk account where you can use Admin Center
Start your app
Zendesk sends two headers: X-Zendesk-Webhook-Signature and X-Zendesk-Webhook-Signature-Timestamp. The signature is the Base64 encoding of an HMAC SHA-256 (a keyed hash) of the timestamp followed by the raw body, using the webhook's signing secret.
import { createHmac, timingSafeEqual } from "node:crypto";
export async function POST(request: Request) {
const secret = process.env.ZENDESK_WEBHOOK_SECRET;
if (!secret) {
return new Response("Missing ZENDESK_WEBHOOK_SECRET", { status: 500 });
}
const body = await request.text();
const received = request.headers.get("x-zendesk-webhook-signature") ?? "";
const timestamp =
request.headers.get("x-zendesk-webhook-signature-timestamp") ?? "";
const expected = createHmac("sha256", secret)
.update(timestamp + body)
.digest("base64");
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 Zendesk webhook:", body);
return new Response("ok", { status: 200 });
}Zendesk gives a static secret for test requests you send before the webhook exists. Use it for now.
ZENDESK_WEBHOOK_SECRET=dGhpc19zZWNyZXRfaXNfZm9yX3Rlc3Rpbmdfb25seQ==Start 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 Zendesk endpoint 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 Zendesk
- In Admin Center, select Apps and integrations in the sidebar, then Webhooks, then Webhooks.
- Select Create webhook.
- To receive Zendesk events, select Zendesk events and pick one or more event types from the dropdown. Select Next.
- Enter a Name and a Description.
- Enter
https://my-app.hrzn.run/api/webhooks/zendeskas the Endpoint URL. - Select the Request method and Request format.
- Select the Authentication method. Zendesk lists none, API key, basic authentication and bearer token. The signature header works with any of them, so pick None unless you need more.
- Select Test webhook and follow the next step.
- Select Create webhook.
Send a test request
In the Test webhook panel, select an event to use as a sample request, then select Send test. Zendesk shows your endpoint's response. This request uses the static secret from .env.local, so the signature passes.
After you select Create webhook, Zendesk generates a signing secret for the webhook. Open the webhook in Admin Center, select Reveal secret, and copy it into .env.local. Restart npm run dev.
ZENDESK_WEBHOOK_SECRET=replace-with-the-revealed-secretCheck it works
Send the test again from the webhook in Admin Center, now with the real secret. Horizon prints one line for the request:
POST 200 /api/webhooks/zendeskYour app terminal prints Received Zendesk webhook: followed by the body. If the line shows [401], see Troubleshooting.
Troubleshooting
The signature doesn't match
- Check which secret the request used. Tests sent before you created the webhook use the static test secret. A created webhook uses its own secret.
- Check that
ZENDESK_WEBHOOK_SECREThas no extra spaces or newline, and restartnpm run devafter you edit.env.local. - Hash the raw body. Don't run
JSON.parseandJSON.stringifyfirst, because that can change the bytes.
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 Endpoint URL ends with
/api/webhooks/zendesk.
Next steps
- Read Zendesk's guide to verifying webhook authenticity.
Test Typeform webhooks locally
Receive Typeform webhook submissions on localhost with a Horizon tunnel, and verify the Typeform-Signature header.
Test Castle webhooks locally
Receive Castle webhook events on localhost with a Horizon tunnel, and verify the X-Castle-Signature header with the Castle Node SDK.