Authentication
Use custom authentication headers (e.g., X-API-Key, Authorization) to verify webhook source. Configure your endpoint to reject requests that don’t include the expected authentication header.
IP allowlisting
Restrict webhook endpoints to accept requests only from ButterCMS IP ranges. This provides an additional layer of security by ensuring only legitimate sources can reach your endpoint.
HTTPS only
Always use HTTPS endpoints to ensure encrypted data transmission. Never accept webhooks over plain HTTP in production environments.
Payload validation
Check authentication headers before processing webhook payload. Reject requests immediately if authentication fails.
Validate structure
Ensure payload matches expected webhook event schema. Malformed payloads may indicate tampering or misconfiguration.
Idempotency
Handle duplicate webhook deliveries gracefully using event IDs or timestamps. The same webhook may be delivered multiple times.
The most common approach to securing webhooks is using a shared secret in a custom header:
Step 1: Generate a secret key
Create a strong, random secret key to use for webhook authentication:
# Generate a random 32-character secret
openssl rand -hex 32
# Output: a1b2c3d4e5f6789012345678901234567890abcdef
When setting up your webhook in ButterCMS, add a custom header:
Header Name: X-Webhook-Secret
Header Value: your-secret-key-here
Custom headers are configured in the ButterCMS webhook settings alongside the endpoint URL and event selection.
Step 3: Verify the secret in your endpoint
Node.js / Express
const WEBHOOK_SECRET = process.env.BUTTERCMS_WEBHOOK_SECRET;
app.post('/webhooks/buttercms', (req, res) => {
// Verify the secret header
const receivedSecret = req.headers['x-webhook-secret'];
if (!receivedSecret || receivedSecret !== WEBHOOK_SECRET) {
console.warn('Webhook authentication failed');
return res.status(401).json({ error: 'Unauthorized' });
}
// Process the webhook
const { data, webhook } = req.body;
// ... handle the event
res.status(200).json({ received: true });
});
Python / Flask
import os
from flask import Flask, request, jsonify
app = Flask(__name__)
WEBHOOK_SECRET = os.environ.get('BUTTERCMS_WEBHOOK_SECRET')
@app.route('/webhooks/buttercms', methods=['POST'])
def handle_webhook():
# Verify the secret header
received_secret = request.headers.get('X-Webhook-Secret')
if not received_secret or received_secret != WEBHOOK_SECRET:
return jsonify({'error': 'Unauthorized'}), 401
# Process the webhook
payload = request.get_json()
# ... handle the event
return jsonify({'received': True}), 200
PHP / Laravel
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class WebhookController extends Controller
{
public function handleButterCMS(Request $request)
{
// Verify the secret header
$receivedSecret = $request->header('X-Webhook-Secret');
$expectedSecret = config('services.buttercms.webhook_secret');
if (!$receivedSecret || $receivedSecret !== $expectedSecret) {
return response()->json(['error' => 'Unauthorized'], 401);
}
// Process the webhook
$payload = $request->all();
// ... handle the event
return response()->json(['received' => true], 200);
}
}
IP allowlisting
For additional security, configure your firewall or web server to only accept webhook requests from ButterCMS IP addresses.
IP addresses may change over time. Contact ButterCMS support for the current list of IP ranges used for webhook delivery, and subscribe to updates.
Nginx configuration example
location /webhooks/buttercms {
# Only allow ButterCMS IP ranges
allow 52.0.0.0/8; # Example - get actual IPs from ButterCMS
deny all;
proxy_pass http://localhost:3000;
}
AWS Security Group
If hosting on AWS, create a security group rule that only allows inbound traffic on your webhook port from ButterCMS IP ranges.
Request validation
Beyond authentication, validate the incoming request to ensure it’s a legitimate webhook:
Validate content type
app.post('/webhooks/buttercms', (req, res) => {
// Ensure request is JSON
const contentType = req.headers['content-type'];
if (!contentType || !contentType.includes('application/json')) {
return res.status(400).json({ error: 'Invalid content type' });
}
// ... continue processing
});
Validate payload structure
function isValidWebhookPayload(payload) {
// Check required fields exist
if (!payload || typeof payload !== 'object') return false;
if (!payload.data || typeof payload.data !== 'object') return false;
if (!payload.webhook || typeof payload.webhook !== 'object') return false;
if (!payload.webhook.event || !payload.webhook.target) return false;
// Validate event type format
const validEvents = [
'page.all', 'page.published', 'page.draft', 'page.unpublished', 'page.delete',
'post.all', 'post.published', 'post.draft', 'post.delete',
'collectionitem.all', 'collectionitem.published', 'collectionitem.draft',
'collectionitem.unpublished', 'collectionitem.delete',
'media.videouploaded'
];
return validEvents.includes(payload.webhook.event);
}
app.post('/webhooks/buttercms', (req, res) => {
if (!isValidWebhookPayload(req.body)) {
return res.status(400).json({ error: 'Invalid payload structure' });
}
// ... continue processing
});
Validate timestamp freshness
Reject webhooks with timestamps that are too old to prevent replay attacks:
function isTimestampFresh(timestamp, maxAgeMinutes = 5) {
const webhookTime = new Date(timestamp);
const now = new Date();
const ageMinutes = (now - webhookTime) / (1000 * 60);
return ageMinutes <= maxAgeMinutes;
}
app.post('/webhooks/buttercms', (req, res) => {
const { data } = req.body;
// Check if the webhook is recent
if (data.timestamp && !isTimestampFresh(data.timestamp, 5)) {
return res.status(400).json({ error: 'Webhook too old' });
}
// ... continue processing
});
Handling duplicate webhooks
ButterCMS may deliver the same webhook multiple times (at-least-once delivery). Implement idempotency to handle duplicates gracefully:
Using database tracking
const processedWebhooks = new Map(); // Use Redis or database in production
async function handleWebhook(payload) {
const { data, webhook } = payload;
// Create unique identifier for this event
const webhookId = `${webhook.event}-${data.id}-${data.timestamp}`;
// Check if already processed
if (processedWebhooks.has(webhookId)) {
console.log('Duplicate webhook, skipping:', webhookId);
return { duplicate: true };
}
// Mark as processed
processedWebhooks.set(webhookId, Date.now());
// Process the webhook
// ...
return { processed: true };
}
Using Redis for distributed systems
const Redis = require('ioredis');
const redis = new Redis();
async function handleWebhook(payload) {
const { data, webhook } = payload;
const webhookId = `webhook:${webhook.event}:${data.id}:${data.timestamp}`;
// Try to set the key (only succeeds if not exists)
const isNew = await redis.set(webhookId, '1', 'NX', 'EX', 3600); // Expires in 1 hour
if (!isNew) {
console.log('Duplicate webhook detected');
return { duplicate: true };
}
// Process the webhook
// ...
return { processed: true };
}
Security checklist
Use this checklist to ensure your webhook endpoint is properly secured:
- HTTPS enabled - Endpoint uses TLS encryption
- Secret header configured - Custom authentication header is set and verified
- Content-Type validated - Only accept
application/json requests
- Payload structure validated - Check for required fields
- Event types validated - Only process known event types
- Timestamp checked - Reject stale webhooks
- Idempotency implemented - Handle duplicate deliveries
- Rate limiting configured - Protect against abuse
- Logging enabled - Track all webhook activity
- Errors handled gracefully - Don’t leak internal details
Rate limiting
Protect your endpoint from abuse by implementing rate limiting:
const rateLimit = require('express-rate-limit');
const webhookLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100, // 100 requests per minute
message: { error: 'Too many requests' },
standardHeaders: true,
legacyHeaders: false,
});
app.post('/webhooks/buttercms', webhookLimiter, (req, res) => {
// ... handle webhook
});