Integration patterns - event-driven, API-mediated, and batch sync
Integration patterns
TL;DR:
- Three patterns cover virtually all CMS integrations: event-driven (webhooks for real-time reactions), API-mediated (runtime composition at render time), and batch sync (scheduled bulk transfers).
- Most real projects use all three for different integration points — they are complementary, not competing
- Design webhook handlers to be idempotent; use Promise.allSettled for runtime composition; track progress with checkpoints for batch sync.
- Always build error handling specific to the pattern: dead-letter queues for events, graceful degradation for API calls, checkpoint-based resumption for batch jobs.
Connecting Contentstack to external systems is not a single problem - it is three different problems that require three different architectural approaches. Choosing the wrong integration pattern leads to stale data, brittle dependencies, or systems that cannot scale. This lesson covers the three fundamental patterns for integrating Contentstack with external systems: event-driven (webhooks), API-mediated (runtime composition), and batch sync (scheduled jobs). Each pattern has a specific set of strengths, and most real projects use all three for different integration points.
Pattern 1: Event-driven integration (webhooks)
Event-driven integration follows a publish-subscribe model: Contentstack publishes an event when something happens (entry published, asset updated, workflow stage changed), and an external system reacts to that event via a webhook.
How it works: You configure a webhook in Contentstack that fires an HTTP POST request to a URL you control whenever a specified event occurs. The external system receives the event payload, processes it, and performs its own action.
The flow:
- An editor publishes a blog entry in Contentstack.
- Contentstack fires a webhook to https://your-api.com/hooks/content-published.
- Your webhook handler receives the payload containing the entry data, content type UID, and event metadata.
- Your handler updates the Algolia search index with the new content.
- Algolia now serves the updated content in search results.
Here is a webhook handler that updates a search index when content is published:
// Express webhook handler: update Algolia on content publish
import express from "express";
import algoliasearch from "algoliasearch";
const app = express();
app.use(express.json());
const algolia = algoliasearch(process.env.ALGOLIA_APP_ID, process.env.ALGOLIA_ADMIN_KEY);
const index = algolia.initIndex("articles");
app.post("/hooks/content-published", async (req, res) => {
const { event, data } = req.body;
// Only process entry.publish events for the article content type
if (event !== "entry.publish" || data.content_type.uid !== "article") {
return res.status(200).json({ skipped: true });
}
const entry = data.entry;
await index.saveObject({
objectID: entry.uid,
title: entry.title,
summary: entry.summary,
body: entry.body,
category: entry.category,
published_at: entry.published_at,
url: entry.url,
locale: entry.locale,
});
res.status(200).json({ indexed: entry.uid });
});Best for: Real-time reactions to content changes. Search index updates, CDN cache invalidation, notification delivery, triggering downstream workflows, syncing content to external systems.
Considerations: Webhook delivery is at-least-once, meaning your handler may receive the same event more than once. Design handlers to be idempotent - processing the same event twice should produce the same result. Also, webhooks are fire-and-forget from Contentstack's perspective. If your endpoint is down, the event is lost unless you have configured retry logic. Contentstack provides retry configuration on webhook settings, but you should still build your handler to handle failures gracefully.
For a deeper look at webhook configuration, security, and reliability patterns, see Course 6, Lesson 4 on webhooks.
Pattern 2: API-mediated integration (runtime composition)
API-mediated integration happens at render time: the frontend (or a backend-for-frontend) calls multiple APIs and combines the responses into a single view. No data is copied between systems. Each system remains the authoritative source for its own data, and the frontend is the composition point.
How it works: When a user requests a page, your application fetches editorial content from Contentstack's Delivery API and fetches complementary data from one or more external APIs. The responses are combined and rendered.
The flow:
- A user visits /destinations/paris.
- The frontend fetches the Paris destination entry from Contentstack (description, images, travel tips).
- The frontend fetches available tour packages from the Amadeus travel API.
- The frontend fetches current weather data from a weather API.
- All three data sets are combined and rendered as a single page.
// Runtime composition: travel destination page
async function getDestinationPage(slug: string) {
// Editorial content from Contentstack
const query = stack.contentType("page").entry().query();
const cmsResult = await query
.equalTo("url", `/destinations/${slug}`)
.includeReference("featured_attractions")
.includeReference("travel_guides")
.find();
const destination = cmsResult.entries[0];
// Tour packages from Amadeus API
const toursResponse = await fetch(
`${AMADEUS_API}/shopping/activities?latitude=${destination.latitude}&longitude=${destination.longitude}&radius=20`,
{ headers: { Authorization: `Bearer ${AMADEUS_TOKEN}` } }
);
const tours = await toursResponse.json();
// Current weather from weather API
const weatherResponse = await fetch(
`${WEATHER_API}/current?lat=${destination.latitude}&lon=${destination.longitude}&appid=${WEATHER_KEY}`
);
const weather = await weatherResponse.json();
return {
destination, // editorial: description, images, tips
tours: tours.data, // transactional: available packages and prices
weather: weather.main, // real-time: temperature, conditions
};
}Best for: Data that changes independently at different rates. The destination description might update monthly, tour prices change hourly, and weather changes continuously. No single system owns all the data, and copying data between systems would create staleness.
Considerations: Runtime composition adds latency because the page depends on multiple API calls. Mitigate this with parallel requests (Promise.all), caching strategies, and fallback content when external APIs are slow or unavailable. If the weather API is down, the page should still render with CMS content and tour data - degrade gracefully rather than failing entirely.
// Parallel fetching with graceful degradation
async function getDestinationPage(slug: string) {
const cmsPromise = stack.contentType("page").entry().query()
.equalTo("url", `/destinations/${slug}`).find();
const [cmsResult, tours, weather] = await Promise.allSettled([
cmsPromise,
fetch(`${AMADEUS_API}/activities?lat=48.8566&lon=2.3522`).then(r => r.json()),
fetch(`${WEATHER_API}/current?lat=48.8566&lon=2.3522`).then(r => r.json()),
]);
return {
destination: cmsResult.status === "fulfilled" ? cmsResult.value.entries[0] : null,
tours: tours.status === "fulfilled" ? tours.value.data : [],
weather: weather.status === "fulfilled" ? weather.value.main : null,
};
}This is the pattern described in the previous lesson's hybrid composition approach. Lesson 1 covers the conceptual framework for what belongs where; this lesson covers the technical pattern for how those systems connect at runtime.
Pattern 3: Batch sync (scheduled jobs)
Batch sync moves data between systems on a schedule rather than in real time. A cron job, serverless function, or scheduled pipeline reads data from one system, transforms it, and writes it to another.
How it works: A scheduled process runs at a defined interval (hourly, nightly, weekly). It reads data from a source system, transforms it to match the target system's schema, and writes it to the target using an API.
The flow:
- Every night at 2:00 AM, a scheduled job runs.
- The job reads the product catalog from a Product Information Management (PIM) system.
- For each product, the job checks if a corresponding Contentstack entry exists.
- If the entry exists, it updates the entry via the Content Management API.
- If the entry does not exist, it creates a new entry.
- The job logs results and reports any failures.
// Nightly sync: PIM product catalog to Contentstack entries
import contentstackManagement from "@contentstack/management";
const client = contentstackManagement.client({
authtoken: process.env.NEXT_PUBLIC_CONTENTSTACK_MANAGEMENT_TOKEN,
});
const stackInstance = client.stack({
api_key: process.env.NEXT_PUBLIC_CONTENTSTACK_API_KEY,
});
async function syncProductCatalog() {
// Fetch all products from PIM
const pimProducts = await fetch(`${PIM_API}/products?updated_since=yesterday`)
.then((r) => r.json());
const results = { created: 0, updated: 0, failed: 0 };
for (const product of pimProducts) {
try {
// Check if entry already exists in Contentstack
const existing = await stackInstance
.contentType("product")
.entry()
.query({ query: { sku: product.sku } })
.find();
const entryData = {
title: product.name,
sku: product.sku,
short_description: product.summary,
specifications: product.specs.map((s) => ({
label: s.key,
value: s.value,
})),
primary_category: product.category_code,
};
if (existing.items.length > 0) {
// Update existing entry
const entry = existing.items[0];
Object.assign(entry, entryData);
await entry.update();
results.updated++;
} else {
// Create new entry
await stackInstance
.contentType("product")
.entry()
.create({ entry: entryData });
results.created++;
}
} catch (error) {
console.error(`Failed to sync product ${product.sku}:`, error.message);
results.failed++;
}
}
console.log(`Sync complete: ${JSON.stringify(results)}`);
return results;
}Best for: Large data volumes, initial data loading, systems that do not support event-driven integration, data normalization across sources. Batch sync is also useful when you need to populate Contentstack with structured data from external catalogs - the PIM provides the product framework, and editors then enrich entries with marketing content.
Considerations: Batch sync introduces latency by design. Data is only as fresh as the last sync run. For a nightly sync, content could be up to 24 hours behind the source. This is acceptable for product catalog structures but not for pricing or inventory. Also, the Content Management API has rate limits (detailed in Course 3). A sync that processes thousands of entries must respect these limits with throttling and backoff logic.
Choosing the right pattern
The choice depends on three factors: how quickly the target system needs to reflect changes, how much data moves, and whether the source system supports events.
| Requirement | Pattern | Why |
|---|---|---|
| Search index must reflect content changes within seconds | Event-driven | Webhooks fire immediately on publish |
| Product page combines CMS content with live pricing | API-mediated | Prices change independently; no data copying |
| 10,000 products need initial loading from PIM to CMS | Batch sync | Bulk data, one-time or periodic transfer |
| Slack notification when content enters review | Event-driven | Workflow stage change triggers webhook |
| Dashboard page shows CMS content alongside analytics | API-mediated | Analytics data is real-time, not editorial |
| Weekly export of published content to data warehouse | Batch sync | Bulk export, scheduled, tolerance for latency |
Combining patterns in one project
Real projects use multiple patterns simultaneously. Consider a travel company integrating Contentstack with several external systems:
Amadeus (booking API) — API-mediated: Tour prices and availability change constantly. The frontend queries Amadeus at render time to show current prices alongside editorial destination content from Contentstack. No data is copied.
Algolia (search) — Event-driven: When an editor publishes or updates a destination entry, a webhook fires and updates the Algolia search index. Users searching the site get results within seconds of content being published.
DAM system (digital asset management) — Batch sync: The company's photography team uploads thousands of images to a central DAM. A nightly sync process checks for new images tagged with destination codes and creates corresponding assets in Contentstack, linking them to destination entries. Editors then curate which images appear on each page.
Jira (project management) — Event-driven: When a content entry's workflow stage changes to “Review Needed,” a webhook sends a message to a Jira integration that creates a review task. The editorial workflow in Contentstack drives task creation in Jira automatically.
This combination is typical. The patterns are not competing approaches - they are complementary tools for different integration requirements.
Error handling for each pattern
Each pattern has distinct failure modes:
Event-driven failures: The webhook endpoint is unreachable, returns a 5xx error, or times out. Contentstack will retry based on your webhook configuration, but you should also build dead-letter handling: log failed events to a queue and reprocess them. Idempotent handlers prevent duplicate processing when retries succeed.
API-mediated failures: An external API is slow or unavailable at render time. Use Promise.allSettled instead of Promise.all so one failing API does not take down the entire page. Cache external API responses with a short TTL so the previous response is served while the API is recovering. Show placeholder content or hide sections gracefully.
Batch sync failures: A sync job fails partway through processing 5,000 entries. Track progress with a cursor or checkpoint so the next run resumes where the previous one failed rather than reprocessing everything. Log each entry's sync status individually so you can identify and retry specific failures.
// Dead-letter queue pattern for webhook failures
app.post("/hooks/content-published", async (req, res) => {
try {
await processEvent(req.body);
res.status(200).json({ processed: true });
} catch (error) {
// Log to dead-letter queue for later reprocessing
await deadLetterQueue.push({
event: req.body,
error: error.message,
timestamp: new Date().toISOString(),
retryCount: 0,
});
// Return 200 so Contentstack does not retry immediately
// The dead-letter processor handles retries on your schedule
res.status(200).json({ queued: true });
}
});Common mistakes
Mistake 1: Using batch sync when event-driven is appropriate
A team sets up a cron job that runs every 5 minutes to check if any content has changed in Contentstack and update the search index accordingly. This polling approach wastes resources and still has up to 5 minutes of latency. Contentstack webhooks can notify your search index handler the moment content is published, making the update nearly instantaneous with zero polling overhead.
Mistake 2: Copying external data into Contentstack instead of composing at runtime
A team syncs product prices from their commerce platform into Contentstack number fields every hour, then reads everything from Contentstack on the frontend. This creates a single point of failure (the sync job), introduces staleness (up to one hour of price delay), and forces editors to see and potentially edit prices that should be system-managed. The API-mediated pattern keeps prices in the commerce system and fetches them at render time, ensuring accuracy without data duplication.
Common pitfall: A webhook handler that returns 200 without checking downstream success silently loses events — if Algolia or Slack is down, the event is acknowledged and gone forever.
Mistake 3: Ignoring error handling in webhook endpoints
A webhook handler processes the event and returns a 200 status but has no fallback if the downstream service (Algolia, Slack, etc.) is unavailable. The event is acknowledged and lost. Building idempotent handlers with dead-letter queues ensures no events are permanently lost, even when downstream systems are temporarily unreachable.