Building an Event-Driven Webhook Pipeline in SitecoreAI - An Architectural Deep Dive
Every publish event is a signal. Here's how to build the infrastructure that actually listens.
Here's a scenario most Sitecore teams have lived through. A content editor publishes an article, then walks over to a developer and says "the page hasn't updated yet." The developer checks the CDN — cached. Purges it manually. Five minutes later, another editor. Same story.
The root cause isn't the CDN. It's that nothing in the system is listening for the publish event and reacting to it in real time. Content goes from the CM to Experience Edge, and then silence. Downstream systems — the Next.js frontend, the Sitecore Search index, the Content Hub asset record — have no idea something changed.
SitecoreAI (the platform formerly known as XM Cloud, now part of the broader Sitecore AI product family) ships with a webhook system designed to solve exactly this. When used correctly, it becomes the backbone of a fully event-driven architecture where a single publish cascades automatically through your entire stack. When used naively, it's a system that silently drops messages and leaves you wondering why ISR pages are stale.
This post walks through building it the right way — with a durable receiver, a proper message broker, and typed consumers that can fail gracefully.
Three Webhook Types — and Why the Distinction Matters
Most developers encounter SitecoreAI webhooks through the CM UI, create a single Event Handler pointing at their endpoint, and call it done. That misses two more powerful types that unlock very different integration patterns.
| Type | What triggers it | Blocks workflow? | Best used for |
|---|---|---|---|
| Event handler | item:saved, item:published, item:moved… |
No | Reacting to CMS events asynchronously |
| Submit action | A workflow command runs (e.g. Approve) | No | Notifying external systems on state change |
| Validation action | A workflow state transition is attempted | Yes — response gates the move | External compliance or quality checks |
The Validation action type is the most underused and the most powerful. Imagine a content quality score service that must return { valid: true } before an item can transition to Approved. If your service returns { valid: false }, the CMS holds the item in its current state — no developer intervention needed. This pattern alone can replace entire editorial workflow management tools.
The Architecture
Before touching any code, it's worth understanding the full picture. The four-tier flow below is the architecture we'll build together.
Four-tier event-driven architecture — SOURCE → RECEIVER → MESSAGE BROKER → CONSUMERS
The key design decision is the buffer between SitecoreAI and everything downstream. The webhook receiver doesn't process anything — it validates the incoming payload, drops a message onto Azure Service Bus, and returns 200 OK in under two seconds. Everything else happens asynchronously in consumer functions, each subscribing to the same topic with its own filter.
The Critical Limitation You'll Hit in Production
If your endpoint is down, slow, or returns a non-2xx response, the event is dropped. Silently. You will never know it happened unless you build the safety net yourself.
This is the thing that isn't in the docs in large enough font. Every production webhook architecture has to account for it. The solution is simple: make your receiver so fast and so durable that it almost never fails, and build a dead-letter queue replay mechanism for the times it does.
The receiver's only job is to validate the signature, enqueue the message, and return 200 OK. That's it. No database calls. No HTTP calls to downstream services. No parsing content fields. Just validate, enqueue, return.
Setting Up the Webhook
Option A: Via the CM UI (quick start)
Navigate to /sitecore/System/Webhooks in the CM. Create a new Webhook Event Handler. Set the event to publish:end, enter your receiver URL, and choose JSON serialization. One gotcha: you cannot set a Rule filter on Publish events — only item-level events support rule-based filtering.
Option B: Via the Experience Edge Admin API (production-recommended)
The Admin API approach is infrastructure-as-code friendly and works for both CM-level and Edge-level webhooks. Start by getting a JWT:
# Step 1 — obtain a JWT from Sitecore auth
curl -X POST https://auth.sitecorecloud.io/oauth/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=client_credentials' \
-d 'client_id=YOUR_CLIENT_ID' \
-d 'client_secret=YOUR_CLIENT_SECRET' \
-d 'audience=https://api.sitecorecloud.io'
# Step 2 — register the webhook
curl -X POST https://edge.sitecorecloud.io/api/admin/v1/webhooks \
-H 'Authorization: Bearer YOUR_JWT' \
-H 'Content-Type: application/json' \
-d '{
"label": "item-published-to-service-bus",
"uri": "https://your-func.azurewebsites.net/api/WebhookReceiver",
"method": "POST",
"headers": { "x-functions-key": "YOUR_FUNCTION_KEY" },
"executionMode": "OnEnd"
}'
JWTs expire in 24 hours. For automated infrastructure setup, build your deployment pipeline around token generation. The Admin API supports the full CRUD surface — GET, POST, PATCH, PUT, and DELETE on /webhooks and /webhooks/{id}.
The Webhook Receiver — Azure Function
The receiver is a standard HTTP-triggered Azure Function with an output binding to Service Bus. The key discipline: nothing slow happens here.
// WebhookReceiverFunction.cs
[FunctionName("WebhookReceiver")]
public static async Task<IActionResult> Run(
[HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequest req,
[ServiceBus("xmc-item-events", Connection = "ServiceBusConnection")]
IAsyncCollector<ServiceBusMessage> outputMessages,
ILogger log)
{
string body = await new StreamReader(req.Body).ReadToEndAsync();
XmCloudWebhookPayload payload;
try
{
payload = JsonSerializer.Deserialize<XmCloudWebhookPayload>(body);
}
catch (Exception ex)
{
log.LogError(ex, "Invalid webhook payload");
return new BadRequestResult();
}
// Only process publish end events
if (payload?.EventName != "publish:end")
return new OkResult();
var message = new ServiceBusMessage(body)
{
// Idempotency key — deduplicates if the same event fires twice
MessageId = $"{payload.Item?.Id}-{payload.Item?.Version}",
Subject = payload.EventName,
ContentType = "application/json",
TimeToLive = TimeSpan.FromHours(24)
};
message.ApplicationProperties["itemId"] = payload.Item?.Id;
message.ApplicationProperties["language"] = payload.Item?.Language;
await outputMessages.AddAsync(message);
return new OkResult(); // Return 200 before any downstream work
}
The MessageId set to {itemId}-{version} is your idempotency key. Service Bus deduplicates on this during a configurable window, so duplicate events — which do happen — won't trigger double-processing downstream.
Three Consumers, Three Real-World Use Cases
Consumer 1 — Next.js ISR Revalidation
When an item publishes, your Next.js frontend should regenerate the affected page immediately — not wait for the next scheduled ISR window. But there's an important detail most posts skip: Next.js is a web framework — it only speaks HTTP. It has no mechanism to subscribe to a message queue directly.
So Consumer 1 is actually two components working together: an Azure Function that holds the Service Bus subscription and acts as the bridge, and a Next.js API route that does the actual page regeneration.
| Service Bus | ──(sub:isr)──▶ | Azure Function | ──HTTP POST──▶ | Next.js |
| xmc-item-events | IsrRevalidationConsumer [ServiceBusTrigger] throws on failure = retry |
/api/revalidate res.revalidate(route) returns 200 OK |
Step 1 — The Azure Function bridge (Service Bus subscriber)
This function fires automatically whenever a message lands on the sub:isr subscription. It maps the Sitecore item path to a Next.js route and makes a secured HTTP POST to /api/revalidate. Notice it throws on failure — unlike the receiver which always returns 200 OK, throwing here tells Service Bus to retry the message automatically, and eventually dead-letter it if all retries are exhausted.
// IsrRevalidationConsumer.cs — Azure Function (Service Bus subscriber)
[FunctionName("IsrRevalidationConsumer")]
public static async Task Run(
[ServiceBusTrigger("xmc-item-events", "sub-isr",
Connection = "ServiceBusConnection")] ServiceBusMessage message,
ILogger log)
{
var payload = JsonSerializer.Deserialize<XmCloudWebhookPayload>(
message.Body.ToString());
// Map Sitecore item path → Next.js route
var route = payload.Item.Path
.Replace("/sitecore/content/MySite/home", "",
StringComparison.OrdinalIgnoreCase)
.ToLower();
if (string.IsNullOrEmpty(route)) route = "/";
// Build the request body — must match what /api/revalidate expects
var body = JsonSerializer.Serialize(new {
itemPath = payload.Item.Path,
language = payload.Item.Language
});
using var http = new HttpClient();
http.DefaultRequestHeaders.Add(
"x-revalidation-secret",
Environment.GetEnvironmentVariable("REVALIDATION_SECRET"));
var response = await http.PostAsync(
$"{Environment.GetEnvironmentVariable("NEXTJS_BASE_URL")}/api/revalidate",
new StringContent(body, Encoding.UTF8, "application/json"));
if (!response.IsSuccessStatusCode)
{
// Throw so Service Bus retries → eventually dead-letters if all retries fail
throw new Exception(
$"Revalidation failed: {response.StatusCode} for route {route}");
}
log.LogInformation("Revalidated {Route} for item {Id}",
route, payload.Item.Id);
}
Step 2 — The Next.js endpoint (HTTP receiver)
This is a standard Next.js API route. It receives the HTTP POST from the Azure Function above, verifies the shared secret, maps the item path to a route, and calls res.revalidate(). It knows nothing about Service Bus — it simply responds to an authenticated HTTP request.
// pages/api/revalidate.ts — Next.js API route (HTTP receiver)
// Called by: IsrRevalidationConsumer Azure Function
// Expects body: { itemPath: string, language: string }
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
// Reject anything not coming from the trusted Azure Function
if (req.headers['x-revalidation-secret'] !== process.env.REVALIDATION_SECRET)
return res.status(401).json({ message: 'Unauthorized' })
// These fields are sent by IsrRevalidationConsumer — must match exactly
const { itemPath, language } = req.body
try {
const route = itemPathToRoute(itemPath, language)
await res.revalidate(route)
return res.json({ revalidated: true, route })
} catch (err) {
// Return 500 so the Azure Function throws and Service Bus retries
return res.status(500).json({ message: 'Revalidation failed' })
}
}
function itemPathToRoute(sitecorePath: string, lang: string): string {
const route = sitecorePath
.replace(/^\/sitecore\/content\/[^\/]+\/home/i, '')
.toLowerCase() || '/'
return lang === 'en' ? route : `/${lang}${route}`
}
When Next.js returns 500, the Azure Function throws, which tells Service Bus to retry the message. When Next.js returns 200, the Azure Function returns normally, which tells Service Bus the message is done. The two components are loosely coupled by HTTP but tightly coupled by this retry contract — if you change the response codes in one, you must update the other.
Consumer 2 — Content Hub URL Sync
When content that originated in Content Hub gets published through SitecoreAI, the live URL doesn't automatically write back to the asset record. This consumer fixes that — it receives the publish event, builds the live URL, and updates the Website Link field on the corresponding Content Hub entity via REST API.
// ContentHubSyncConsumer.cs — Azure Function (Service Bus subscriber)
[FunctionName("ContentHubSyncConsumer")]
public static async Task Run(
[ServiceBusTrigger("xmc-item-events", "sub-content-hub",
Connection = "ServiceBusConnection")] ServiceBusMessage message,
ILogger log)
{
var payload = JsonSerializer.Deserialize<XmCloudWebhookPayload>(
message.Body.ToString());
if (!payload.Item.Path.StartsWith("/sitecore/content")) return;
var hubEntityId = payload.Item.Fields.GetValueOrDefault("ContentHubId");
if (string.IsNullOrEmpty(hubEntityId))
{
log.LogWarning("No ContentHubId on item {Id} — skipping", payload.Item.Id);
return;
}
// Build live URL from item path
var liveUrl = $"{Environment.GetEnvironmentVariable("SITE_BASE_URL")}"
+ payload.Item.Path
.Replace("/sitecore/content/MySite/home", "",
StringComparison.OrdinalIgnoreCase)
.ToLower();
// PATCH the Website Link field on the Content Hub entity
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("X-Auth-Token",
Environment.GetEnvironmentVariable("CONTENT_HUB_TOKEN"));
var patchBody = JsonSerializer.Serialize(new {
properties = new { WebsiteLink = liveUrl }
});
var response = await http.PatchAsync(
$"{Environment.GetEnvironmentVariable("CONTENT_HUB_BASE_URL")}/api/entities/{hubEntityId}",
new StringContent(patchBody, Encoding.UTF8, "application/json"));
if (!response.IsSuccessStatusCode)
throw new Exception(
$"Content Hub PATCH failed: {response.StatusCode} for entity {hubEntityId}");
log.LogInformation("Synced {Url} to Content Hub entity {Id}", liveUrl, hubEntityId);
}
This reads ContentHubId directly from the webhook payload. This works when the field is included in your webhook serialization config in the CM. If it is missing or truncated in your setup, fetch the full item from Experience Edge GraphQL using payload.Item.Id as a fallback.
Consumer 3 — Sitecore Search Indexer
Every approved publish pushes the item to the Sitecore Search Ingestion API — no manual re-index jobs, no scheduled crawlers.
// SearchIndexConsumer.cs — Azure Function (Service Bus subscriber)
[FunctionName("SearchIndexConsumer")]
public static async Task Run(
[ServiceBusTrigger("xmc-item-events", "sub-search",
Connection = "ServiceBusConnection")] ServiceBusMessage message,
ILogger log)
{
var payload = JsonSerializer.Deserialize<XmCloudWebhookPayload>(
message.Body.ToString());
if (!payload.Item.Path.StartsWith("/sitecore/content")) return;
// Build document from payload fields
var liveUrl = $"{Environment.GetEnvironmentVariable("SITE_BASE_URL")}"
+ payload.Item.Path
.Replace("/sitecore/content/MySite/home", "",
StringComparison.OrdinalIgnoreCase)
.ToLower();
// "value" wrapper is required by Sitecore Search Ingestion API
var document = new {
id = payload.Item.Id,
value = new {
url = liveUrl,
title = payload.Item.Fields.GetValueOrDefault("Title") ?? "",
body = payload.Item.Fields.GetValueOrDefault("Text") ?? "",
language = payload.Item.Language,
type = payload.Item.Fields.GetValueOrDefault("TemplateName") ?? "page"
}
};
// Correct endpoint: /api/ingestion/v1/sources/{sourceId}/documents
var sourceId = Environment.GetEnvironmentVariable("SEARCH_SOURCE_ID");
var ingestUrl = $"https://api.search.sitecorecloud.io/api/ingestion/v1/sources/{sourceId}/documents";
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("Authorization",
$"Bearer {Environment.GetEnvironmentVariable("SEARCH_API_KEY")}");
var ingestResponse = await http.PostAsync(
ingestUrl,
new StringContent(
JsonSerializer.Serialize(new { documents = new[] { document } }),
Encoding.UTF8, "application/json"));
if (!ingestResponse.IsSuccessStatusCode)
throw new Exception(
$"Search ingestion failed: {ingestResponse.StatusCode} for item {payload.Item.Id}");
log.LogInformation("Indexed item {Id} in Sitecore Search source {SourceId}",
payload.Item.Id, sourceId);
}
OnEnd vs OnUpdate — Choose Carefully
| Mode | When it fires | Payload | Best for |
|---|---|---|---|
| OnEnd | After the full publish job completes | Custom body you define | ISR revalidation, search indexing |
| OnUpdate | On each individual content update | Full entity diff (field-level) | Field-level change detection, audit logging |
Start with OnEnd. It fires once per publish batch, the payload is predictable, and the volume is manageable. Switch specific subscriptions to OnUpdate only when you genuinely need to react to which fields changed — for example, if you only want to retranslate content when the Title or Body field changes, not when a metadata field does.
Dead Letters and the Replay Pattern
Even with a fast, durable receiver, consumer failures happen. A downstream service is unavailable, a content item has unexpected field data, a network partition occurs. Service Bus moves failed messages to the dead-letter queue after the configured retry count. Without a strategy for these, they silently accumulate.
// DeadLetterProcessor — timer-triggered Azure Function (every 15 min)
[FunctionName("DeadLetterProcessor")]
public static async Task Run(
[TimerTrigger("0 */15 * * * *")] TimerInfo timer,
ILogger log)
{
// All three subscriptions have their own dead-letter queue
var subscriptions = new[] {
"sub-isr", "sub-content-hub", "sub-search"
};
await using var client = new ServiceBusClient(
Environment.GetEnvironmentVariable("ServiceBusConnection"));
foreach (var subscription in subscriptions)
{
await using var receiver = client.CreateReceiver(
"xmc-item-events", subscription,
new ServiceBusReceiverOptions { SubQueue = SubQueue.DeadLetter });
var messages = await receiver.ReceiveMessagesAsync(maxMessages: 20);
foreach (var msg in messages)
{
log.LogWarning(
"Dead-lettered [{Subscription}]: {Reason} | Item: {ItemId}",
subscription,
msg.DeadLetterReason,
msg.ApplicationProperties["itemId"]);
await SendOpsAlert(
subscription,
msg.DeadLetterReason,
msg.ApplicationProperties["itemId"].ToString());
await receiver.CompleteMessageAsync(msg);
}
}
}
A complementary safety net: because SitecoreAI also fires item:saved and item:created events, you can build a reconciliation job that periodically re-processes recently saved items — catching any gaps left by dropped publish events. This is the event sourcing fallback pattern.
Production Checklist
- Receiver returns 200 OK in under 2 seconds — zero real work synchronously
- All processing in Service Bus consumers — never in the receiver
- MessageId set to {itemId}-{version} — idempotency key against duplicate events
- TimeToLive set on messages — stale content updates shouldn't queue indefinitely
- Dead-letter queue monitored — it's your only signal when events drop
- Replay function in place — timer-triggered safety net for missed events
- OnEnd as the default execution mode — switch to OnUpdate only when you need field-level diffs
- Webhook receiver URL pinned before launch — you can't change the endpoint without recreating the webhook
- Secrets in Key Vault — function keys, Service Bus connection strings, search API keys
Closing Thoughts
The webhook system in SitecoreAI is genuinely capable — but it's designed as a signal emitter, not a guaranteed delivery mechanism. The architecture pattern in this post shifts the reliability burden to where it belongs: Azure Service Bus, which is built for exactly this.
Once this pipeline is in place, every new integration becomes a new Service Bus subscription with a new consumer function. Adding a CRM sync, a cache invalidation hook, or a Slack notification to the editorial team is a matter of adding one more subscriber — the source and the receiver never change.
That's the power of event-driven architecture done properly. The CMS publishes — in both senses of the word.
About this post
Part of the Sitecore AI Architecture Series on learning-sitecore.blogspot.com — Practical insights from real-world, production-grade enterprise implementations across Sitecore AI, Content Hub, and the broader Sitecore ecosystem, including Azure-native integrations, event-driven architectures, and scalable composable DXP solutions.