Secure your webhooks

Listening to a webhook implies exposing a URL (the webhook endpoint) to the web. Because anyone can call the webhook endpoint, it is insecure. The solution is to request that Typeform signs each webhook payload with a secret. The resulting signature is included in the header of the request, which you can then use to verify that the webhook is from Typeform before continuing program execution.

This page shows you how to configure secrets in webhooks so that they get signed, and how to verify those signatures in your app to maintain the data integrity of your application. It can be done by verifying the signature of the payload which will be sent in the request header Typeform-Signature.

Set up your webhook secret

  1. Generate a random string (for example, via terminal: ruby -rsecurerandom -e 'puts SecureRandom.hex(20)' ).

  2. Update the webhook setting secret by sending an update request to the Webhooks API.

Validate payload from Typeform

To validate the signature you received from Typeform, you will generate the signature yourself using your secret and compare that signature with the signature you receive in the webhook payload.

  1. Using the HMAC SHA-256 algorithm, create a hash (using secret as a key) of the entire received payload as binary.
  2. Encode the binary hash in base64 format.
  3. Add prefix sha256= to the binary hash.
  4. Compare the created value with the signature you received in the Typeform-Signature header from Typeform.

Ruby

post '/webhook' do
  request.body.rewind
  payload_body = request.body.read
  verify_signature(request.env['HTTP_TYPEFORM_SIGNATURE'], payload_body)
  "Payload received: #{payload_body.inspect}"
end

def verify_signature(received_signature, payload_body)
  hash = OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'), ENV['SECRET_TOKEN'], payload_body)
  actual_signature = 'sha256=' + Base64.strict_encode64(hash)
  return halt 500, "Signatures don't match!" unless Rack::Utils.secure_compare(actual_signature, received_signature)
end

Node.js with Express

Get the whole example: source

const crypto = require('crypto')
app.use(express.raw({ type: 'application/json' }))
app.post('/webhook', async (request, response) => {
  const signature = request.headers['typeform-signature']
  const isValid = verifySignature(signature, request.body.toString())
})

const verifySignature = function (receivedSignature, payload) {
  const hash = crypto
    .createHmac('sha256', process.env.SECRET_TOKEN)
    .update(payload)
    .digest('base64')
  return receivedSignature === `sha256=${hash}`
}

Node.js with Fastify

Get the whole example: source

const crypto = require('crypto')

const fastify = require('fastify')()

// we need to use raw request body (as string)
await fastify.register(require('fastify-raw-body'))

fastify.post('/typeform/webhook', (request, reply) => {
  const signature = request.headers['typeform-signature']
  const isValid = verifySignature(signature, request.rawBody)
})

const verifySignature = function (receivedSignature, payload) {
  const hash = crypto
    .createHmac('sha256', process.env.SECRET_TOKEN)
    .update(payload)
    .digest('base64')
  return receivedSignature === `sha256=${hash}`
}

Python

Live example with FastAPI

from fastapi import FastAPI,Request,HTTPException

import hashlib
import hmac
import json
import base64
import os

app = FastAPI()

@app.post("/hook")
async def recWebHook(req: Request):
  body = await req.json()
  raw = await req.body()

  receivedSignature = req.headers.get("typeform-signature")

  if receivedSignature is None:
    return HTTPException(403, detail="Permission denied.")

  sha_name, signature = receivedSignature.split('=', 1)
  if sha_name != 'sha256':
    return HTTPException(501, detail="Operation not supported.")

  is_valid = verifySignature(signature, raw)

  if(is_valid != True):
    return HTTPException(403, detail="Invalid signature. Permission Denied.")


def verifySignature(receivedSignature: str, payload):
  WEBHOOK_SECRET = os.environ.get('TYPEFORM_SECRET_KEY')
  digest = hmac.new(WEBHOOK_SECRET.encode('utf-8'), payload, hashlib.sha256).digest()
  e = base64.b64encode(digest).decode()
  if(e == receivedSignature):
    return True
  return False

Swift

import CryptoKit
func verifySig(receivedSig: String, payload: Request.Body) -> Bool{
    let secretString = "abc123" // replace with your own
    let payloadString = payload.string ?? ""
    let key = SymmetricKey(data: Data(secretString.utf8))
    let regenSig = HMAC<SHA256>.authenticationCode(for: Data(payloadString.utf8), using: key)
    let sigData = Data(regenSig)
    let sigBase64 = sigData.base64EncodedString()
    let final = "sha256=\(sigBase64)"
    if(final == receivedSig){
        return true
    }
    return false
}

PHP

<?php

echo "php version: ".phpversion()."\n";

$headers = getallheaders();
$header_signature = $headers["Typeform-Signature"];

$secret = getenv("TYPEFORM_WEBHOOK_SECRET");
$payload = @file_get_contents("php://input");
$hashed_payload = hash_hmac("sha256", $payload, $secret, true);
$base64encoded = "sha256=".base64_encode($hashed_payload);

echo "header signature:  ".$header_signature."\n";
echo "request signature: ".$base64encoded."\n";

if ($header_signature === $base64encoded) {
	echo "success!\n";
}

NOTE: We do not currently have designated IPs for webhook requests. Typeform.com is hosted on Amazon Web Services (AWS) servers, which uses dynamic IP addresses, so we cannot guarantee a static IP address or even a range of IP addresses.


HTTPS requirement

All new webhook URLs must use https. If you try to create or update a webhook with an http URL, the API will return a 400 Bad Request with the error code webhook_url_https_required.

Your SSL/TLS certificate must be validated — self-signed certificates will not work.

Existing HTTP webhooks

If you created a webhook with an http URL before this requirement was introduced, it will continue to work. You can still update other fields on that webhook (like enabled, secret, or event_types) without changing the URL. However, you cannot change the URL to a different http address — only to an https one.

The verify_ssl field

The verify_ssl field is now read-only. You no longer need to set it — it's automatically derived from the URL scheme:

  • https URLs → verify_ssl is true
  • http URLs (legacy only) → verify_ssl is false

The field is still included in API responses for backward compatibility, but if you include it in a request, it will be ignored.

What's next?

Check out our example Webhook payload or head to the Webhooks reference for endpoint information.