Horizon

Test Typeform webhooks locally

Receive Typeform webhook submissions on localhost with a Horizon tunnel, and verify the Typeform-Signature header.

Receive Typeform submissions on your laptop while you build, with a URL Typeform can reach.

Before you begin

Start your app

Typeform signs the raw payload. It computes an HMAC SHA256 with your secret as the key, encodes the binary hash in base64, and sends sha256=<base64 hash> in the Typeform-Signature header. Read the body with request.text() before you parse it.

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

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

  const body = await request.text();
  const received = request.headers.get("typeform-signature") ?? "";
  const expected = `sha256=${createHmac("sha256", secret).update(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 });
  }

  const event = JSON.parse(body);
  console.log(`Received Typeform event: ${event.event_type}`);

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

Pick a random secret and store it in an environment variable.

.env.local
TYPEFORM_WEBHOOK_SECRET=replace-with-a-long-random-string

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 webhook 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-typeform

Horizon prints HORIZON: Tunnel connected. Your public URL is https://my-typeform.hrzn.run. Keep this terminal open.

Register the webhook with the Webhooks API

Create or update the webhook with PUT /forms/{form_id}/webhooks/{tag}. The tag is a name you choose. Set url, enabled, and the secret that signs payloads. Typeform only accepts https URLs for new webhooks.

curl -X PUT "https://api.typeform.com/forms/YOUR_FORM_ID/webhooks/horizon" \
  -H "Authorization: Bearer $TYPEFORM_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://my-typeform.hrzn.run/api/webhooks/typeform",
    "enabled": true,
    "secret": "replace-with-a-long-random-string"
  }'

Use the same value for secret as for TYPEFORM_WEBHOOK_SECRET.

Trigger a submission

Open your typeform and submit an answer. Typeform sends a form_response event for each submission.

Check it works

Submit your typeform. The Horizon terminal prints one line:

Output
  POST    200  /api/webhooks/typeform

Your app terminal prints:

Output
Received Typeform event: form_response

Troubleshooting

The signature doesn't match

  • Check that TYPEFORM_WEBHOOK_SECRET is identical to the secret you sent to the API.
  • Compute the HMAC over the raw body. Don't run JSON.parse and JSON.stringify first.
  • Encode the digest as base64, not hex, and add the sha256= prefix.
  • Restart npm run dev after you edit .env.local.

The webhook has no signature header

You didn't set secret on the webhook. Run the PUT request again with a secret.

Typeform disabled the webhook

Typeform disables a webhook that keeps failing. It does so after 100 or more failed attempts within 5 minutes, or 300 or more within 24 hours. Fix the handler, then send the PUT request again with "enabled": true. Typeform doesn't retry on 404 or 410, and disables the webhook straight away.

The URL changed after a restart

You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-typeform and run the PUT request again with the new URL. -s needs a subdomain you reserved, see Pricing.

Next steps

On this page