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

  1. Go to your survey in the dashboard
  2. Click the Settings tab
  3. Enter your webhook URL (must be HTTPS)
  4. Optionally add a webhook secret for signature verification
  5. 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

  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