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.


A quick note about https

We recommend using https for your webhook URL because it is more secure. We support either http or https, but we cannot guarantee security with http.

If you use https, your SSL/TLS certificate must be validated — self-signed certificates will not work. We may introduce an option to use self-signed certificates in the future, so if this is something you're interested in, please let us know.

What's next?

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