Skip to main content

Authentication

Custom headers

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

Verify headers

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.

Implementing custom header authentication

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

Step 2: Configure ButterCMS

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
});