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
- 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 typeform and a Typeform personal access token
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.
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.
TYPEFORM_WEBHOOK_SECRET=replace-with-a-long-random-stringStart 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 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-typeformHorizon 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:
POST 200 /api/webhooks/typeformYour app terminal prints:
Received Typeform event: form_responseTroubleshooting
The signature doesn't match
- Check that
TYPEFORM_WEBHOOK_SECRETis identical to thesecretyou sent to the API. - Compute the HMAC over the raw body. Don't run
JSON.parseandJSON.stringifyfirst. - Encode the digest as base64, not hex, and add the
sha256=prefix. - Restart
npm run devafter 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
- Read Typeform's guide to securing your webhooks.
- See the example payload.