Webhooks - design, verification, and reliability
Webhooks: design, verification, and reliability
TL;DR:
- Always verify webhook signatures against Contentstack's public key — an unverified endpoint is an open door.
- Respond to Contentstack with 200 immediately and process asynchronously to avoid timeouts and duplicate deliveries.
- Implement idempotent processing because webhooks are delivered “at least once,” not “exactly once.”
Webhooks are Contentstack's mechanism for pushing event notifications to your systems in real time. Unlike the Delivery API or Management API, where your code initiates a request, webhooks reverse the direction: Contentstack calls your endpoint when something happens. An entry is published, a workflow stage changes, an asset is deleted - Contentstack sends an HTTP POST to a URL you specify, carrying a JSON payload that describes the event. This push-based architecture is the foundation for most server-side integrations: search indexing, cache invalidation, notification systems, data synchronization, and deployment triggers.
Getting webhooks right requires more than registering a URL. You need to verify that incoming requests are genuinely from Contentstack, handle duplicate deliveries gracefully, respond quickly to avoid timeouts, and monitor delivery health over time. This lesson covers all of these concerns.
When to use webhooks
Webhooks are appropriate when your system needs to react to content events without polling the Contentstack API. Common use cases include:
- Search index updates. When a product is published or unpublished, update the corresponding record in Algolia, Elasticsearch, or another search service.
- Cache invalidation. When content changes, purge the relevant CDN or application cache so visitors see updated content.
- Notification systems. When an entry reaches a specific workflow stage, notify the assigned reviewer via email, Slack, or another channel.
- Data synchronization. When a product entry is updated in Contentstack, sync the changes to an e-commerce platform or ERP system.
- Build triggers. When content is published, trigger a static site rebuild on Vercel, Netlify, or another hosting platform.
- Audit logging. Capture content events in an external audit log for compliance or analytics purposes.
Before implementing a webhook, check whether Automation Hub (covered in Course 5, Module 5.1, Lesson 3) handles the integration. As discussed in Lesson 1 of this module, Automation Hub's no-code connectors are preferable when they cover the use case because they eliminate the hosting, monitoring, and maintenance burden of a custom webhook handler.
Creating a webhook
Webhooks are configured in the Contentstack UI under Settings > Webhooks. Click + New Webhook and configure:
Basic settings
- Name: a descriptive name (e.g., “Algolia Index Update on Product Publish”). Clear naming prevents webhook sprawl, which is covered in Module 6.2.
- URL: the HTTPS endpoint that will receive the webhook payload. This must be a publicly accessible URL that your handler application exposes.
- Custom headers: additional HTTP headers sent with every webhook request. Commonly used for authentication tokens that your handler verifies.
Event selection
Select which content events trigger this webhook. Contentstack organizes events by resource type:
- Entries: events such as content_types.entries.create, content_types.entries.update, content_types.entries.publish, content_types.entries.unpublish, and workflow events.
- Assets: upload/update/delete/publish/unpublish events.
- Content types: create/update/delete events.
- Releases: create/deploy events.
You can scope a webhook to specific content types. For example, a search indexing webhook might listen only to publish and unpublish events on the Product and Product Line content types, ignoring events on internal content types like Navigation or Site Config.
Retry policy
Contentstack retries failed webhook deliveries. Configure the retry count (how many times to retry after a failure) and the retry delay (how long to wait between retries). A typical configuration is 3-5 retries with a 60-second delay.
A “failure” means your endpoint returned a non-2xx HTTP status code or did not respond within the timeout window. Contentstack considers a 200, 201, or 204 response as successful delivery.
Webhook payload structure
Every webhook request is an HTTP POST with a JSON body. The payload contains consistent metadata plus event-specific fields.
{
"event": "content_types.entries.publish",
"triggered_at": "2025-04-02T14:22:00.000Z",
"triggered_by": "bltuser1234567890",
"event_data": {
"entry": {
"uid": "blt_matrix_link_001",
"title": "Matrix Link Bracelet",
"url": "/products/digital-dawn/matrix-link-bracelet",
"short_description": "A sleek link bracelet composed of interlocking square links...",
"description": "Crafted in sterling silver with geometric detailing.",
"product_line": [
{
"uid": "blt_digital_dawn_001",
"_content_type_uid": "product_line"
}
],
"locale": "en-us",
"created_at": "2025-03-15T10:30:00.000Z",
"updated_at": "2025-04-02T14:22:00.000Z"
},
"content_type": {
"uid": "product",
"title": "Product"
},
"environment": {
"uid": "bltenv1234567890ab",
"name": "production"
},
"locale": "en-us"
}
}Key fields in every payload:
- event: fully qualified event identifier (for example, content_types.entries.publish).
- triggered_at: timestamp when the event was fired.
- triggered_by: user UID of the actor who triggered the event.
- event_data.entry or event_data.asset: resource snapshot at trigger time.
- event_data.content_type: content type metadata when applicable.
- event_data.environment and event_data.locale: publish context for publish/unpublish-style events.
Security: verifying webhook authenticity
Common pitfall:
Without signature verification, your webhook endpoint accepts requests from any source. An attacker who discovers the URL could trigger index deletions, cache purges, or data corruption in your downstream systems.
Any publicly accessible URL can receive HTTP requests from any source. Without verification, you cannot be sure that an incoming webhook request actually came from Contentstack rather than a malicious actor.
Contentstack signs webhook requests and includes signature metadata headers. Verification is done with Contentstack's webhook public key endpoint.
How signing works
- Contentstack signs the webhook request payload.
- It sends signature metadata in request headers, including X-Contentstack-Request-Signature, X-Contentstack-Request-Timestamp, and X-Contentstack-Request-Version.
- Your handler fetches the Contentstack webhook public key from the public key endpoint and verifies the signature against the raw request body and signature headers.
- If verification fails, reject the request.
Implementing verification
import express from "express";
const app = express();
// IMPORTANT: keep the raw bytes for signature verification
app.use("/webhooks", express.raw({ type: "application/json" }));
async function verifyContentstackRequest(req) {
const signature = req.headers["x-contentstack-request-signature"];
const timestamp = req.headers["x-contentstack-request-timestamp"];
const version = req.headers["x-contentstack-request-version"];
if (!signature || !timestamp || !version) {
return false;
}
// Example endpoint format:
// https:///apps-api/v1/webhooks/projects/:project_id/publicKey
const publicKey = await getWebhookPublicKey(
process.env.NEXT_PUBLIC_CONTENTSTACK_WEBHOOK_PUBLIC_KEY_URL
);
// Implement this helper per Contentstack secure webhook docs.
return verifyContentstackSignature({
rawBody: req.body,
signature: String(signature),
timestamp: String(timestamp),
version: String(version),
publicKey,
});
}
app.post("/webhooks/content-update", async (req, res) => {
if (!(await verifyContentstackRequest(req))) {
return res.status(401).json({ error: "Invalid signature" });
}
// Signature verified - process the webhook
const payload = JSON.parse(req.body.toString());
processWebhook(payload);
// Respond immediately with 200
res.status(200).json({ received: true });
}); Critical implementation details:
- Use the raw request body for verification. If your framework parses JSON before verification, payload bytes can change and signature validation will fail.
- Validate timestamp freshness. Reject requests outside an acceptable skew window to reduce replay risk.
- Cache the public key and refresh on rotation. Avoid fetching the key for every request, but handle key rotation gracefully.
Reliability patterns
Webhooks operate over HTTP, which means they are subject to network failures, endpoint outages, and processing errors. Building reliable webhook handlers requires several patterns.
Idempotent processing
Contentstack may deliver the same webhook more than once - if your handler responds slowly, if a retry is triggered, or if a network issue causes an ambiguous delivery status. Your handler must be idempotent: processing the same event twice should produce the same result as processing it once.
The simplest idempotency strategy uses the event's unique identifier (entry UID + event type + timestamp) as a deduplication key:
const processedEvents = new Map(); // In production, use Redis or a database
async function processWebhook(payload) {
// Create a deduplication key from event metadata
const deduplicationKey = `${payload.event_data.entry.uid}-${payload.event}-${payload.event_data.entry.updated_at}`;
// Check if we have already processed this event
if (processedEvents.has(deduplicationKey)) {
console.log(`Skipping duplicate event: ${deduplicationKey}`);
return;
}
// Mark as processing before doing work
processedEvents.set(deduplicationKey, { status: "processing", timestamp: Date.now() });
try {
// Perform the actual work (e.g., update search index)
await updateAlgoliaIndex(payload);
processedEvents.set(deduplicationKey, { status: "completed", timestamp: Date.now() });
} catch (error) {
processedEvents.set(deduplicationKey, { status: "failed", timestamp: Date.now() });
throw error;
}
}In production, creplace the in-memory Map with a persistent store like Redis or a database table. The deduplication key must be durable - if your handler restarts, it should still know which events have been processed.
Respond first, process later
Webhook handlers must respond to Contentstack quickly. If your handler takes too long to respond, Contentstack considers the delivery failed and triggers a retry - which means your handler might receive the same event again while still processing the first delivery.
The solution is to separate acknowledgment from processing:
app.post("/webhooks/content-update", (req, res) => {
// Verify signature (fast)
if (!verifySignature(req)) {
return res.status(401).json({ error: "Invalid signature" });
}
const payload = JSON.parse(req.body.toString());
// Respond immediately - tell Contentstack we received the webhook
res.status(200).json({ received: true });
// Process asynchronously (this runs after the response is sent)
processWebhookAsync(payload).catch((error) => {
console.error("Webhook processing failed:", error);
// Alert monitoring system
});
});For more robust asynchronous processing, enqueue the webhook payload into a message queue (AWS SQS, Google Cloud Pub/Sub, RabbitMQ) and process it from a worker:
import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs";
const sqsClient = new SQSClient({ region: "us-east-1" });
app.post("/webhooks/content-update", async (req, res) => {
if (!verifySignature(req)) {
return res.status(401).json({ error: "Invalid signature" });
}
// Enqueue for async processing
await sqsClient.send(
new SendMessageCommand({
QueueUrl: process.env.WEBHOOK_QUEUE_URL,
MessageBody: req.body.toString(),
})
);
res.status(200).json({ queued: true });
});This pattern guarantees fast response times and durability - even if your processing worker is temporarily down, the event is preserved in the queue.
Webhook channels and filtering
As your Contentstack project grows, you may have multiple external systems that need to react to different events. Rather than routing all events to a single handler that switches on event type, create separate webhooks for separate concerns:
| Webhook name | Events | Content types | Target |
|---|---|---|---|
| Algolia Product Index | content_types.entries.publish, content_types.entries.unpublish | Product | https://api.example.com/webhooks/algolia |
| Vercel Build Trigger | content_types.entries.publish | Page, Product | https://api.vercel.com/v1/integrations/deploy/{hook-id} |
| Slack Notifications | content_types.entries.workflows.update | All | https://api.example.com/webhooks/slack |
| CDN Cache Purge | content_types.entries.publish | All | https://api.example.com/webhooks/cache-purge |
This approach keeps each handler focused on a single responsibility, makes debugging easier (you can see in the webhook logs which specific webhook fired), and allows independent retry policies.
Monitoring webhook health
Contentstack provides webhook logs under Settings > Webhooks > [Webhook Name] > Logs. Each log entry shows:
- The timestamp of the delivery attempt.
- The HTTP status code returned by your endpoint.
- The response body (truncated).
- Whether the delivery succeeded or failed.
- Retry attempts and their outcomes.
Review these logs regularly. Patterns to watch for:
- Consistent 5xx errors: your handler is failing. Check application logs and error monitoring.
- Timeout errors: your handler is taking too long to respond. Implement the “respond first, process later” pattern.
- 4xx errors: your handler is rejecting requests. Check signature verification, URL correctness, and header validation.
- Successful delivery but no effect: the handler responds 200 but does not actually process the event. Check business logic and idempotency state.
Worked example: Algolia search index update
Putting all the patterns together, a production-ready webhook handler updates an Algolia search index when products are published or unpublished.
import express from "express";
import algoliasearch from "algoliasearch";
import { verifyRequestSignature } from "./contentstack-signature.js";
const app = express();
const algoliaClient = algoliasearch(
process.env.ALGOLIA_APP_ID,
process.env.ALGOLIA_ADMIN_API_KEY
);
const productsIndex = algoliaClient.initIndex("products");
// Store processed events (use Redis in production)
const processedEvents = new Map();
app.use("/webhooks", express.raw({ type: "application/json" }));
app.post("/webhooks/algolia-product-index", async (req, res) => {
// Step 1: Verify signature
if (!(await verifyRequestSignature(req))) {
return res.status(401).json({ error: "Invalid signature" });
}
const payload = JSON.parse(req.body.toString());
// Step 2: Respond immediately
res.status(200).json({ received: true });
const entry = payload.event_data?.entry;
if (!entry) return;
// Step 3: Check idempotency
const eventKey = `${entry.uid}-${payload.event}-${entry.updated_at ?? payload.triggered_at}`;
if (processedEvents.has(eventKey)) {
console.log(`Duplicate event skipped: ${eventKey}`);
return;
}
processedEvents.set(eventKey, Date.now());
// Step 4: Process based on event type
try {
if (payload.event === "content_types.entries.publish") {
await productsIndex.saveObject({
objectID: entry.uid,
title: entry.title,
url: entry.url,
short_description: entry.short_description,
description: entry.description,
locale: payload.event_data?.locale,
published_at: entry.updated_at,
});
console.log(`Indexed product: ${entry.uid}`);
}
if (payload.event === "content_types.entries.unpublish") {
await productsIndex.deleteObject(entry.uid);
console.log(`Removed product from index: ${entry.uid}`);
}
} catch (error) {
console.error(`Algolia update failed for ${eventKey}:`, error);
// In production: send to error monitoring (Sentry, Datadog, etc.)
}
});
app.listen(3000, () => console.log("Webhook handler running on port 3000"));This handler demonstrates all four reliability patterns: signature verification, fast response, idempotent processing, and event-specific logic. The Algolia saving sequence uses the entry UID as the object ID, making updates naturally idempotent at the search engine layer as well — saving an object with the same ID cleanly overwrites the previous version.
Common mistakes
- Processing the webhook synchronously before responding. If your handler performs a database write, an API call to a third-party service, and a cache purge before sending the 200 response, any of those steps could time out. Contentstack then retries, and your handler receives the same event again - potentially causing duplicate operations. Always respond with 200 immediately and process asynchronously.
- Skipping signature verification. Without verifying Contentstack's request signature headers against the webhook public key, your endpoint accepts requests from any source. An attacker who discovers the URL could trigger arbitrary operations in your system - deleting search index records, purging caches, or corrupting synchronized data. Always verify signatures before processing.
- Not accounting for duplicate deliveries. Webhooks are delivered “at least once,” not “exactly once.” If your handler creates a new record for every delivery rather than upserting, duplicate deliveries create duplicate records. Always implement idempotent processing using a deduplication key.