Skip to main content

Why webhook security matters

Webhooks expose your application to the public internet. Without proper security:
  • Attackers could forge events - Trigger unauthorized actions in your system
  • Data could be compromised - Sensitive customer information could be intercepted
  • Replay attacks - Old webhooks could be re-sent to cause duplicate actions
  • DDoS attacks - Your endpoint could be overwhelmed with fake requests
Pocketsflow provides robust signature verification to protect against these threats.

Webhook signing secrets

When you create a webhook in Pocketsflow, you receive a signing secret: Format: whsec_ followed by a random string Example: whsec_1a2b3c4d5e6f7g8h9i0j
Critical: Save this secret immediately. It’s only shown once during creation.

Storing secrets securely

✅ DO:
// Store in environment variables
const WEBHOOK_SECRET = process.env.POCKETSFLOW_WEBHOOK_SECRET;
❌ DON’T:
// Never hardcode secrets in your code
const WEBHOOK_SECRET = 'whsec_1a2b3c4d5e6f7g8h9i0j'; // WRONG!
Best practices:
  • Use environment variables or secret managers (AWS Secrets Manager, Google Secret Manager, etc.)
  • Never commit secrets to version control
  • Add *.env to your .gitignore
  • Use different secrets for staging and production
  • Rotate secrets if compromised

Signature verification

Every Pocketsflow webhook includes cryptographic signatures to verify authenticity.

How it works

1

Pocketsflow signs the webhook

Before sending, we create an HMAC SHA256 signature using:
  • Webhook payload (JSON body)
  • Your webhook secret
2

Signature sent in headers

The signature is included in the X-Pocketsflow-Signature header
3

Your server verifies

Recompute the signature using the payload and your secret, then compare
4

Accept or reject

If signatures match → process the webhook. If not → reject (401)

Webhook headers

Every webhook request includes these headers:
HeaderDescriptionExample
X-Pocketsflow-SignatureHMAC SHA256 signaturea1b2c3d4e5f6...
X-Pocketsflow-EventEvent typeorder.completed
X-Pocketsflow-TimestampUnix timestamp (ms)1703174400000
Content-TypeAlways JSONapplication/json

Implementation examples

Node.js / Express

const express = require('express');
const crypto = require('crypto');

const app = express();
const WEBHOOK_SECRET = process.env.POCKETSFLOW_WEBHOOK_SECRET;

