> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://typeform.ferndocs.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://typeform.ferndocs.com/_mcp/server.

# Secure Your Webhooks

> Learn how to implement webhook signature verification to ensure requests are genuinely from Typeform.

Webhook signatures allow you to verify that incoming webhooks are genuinely from Typeform and haven't been tampered with. This is a critical security measure for production webhooks.

## Why verify webhooks?

Without verification, anyone who knows your webhook URL could send fake requests to your endpoint. Signature verification ensures:

* Requests are from Typeform
* Payload hasn't been modified
* Protection against replay attacks
* Compliance with security best practices

> **Warning**
>
> Always verify webhook signatures in production environments. Never trust
> incoming webhook data without verification.

## How webhook signatures work

Typeform includes a signature in the webhook request headers:

1. Typeform generates a hash of the request body using HMAC-SHA256
2. The hash is created using a secret key (your form's webhook secret)
3. The signature is sent in the `Typeform-Signature` header
4. Your server recreates the hash and compares it to the received signature

## Getting your webhook secret

When you create or update a webhook, Typeform provides a secret key:

```bash
curl -X PUT \
  'https://api.typeform.com/forms/{form_id}/webhooks/{tag}' \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{
    "url": "https://your-server.com/webhooks/typeform",
    "enabled": true,
    "secret": "your_webhook_secret_key"
  }'
```

**Response:**

```json
{
  "id": "webhook_123",
  "form_id": "abc123",
  "tag": "production",
  "url": "https://your-server.com/webhooks/typeform",
  "enabled": true,
  "secret": "your_webhook_secret_key",
  "created_at": "2024-01-15T10:00:00Z"
}
```

> **Info**
>
> Store the secret securely in your environment variables. Never commit secrets
> to version control.

## Verifying signatures

### Node.js implementation

```javascript
const crypto = require('crypto');

function verifyWebhookSignature(request, secret) {
  // Get the signature from the header
  const signature = request.headers['typeform-signature'];

  if (!signature) {
    console.error('No Typeform-Signature header found');
    return false;
  }

  // Extract the signature value (format: sha256=SIGNATURE)
  const signatureMatch = signature.match(/sha256=([a-f0-9]+)/);
  if (!signatureMatch) {
    console.error('Invalid signature format');
    return false;
  }

  const receivedSignature = signatureMatch[1];

  // Get the raw request body as a string
  const payload =
    typeof request.body === 'string'
      ? request.body
      : JSON.stringify(request.body);

  // Compute the expected signature
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(payload, 'utf8')
    .digest('hex');

  // Compare signatures using timing-safe comparison
  return crypto.timingSafeEqual(
    Buffer.from(expectedSignature, 'hex'),
    Buffer.from(receivedSignature, 'hex')
  );
}

// Usage with Express
const express = require('express');
const app = express();

// Important: Use express.text() to get raw body for signature verification
app.use(express.text({ type: 'application/json' }));

app.post('/webhooks/typeform', (req, res) => {
  const secret = process.env.TYPEFORM_WEBHOOK_SECRET;

  // Verify the signature
  if (!verifyWebhookSignature(req, secret)) {
    console.error('Invalid webhook signature');
    return res.status(401).send('Unauthorized');
  }

  // Parse the JSON body
  const payload = JSON.parse(req.body);

  // Process the verified webhook
  processWebhook(payload);

  res.status(200).send('OK');
});
```

### Python/Flask implementation

```python
import hmac
import hashlib
from flask import Flask, request, jsonify

app = Flask(__name__)

def verify_webhook_signature(request, secret):
    """Verify the Typeform webhook signature"""

    # Get the signature from headers
    signature_header = request.headers.get('Typeform-Signature')

    if not signature_header:
        print('No Typeform-Signature header found')
        return False

    # Extract the signature value
    if not signature_header.startswith('sha256='):
        print('Invalid signature format')
        return False

    received_signature = signature_header[7:]  # Remove 'sha256=' prefix

    # Get the raw request body
    payload = request.get_data(as_text=True)

    # Compute the expected signature
    expected_signature = hmac.new(
        secret.encode('utf-8'),
        payload.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()

    # Compare signatures using timing-safe comparison
    return hmac.compare_digest(expected_signature, received_signature)

@app.route('/webhooks/typeform', methods=['POST'])
def typeform_webhook():
    import os
    secret = os.environ.get('TYPEFORM_WEBHOOK_SECRET')

    # Verify the signature
    if not verify_webhook_signature(request, secret):
        return jsonify({'error': 'Invalid signature'}), 401

    # Process the verified webhook
    payload = request.get_json()
    process_webhook(payload)

    return jsonify({'status': 'success'}), 200

if __name__ == '__main__':
    app.run()
```

### Ruby/Sinatra implementation

```ruby
require 'sinatra'
require 'json'
require 'openssl'

def verify_webhook_signature(request, secret)
  # Get the signature from headers
  signature_header = request.env['HTTP_TYPEFORM_SIGNATURE']

  return false unless signature_header
  return false unless signature_header.start_with?('sha256=')

  received_signature = signature_header[7..-1]

  # Get the raw request body
  request.body.rewind
  payload = request.body.read

  # Compute the expected signature
  expected_signature = OpenSSL::HMAC.hexdigest(
    'SHA256',
    secret,
    payload
  )

  # Compare signatures
  Rack::Utils.secure_compare(expected_signature, received_signature)
end

post '/webhooks/typeform' do
  secret = ENV['TYPEFORM_WEBHOOK_SECRET']

  # Verify the signature
  unless verify_webhook_signature(request, secret)
    halt 401, 'Unauthorized'
  end

  # Process the verified webhook
  payload = JSON.parse(request.body.read)
  process_webhook(payload)

  status 200
  body 'OK'
end
```

### PHP implementation

```php
<?php

function verifyWebhookSignature($request, $secret) {
    // Get the signature from headers
    $signatureHeader = $_SERVER['HTTP_TYPEFORM_SIGNATURE'] ?? '';

    if (empty($signatureHeader)) {
        error_log('No Typeform-Signature header found');
        return false;
    }

    // Extract the signature value
    if (strpos($signatureHeader, 'sha256=') !== 0) {
        error_log('Invalid signature format');
        return false;
    }

    $receivedSignature = substr($signatureHeader, 7);

    // Get the raw request body
    $payload = file_get_contents('php://input');

    // Compute the expected signature
    $expectedSignature = hash_hmac('sha256', $payload, $secret);

    // Compare signatures using timing-safe comparison
    return hash_equals($expectedSignature, $receivedSignature);
}

// Handle webhook
$secret = getenv('TYPEFORM_WEBHOOK_SECRET');

if (!verifyWebhookSignature($_SERVER, $secret)) {
    http_response_code(401);
    echo 'Unauthorized';
    exit;
}

// Process the verified webhook
$payload = json_decode(file_get_contents('php://input'), true);
processWebhook($payload);

http_response_code(200);
echo 'OK';
?>
```

## Important implementation notes

### 1. Use raw request body

You must verify the signature against the raw request body string, not a parsed JSON object:

```javascript
// ❌ Wrong - don't use parsed body
app.use(express.json());
app.post('/webhook', (req, res) => {
  const payload = JSON.stringify(req.body); // Won't match!
  // ...
});

// ✅ Correct - use raw body
app.use(express.text({ type: 'application/json' }));
app.post('/webhook', (req, res) => {
  const payload = req.body; // Raw string
  verifySignature(payload);
  const data = JSON.parse(payload); // Parse after verification
  // ...
});
```

### 2. Use timing-safe comparison

Always use timing-safe comparison functions to prevent timing attacks:

```javascript
// ❌ Wrong - vulnerable to timing attacks
if (expectedSignature === receivedSignature) {
  // ...
}

// ✅ Correct - timing-safe comparison
if (
  crypto.timingSafeEqual(
    Buffer.from(expectedSignature, 'hex'),
    Buffer.from(receivedSignature, 'hex')
  )
) {
  // ...
}
```

### 3. Handle missing signatures

Always check for the presence of the signature header:

```javascript
const signature = request.headers['typeform-signature'];
if (!signature) {
  console.error('Missing signature');
  return res.status(401).send('Unauthorized');
}
```

## Testing signature verification

### Create a test webhook

```javascript
const crypto = require('crypto');

function createTestWebhook(payload, secret) {
  const payloadString = JSON.stringify(payload);

  const signature = crypto
    .createHmac('sha256', secret)
    .update(payloadString, 'utf8')
    .digest('hex');

  return {
    headers: {
      'Typeform-Signature': `sha256=${signature}`,
      'Content-Type': 'application/json',
    },
    body: payloadString,
  };
}

// Test your endpoint
const testPayload = {
  event_id: 'test_123',
  event_type: 'form_response',
  form_response: {
    /* ... */
  },
};

const testWebhook = createTestWebhook(testPayload, 'your_secret');

fetch('http://localhost:3000/webhooks/typeform', {
  method: 'POST',
  headers: testWebhook.headers,
  body: testWebhook.body,
});
```

### Unit test example

```javascript
const assert = require('assert');

describe('Webhook signature verification', () => {
  const secret = 'test_secret';

  it('should accept valid signatures', () => {
    const payload = JSON.stringify({ test: 'data' });
    const signature = crypto
      .createHmac('sha256', secret)
      .update(payload)
      .digest('hex');

    const request = {
      headers: { 'typeform-signature': `sha256=${signature}` },
      body: payload,
    };

    assert.strictEqual(verifyWebhookSignature(request, secret), true);
  });

  it('should reject invalid signatures', () => {
    const request = {
      headers: { 'typeform-signature': 'sha256=invalid' },
      body: JSON.stringify({ test: 'data' }),
    };

    assert.strictEqual(verifyWebhookSignature(request, secret), false);
  });

  it('should reject missing signatures', () => {
    const request = {
      headers: {},
      body: JSON.stringify({ test: 'data' }),
    };

    assert.strictEqual(verifyWebhookSignature(request, secret), false);
  });
});
```

## Rotating secrets

Best practice is to rotate webhook secrets periodically:

```bash
# Update webhook with new secret
curl -X PUT \
  'https://api.typeform.com/forms/{form_id}/webhooks/{tag}' \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{
    "url": "https://your-server.com/webhooks/typeform",
    "enabled": true,
    "secret": "your_new_secret"
  }'
```

During rotation, implement a grace period:

```javascript
function verifyWebhookSignature(request, currentSecret, previousSecret = null) {
  // Try current secret
  if (verifySignature(request, currentSecret)) {
    return true;
  }

  // During rotation, also accept previous secret
  if (previousSecret && verifySignature(request, previousSecret)) {
    console.warn('Webhook verified with old secret - update in progress');
    return true;
  }

  return false;
}
```

## Security best practices

1. **Always verify signatures**: Never skip verification in production
2. **Use HTTPS**: Webhooks require HTTPS endpoints
3. **Store secrets securely**: Use environment variables or secret managers
4. **Rotate secrets regularly**: Change secrets periodically
5. **Log verification failures**: Monitor for potential attacks
6. **Rate limit your endpoint**: Prevent abuse
7. **Validate payload structure**: Check that the payload is well-formed
8. **Use timing-safe comparisons**: Prevent timing attacks

## Troubleshooting

### Signature verification always fails

1. **Check you're using the raw body**: Don't verify against parsed JSON
2. **Verify the secret**: Ensure you're using the correct secret
3. **Check character encoding**: Use UTF-8 encoding
4. **Inspect the header name**: Should be `Typeform-Signature` (case-sensitive)
5. **Check signature format**: Should be `sha256=<hex_string>`

### Debugging signatures

```javascript
function debugSignature(request, secret) {
  console.log('Headers:', request.headers);
  console.log('Body:', request.body);
  console.log('Secret:', secret ? '(present)' : '(missing)');

  const signature = request.headers['typeform-signature'];
  console.log('Received signature:', signature);

  const expected = crypto
    .createHmac('sha256', secret)
    .update(request.body, 'utf8')
    .digest('hex');
  console.log('Expected signature:', expected);

  console.log('Match:', signature === `sha256=${expected}`);
}
```

## Next steps

* Learn about [webhook implementation](/webhooks-api/walkthroughs)
* See [webhook payload examples](/webhooks-api/example-payload)
* Review the [API Reference](/api-reference) for webhook endpoints