Skip to main content

Delivery guarantees

  • Best-effort delivery: ButterCMS attempts to deliver each webhook once; failed deliveries are logged but not automatically retried
  • Timeout: Webhook endpoints must respond within 5 seconds
  • Status codes: Return 2xx status codes to acknowledge successful processing
ButterCMS does not automatically retry failed webhook deliveries. If your integration requires guaranteed delivery, implement your own retry logic or use a webhook proxy service.

What counts as successful delivery?

A webhook is considered successfully delivered when:
  • Connection is established to your endpoint
  • Response is received within 5 seconds
  • Status code is in the 2xx range (200-299)
✓ 200 OK
✓ 201 Created
✓ 202 Accepted
✓ 204 No Content
✗ 400 Bad Request
✗ 401 Unauthorized
✗ 500 Internal Server Error
✗ 503 Service Unavailable

What causes delivery failure?

A webhook delivery fails if:
  • Connection failed - Unable to establish TCP connection
  • Timeout - No response received within 5 seconds
  • 4xx/5xx response - Server returned an error status code
  • Invalid SSL certificate - HTTPS certificate validation failed
Failed webhook deliveries are logged on ButterCMS’s side but are not automatically retried. Design your webhook handler to be resilient and consider implementing your own retry mechanism for critical integrations.

Timeout handling

Your webhook endpoint must respond within 5 seconds or ButterCMS will consider the delivery failed.

Why timeouts happen

  • Synchronous processing of long-running tasks
  • Database queries taking too long
  • Calling external APIs without timeout limits
  • Resource contention under high load

Avoiding timeouts

Use async processing - Acknowledge the webhook immediately, then process asynchronously:
const Queue = require('bull');
const webhookQueue = new Queue('webhooks');

app.post('/webhooks/buttercms', async (req, res) => {
  // Immediately acknowledge receipt
  res.status(200).json({ received: true });

  // Queue the actual processing for later
  await webhookQueue.add('process-webhook', {
    payload: req.body,
    receivedAt: Date.now()
  });
});

// Process webhooks in the background
webhookQueue.process('process-webhook', async (job) => {
  const { payload } = job.data;

  // Do the actual work here - no timeout concerns
  await invalidateCache(payload.data.id);
  await updateSearchIndex(payload.data);
  await sendNotifications(payload);
});

Timeout-safe patterns

PatternDescription
Queue-basedAdd to job queue, process later
Event-drivenEmit internal event, return immediately
Fire-and-forgetStart async operation, don’t await
Background workersSeparate process handles heavy lifting

Proper error responses

Return appropriate status codes so ButterCMS knows how to handle failures:

Return 2xx for success

Always return a 2xx status code when you’ve successfully received and queued the webhook:
// Good - acknowledges receipt
res.status(200).json({ received: true });
res.status(202).json({ queued: true });
res.status(204).send(); // No content needed

Return 4xx for client errors

Return 4xx for permanent client-side errors:
// Invalid authentication
if (!isValidSecret(req.headers['x-webhook-secret'])) {
  return res.status(401).json({ error: 'Unauthorized' });
}

// Invalid payload
if (!isValidPayload(req.body)) {
  return res.status(400).json({ error: 'Invalid payload' });
}

Return 5xx for server errors

Return 5xx for server-side errors:
// Temporary failure
if (!databaseConnection) {
  return res.status(503).json({ error: 'Service temporarily unavailable' });
}

// Unexpected error
catch (error) {
  console.error('Webhook processing error:', error);
  return res.status(500).json({ error: 'Internal server error' });
}

Response code decision guide

Handling duplicate deliveries

While ButterCMS uses best-effort delivery, your endpoint may occasionally receive duplicate webhooks due to network conditions. Implementing idempotency is a best practice for any webhook handler:

Why duplicates may occur

  • Network issues during response transmission
  • Your server processed successfully but response was lost
  • Infrastructure failover scenarios

Idempotency strategies

1. Track by Unique Identifier Create a unique ID from the webhook data:
function getWebhookId(payload) {
  const { data, webhook } = payload;
  // Combine event + content ID + timestamp for uniqueness
  return `${webhook.event}:${data.id}:${data.timestamp}`;
}
2. Check Before Processing
const processed = new Set(); // Use Redis in production

async function handleWebhook(payload) {
  const id = getWebhookId(payload);

  if (processed.has(id)) {
    console.log(`Duplicate webhook ignored: ${id}`);
    return { status: 'duplicate' };
  }

  processed.add(id);

  try {
    await processWebhook(payload);
    return { status: 'processed' };
  } catch (error) {
    processed.delete(id); // Allow retry on error
    throw error;
  }
}
3. Make operations idempotent Design your processing logic to produce the same result when run multiple times:
// Idempotent: Set value (same result if run twice)
await cache.set(`page:${data.id}`, null);

// NOT idempotent: Increment (different result each time)
await analytics.incrementPageViews(data.id);  // Don't do this!

// Fix: Use set with unique event ID
await analytics.recordEvent(`pageview:${data.id}:${data.timestamp}`);

Graceful degradation

Build resilient webhook handlers that degrade gracefully when dependencies fail:

Circuit breaker pattern

const CircuitBreaker = require('opossum');

const cacheInvalidation = new CircuitBreaker(invalidateCache, {
  timeout: 5000,
  errorThresholdPercentage: 50,
  resetTimeout: 30000
});

app.post('/webhooks/buttercms', async (req, res) => {
  try {
    // Primary action - cache invalidation
    await cacheInvalidation.fire(req.body.data.id);
  } catch (error) {
    // Circuit open or failed - log but don't fail the webhook
    console.error('Cache invalidation failed, will retry manually:', error);
  }

  // Always acknowledge receipt
  res.status(200).json({ received: true });
});

Fallback processing

async function processWebhook(payload) {
  try {
    // Try primary processing
    await primaryHandler(payload);
  } catch (primaryError) {
    console.warn('Primary handler failed:', primaryError);

    try {
      // Try fallback
      await fallbackHandler(payload);
    } catch (fallbackError) {
      // Queue for manual review
      await deadLetterQueue.add(payload);
      console.error('Webhook queued for manual processing');
    }
  }
}

Monitoring failed deliveries

Track webhook failures to identify issues early:

Logging best practices

app.post('/webhooks/buttercms', async (req, res) => {
  const startTime = Date.now();
  const { data, webhook } = req.body;

  console.log('Webhook received', {
    event: webhook.event,
    contentId: data.id,
    timestamp: data.timestamp
  });

  try {
    await processWebhook(req.body);

    console.log('Webhook processed', {
      event: webhook.event,
      contentId: data.id,
      duration: Date.now() - startTime
    });

    res.status(200).json({ received: true });
  } catch (error) {
    console.error('Webhook failed', {
      event: webhook.event,
      contentId: data.id,
      error: error.message,
      duration: Date.now() - startTime
    });

    res.status(500).json({ error: 'Processing failed' });
  }
});

Alerting on failures

Set up alerts for:
  • Webhook endpoint downtime
  • High error rates (>5% failures)
  • Processing latency spikes
  • Circuit breaker trips

Best practices summary

PracticeRecommendation
Response timeReturn 200 within 5 seconds, process async
Status codesUse 2xx for success, 4xx/5xx appropriately
IdempotencyAlways handle duplicate deliveries
Error handlingCatch exceptions, log details, degrade gracefully
MonitoringTrack success rates, latency, and failures
TimeoutsSet timeouts on all external calls
Queue processingUse background workers for heavy operations