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
| Pattern | Description |
|---|
| Queue-based | Add to job queue, process later |
| Event-driven | Emit internal event, return immediately |
| Fire-and-forget | Start async operation, don’t await |
| Background workers | Separate 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
| Practice | Recommendation |
|---|
| Response time | Return 200 within 5 seconds, process async |
| Status codes | Use 2xx for success, 4xx/5xx appropriately |
| Idempotency | Always handle duplicate deliveries |
| Error handling | Catch exceptions, log details, degrade gracefully |
| Monitoring | Track success rates, latency, and failures |
| Timeouts | Set timeouts on all external calls |
| Queue processing | Use background workers for heavy operations |