> ## Documentation Index
> Fetch the complete documentation index at: https://buttercms.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Securing webhooks

> Properly secure publicly accessible ButterCMS webhook endpoints to prevent unauthorized access and ensure the integrity of incoming data.

## 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:

```bash theme={null}
# 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:

```text theme={null}
Header Name: X-Webhook-Secret
Header Value: your-secret-key-here
```

<Info>
  Custom headers are configured in the ButterCMS webhook settings alongside the endpoint URL and event selection.
</Info>

### Step 3: Verify the secret in your endpoint

#### Node.js / Express

```javascript theme={null}
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

```python theme={null}
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 theme={null}
<?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.

<Warning>
  IP addresses may change over time. Contact ButterCMS support for the current list of IP ranges used for webhook delivery, and subscribe to updates.
</Warning>

### Nginx configuration example

```nginx theme={null}
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

```javascript theme={null}
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

```javascript theme={null}
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:

```javascript theme={null}
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

```javascript theme={null}
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

```javascript theme={null}
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:

```javascript theme={null}
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
});
```
