Horizon

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.

app/api/webhooks/zendesk/route.ts
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.

.env.local
ZENDESK_WEBHOOK_SECRET=dGhpc19zZWNyZXRfaXNfZm9yX3Rlc3Rpbmdfb25seQ==

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 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-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 Zendesk

  1. In Admin Center, select Apps and integrations in the sidebar, then Webhooks, then Webhooks.
  2. Select Create webhook.
  3. To receive Zendesk events, select Zendesk events and pick one or more event types from the dropdown. Select Next.
  4. Enter a Name and a Description.
  5. Enter https://my-app.hrzn.run/api/webhooks/zendesk as the Endpoint URL.
  6. Select the Request method and Request format.
  7. 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.
  8. Select Test webhook and follow the next step.
  9. 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.

.env.local
ZENDESK_WEBHOOK_SECRET=replace-with-the-revealed-secret

Check it works

Send the test again from the webhook in Admin Center, now with the real secret. Horizon prints one line for the request:

Output
  POST    200  /api/webhooks/zendesk

Your 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_SECRET has no extra spaces or newline, and restart npm run dev after you edit .env.local.
  • Hash the raw body. Don't run JSON.parse and JSON.stringify first, 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 for Reconnected.
  • Check that the Endpoint URL ends with /api/webhooks/zendesk.

Next steps

On this page