// IMPORTANT: Use express.raw() to get the raw body
app.post('/webhooks/pocketsflow',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.headers['x-pocketsflow-signature'];
    const payload = req.body;

    // Verify signature
    const expectedSignature = crypto
      .createHmac('sha256', WEBHOOK_SECRET)
      .update(payload)
      .digest('hex');

    if (signature !== expectedSignature) {
      console.error('Invalid signature');
      return res.status(401).send('Unauthorized');
    }

    // Signature verified - safe to process
    const event = JSON.parse(payload);
    console.log('Verified event:', event.event);

    // Process the webhook...
    processWebhook(event).catch(console.error);

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

function processWebhook(event) {
  // Your business logic here
  switch (event.event) {
    case 'order.completed':
      return grantAccess(event.customer.email, event.product.id);
    case 'customer.subscription.deleted':
      return revokeAccess(event.customer.email);
  }
}

Python / Flask

import hmac
import hashlib
import os
from flask import Flask, request

app = Flask(__name__)
WEBHOOK_SECRET = os.environ['POCKETSFLOW_WEBHOOK_SECRET']

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

    # Verify signature
    expected_signature = hmac.new(
        WEBHOOK_SECRET.encode('utf-8'),
        payload,
        hashlib.sha256
    ).hexdigest()

    if signature != expected_signature:
        return 'Unauthorized', 401

    # Signature verified
    event = request.get_json()
    print(f'Verified event: {event["event"]}')

    # Process webhook...
    process_webhook(event)

    return 'OK', 200

def process_webhook(event):
    if event['event'] == 'order.completed':
        grant_access(event['customer']['email'], event['product']['id'])

PHP

<?php
$webhookSecret = getenv('POCKETSFLOW_WEBHOOK_SECRET');
$signature = $_SERVER['HTTP_X_POCKETSFLOW_SIGNATURE'];
$payload = file_get_contents('php://input');

// Verify signature
$expectedSignature = hash_hmac('sha256', $payload, $webhookSecret);

if ($signature !== $expectedSignature) {
    http_response_code(401);
    die('Unauthorized');
}

// Signature verified
$event = json_decode($payload, true);
error_log('Verified event: ' . $event['event']);

// Process webhook...
processWebhook($event);

http_response_code(200);
echo 'OK';

function processWebhook($event) {
    switch ($event['event']) {
        case 'order.completed':
            grantAccess($event['customer']['email'], $event['product']['id']);
            break;
    }
}
?>

Ruby / Sinatra

require 'sinatra'
require 'openssl'
require 'json'

WEBHOOK_SECRET = ENV['POCKETSFLOW_WEBHOOK_SECRET']

post '/webhooks/pocketsflow' do
  signature = request.env['HTTP_X_POCKETSFLOW_SIGNATURE']
  payload = request.body.read

  # Verify signature
  expected_signature = OpenSSL::HMAC.hexdigest(
    'SHA256',
    WEBHOOK_SECRET,
    payload
  )

  if signature != expected_signature
    halt 401, 'Unauthorized'
  end

  # Signature verified
  event = JSON.parse(payload)
  puts "Verified event: #{event['event']}"

  # Process webhook...
  process_webhook(event)

  status 200
  body 'OK'
end

def process_webhook(event)
  case event['event']
  when 'order.completed'
    grant_access(event['customer']['email'], event['product']['id'])
  end
end

Go

package main

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

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    webhookSecret := os.Getenv("POCKETSFLOW_WEBHOOK_SECRET")
    signature := r.Header.Get("X-Pocketsflow-Signature")

    payload, _ := ioutil.ReadAll(r.Body)

    // Verify signature
    mac := hmac.New(sha256.New, []byte(webhookSecret))
    mac.Write(payload)
    expectedSignature := hex.EncodeToString(mac.Sum(nil))

    if signature != expectedSignature {
        http.Error(w, "Unauthorized", http.StatusUnauthorized)
        return
    }

    // Signature verified
    var event map[string]interface{}
    json.Unmarshal(payload, &event)
    log.Printf("Verified event: %s", event["event"])

    // Process webhook...
    processWebhook(event)

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

func main() {
    http.HandleFunc("/webhooks/pocketsflow", webhookHandler)
    log.Fatal(http.ListenAndServe(":3000", nil))
}

Timestamp validation

For additional security, validate that webhooks aren’t too old:
const timestamp = parseInt(req.headers['x-pocketsflow-timestamp']);
const currentTime = Date.now();
const fiveMinutes = 5 * 60 * 1000;

if (Math.abs(currentTime - timestamp) > fiveMinutes) {
  return res.status(401).send('Webhook too old');
}
This prevents replay attacks where old webhooks are re-sent.

Common security mistakes

❌ Parsing before verifying

Wrong:
app.post('/webhooks', express.json(), (req, res) => {
  // Body already parsed - can't verify signature!
  const signature = req.headers['x-pocketsflow-signature'];
  // Too late - req.body is an object, not raw bytes
});
Correct:
app.post('/webhooks', express.raw({type: 'application/json'}), (req, res) => {
  // Verify FIRST with raw body
  verifySignature(req.body, signature);
  // THEN parse
  const event = JSON.parse(req.body);
});

❌ Using timing-unsafe comparison

Wrong:
if (signature === expectedSignature) { // Vulnerable to timing attacks
Better:
const crypto = require('crypto');
if (crypto.timingSafeEqual(
  Buffer.from(signature),
  Buffer.from(expectedSignature)
)) {
  // Safer comparison
}

❌ Not using HTTPS

Wrong:
http://yoursite.com/webhooks  // Unencrypted - can be intercepted
Correct:
https://yoursite.com/webhooks  // Encrypted with TLS
Pocketsflow only sends webhooks to HTTPS endpoints.

❌ Logging sensitive data

Wrong:
console.log('Webhook received:', JSON.stringify(event)); // Contains PII
Better:
console.log('Event received:', event.event, 'order:', event.order.id);
// Don't log customer emails, addresses, payment details

Testing signature verification

Manual test with curl

# Your webhook payload
PAYLOAD='{"event":"test.event","data":{}}'

# Generate signature
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "whsec_YOUR_SECRET" | cut -d' ' -f2)

# Send test webhook
curl -X POST https://yoursite.com/webhooks/pocketsflow \
  -H "Content-Type: application/json" \
  -H "X-Pocketsflow-Signature: $SIGNATURE" \
  -H "X-Pocketsflow-Event: test.event" \
  -H "X-Pocketsflow-Timestamp: $(date +%s)000" \
  -d "$PAYLOAD"

Unit test example (Jest)

const crypto = require('crypto');

describe('Webhook verification', () => {
  it('should accept valid signatures', async () => {
    const payload = JSON.stringify({ event: 'test' });
    const secret = 'whsec_test123';

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

    const response = await request(app)
      .post('/webhooks')
      .set('X-Pocketsflow-Signature', signature)
      .set('Content-Type', 'application/json')
      .send(payload);

    expect(response.status).toBe(200);
  });

  it('should reject invalid signatures', async () => {
    const payload = JSON.stringify({ event: 'test' });
    const badSignature = 'invalid_signature';

    const response = await request(app)
      .post('/webhooks')
      .set('X-Pocketsflow-Signature', badSignature)
      .send(payload);

    expect(response.status).toBe(401);
  });
});

IP whitelisting (optional)

For extra security, you can restrict webhook traffic to Pocketsflow’s IP addresses:
Contact support@pocketsflow.com to get the current list of webhook IP addresses for whitelisting.
Nginx example:
location /webhooks/pocketsflow {
    allow 1.2.3.4;  # Pocketsflow IP
    allow 5.6.7.8;  # Pocketsflow IP
    deny all;

    proxy_pass http://localhost:3000;
}

Secret rotation

Rotate your webhook secret if:
  • It’s been exposed (committed to git, logged, etc.)
  • A team member with access leaves
  • You suspect compromise
  • As part of regular security maintenance
How to rotate:
  1. Create new webhook with new secret in dashboard
  2. Update your code to accept both old and new secrets temporarily
  3. Monitor that new webhook is working
  4. Delete old webhook after confirming
  5. Remove old secret from your code
Dual-verification during rotation:
const OLD_SECRET = process.env.OLD_WEBHOOK_SECRET;
const NEW_SECRET = process.env.NEW_WEBHOOK_SECRET;

function verifySignature(payload, signature) {
  const oldValid = crypto
    .createHmac('sha256', OLD_SECRET)
    .update(payload)
    .digest('hex') === signature;

  const newValid = crypto
    .createHmac('sha256', NEW_SECRET)
    .update(payload)
    .digest('hex') === signature;

  return oldValid || newValid;
}

Security checklist

Before going live, ensure:
  • ✅ Webhook secret stored in environment variables (not code)
  • ✅ Signature verification implemented correctly
  • ✅ Using raw body for verification (not parsed JSON)
  • ✅ HTTPS endpoint with valid SSL certificate
  • ✅ Timestamp validation to prevent replay attacks
  • ✅ Rate limiting on webhook endpoint
  • ✅ Error handling doesn’t expose sensitive info
  • ✅ Logging excludes PII (customer emails, addresses)
  • ✅ Idempotency keys used to prevent duplicate processing
  • ✅ Webhook failures monitored and alerted

Troubleshooting

Signature verification always fails

Check:
  1. Are you using the raw request body (not parsed)?
  2. Is the secret correct (including any prefixes like whsec_)?
  3. Are you using the exact same payload bytes (no whitespace changes)?
  4. Is the HMAC algorithm SHA256 (not SHA1 or MD5)?
Debug:
console.log('Received signature:', signature);
console.log('Expected signature:', expectedSignature);
console.log('Payload length:', payload.length);
console.log('Secret length:', WEBHOOK_SECRET.length);

Getting replay attack warnings

If you’re validating timestamps and seeing failures:
  • Check server time is synchronized (NTP)
  • Verify timestamp is in milliseconds (not seconds)
  • Allow reasonable clock skew (5-10 minutes)

Webhook Overview

Learn how to set up webhooks

Webhook Events

See all available event types

Code Examples

More implementation examples

Consuming Webhooks

Best practices for processing