Webhook Signature Verification

Popsee signs all webhook payloads with HMAC-SHA256 so you can verify they originated from Popsee and haven't been tampered with.

How It Works

When you configure a webhook secret in your survey settings, Popsee includes a signature in the X-Popsee-Signature header of every webhook request. The signature is computed using HMAC-SHA256 with your secret as the key and the raw JSON payload as the message.

The header value is prefixed with sha256= followed by the hexadecimal signature.

Verification Steps

  1. Extract the signature from the X-Popsee-Signature header
  2. Remove the sha256= prefix
  3. Compute the HMAC-SHA256 of the raw request body using your webhook secret
  4. Compare your computed signature with the one from the header (use constant-time comparison)
  5. If they match, the webhook is authentic

Code Examples

Node.js

const crypto = require('crypto');

function verifyWebhook(payload, signature, secret) {
  const expectedSignature = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(payload, 'utf8')
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}

// Express.js example
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-popsee-signature'];
  const payload = req.body.toString();

  if (!verifyWebhook(payload, signature, process.env.WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }

  const data = JSON.parse(payload);
  console.log('Received webhook:', data.event);

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

Python

import hmac
import hashlib

def verify_webhook(payload: bytes, signature: str, secret: str) -> bool:
    expected = 'sha256=' + hmac.new(
        secret.encode('utf-8'),
        payload,
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(signature, expected)

# Flask example
from flask import Flask, request

app = Flask(__name__)

@app.route('/webhook', methods=['POST'])
def webhook():
    signature = request.headers.get('X-Popsee-Signature', '')
    payload = request.get_data()

    if not verify_webhook(payload, signature, os.environ['WEBHOOK_SECRET']):
        return 'Invalid signature', 401

    data = request.get_json()
    print(f"Received webhook: {data['event']}")

    return 'OK', 200

PHP

<?php
function verifyWebhook($payload, $signature, $secret) {
    $expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);
    return hash_equals($expected, $signature);
}

// Usage
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_POPSEE_SIGNATURE'] ?? '';
$secret = getenv('WEBHOOK_SECRET');

if (!verifyWebhook($payload, $signature, $secret)) {
    http_response_code(401);
    exit('Invalid signature');
}

$data = json_decode($payload, true);
error_log('Received webhook: ' . $data['event']);

http_response_code(200);
echo 'OK';

Ruby

require 'openssl'

def verify_webhook(payload, signature, secret)
  expected = 'sha256=' + OpenSSL::HMAC.hexdigest('SHA256', secret, payload)
  Rack::Utils.secure_compare(expected, signature)
end

# Sinatra example
post '/webhook' do
  payload = request.body.read
  signature = request.env['HTTP_X_POPSEE_SIGNATURE'] || ''

  unless verify_webhook(payload, signature, ENV['WEBHOOK_SECRET'])
    halt 401, 'Invalid signature'
  end

  data = JSON.parse(payload)
  puts "Received webhook: #{data['event']}"

  'OK'
end

Go

package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
    "os"
)

func verifyWebhook(payload []byte, signature, secret string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(payload)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(signature))
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    payload, _ := io.ReadAll(r.Body)
    signature := r.Header.Get("X-Popsee-Signature")

    if !verifyWebhook(payload, signature, os.Getenv("WEBHOOK_SECRET")) {
        http.Error(w, "Invalid signature", http.StatusUnauthorized)
        return
    }

    // Process webhook...
    w.WriteHeader(http.StatusOK)
    w.Write([]byte("OK"))
}

Webhook Payload

Here's an example of a webhook payload:

{
  "event": "response.completed",
  "timestamp": "2024-01-22T12:00:00.000Z",
  "origin": "web",
  "survey": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "name": "Customer Satisfaction Survey"
  },
  "response": {
    "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
    "answers": [
      { "questionId": "q1", "value": 9 },
      { "questionId": "q2", "value": "Great service!" }
    ],
    "customParams": {
      "userId": "user_12345",
      "email": "customer@example.com",
      "plan": "pro"
    },
    "completedAt": "2024-01-22T12:00:00.000Z",
    "pageUrl": "https://yoursite.com/checkout"
  }
}

Security Best Practices

  • Always verify the signature before processing webhook data
  • Use constant-time comparison to prevent timing attacks
  • Store your webhook secret securely (environment variable, secrets manager)
  • Use HTTPS for your webhook endpoint
  • Respond quickly (within 5 seconds) to avoid timeouts
  • Return a 2xx status code to acknowledge receipt