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
- Extract the signature from the
X-Popsee-Signatureheader - Remove the
sha256=prefix - Compute the HMAC-SHA256 of the raw request body using your webhook secret
- Compare your computed signature with the one from the header (use constant-time comparison)
- 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', 200PHP
<?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'
endGo
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