Webhooks
Webhooks let you send survey responses to your own systems in real-time. When a visitor completes a survey, Popsee can instantly notify your server, CRM, Slack channel, or any other service that accepts HTTP requests.
Use webhooks to sync responses with your database, trigger follow-up emails, update customer records, or feed data into your analytics pipeline.
Webhooks are completely optional. All survey responses are stored in Popsee and viewable in your dashboard. You only need webhooks if you want to integrate responses with external systems.
Setting Up a Webhook
- Go to your survey in the dashboard
- Click the Settings tab
- Enter your webhook URL (must be HTTPS)
- Optionally add a webhook secret for signature verification
- Save your changes
Once configured, Popsee will POST a JSON payload to your URL each time a response is submitted.
Signature Verification
If you provide a webhook secret, Popsee signs all payloads with HMAC-SHA256 so you can verify they originated from Popsee and haven't been tampered with.
The signature is included in the X-Popsee-Signature header, 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