A critical vulnerability was discovered in React Server Components (Next.js). Our systems remain protected but we advise to update packages to newest version. Learn More

Minesh Shah (Netcel)
Dec 17, 2025
  538
(5 votes)

Cleaning Up Content Graph Webhooks in PaaS CMS: Scheduled Job

The Problem

Bit of a niche issue, but we are building a headless solution where the presentation layer is hosted on Netlify, when in a regular delivery cycle and work has to be tested a deployment preview is generated with a unique url, as part of the frontend deployment a new Web Hook is registered in Optimizely Content Graph so when content is published the frontend cache can be invalidated. The problem here is that with so many deployments occuring the list of web hooks can become quite long. While we could have configured deployment previews to skip webhook registration, that would have meant cached content on those environments, preventing proper testing of real-time content updates.

The Solution

Instead of compromising our testing workflow, we built an automated cleanup solution using Optimizely's Scheduled Jobs feature. This job periodically scans all registered webhooks and removes any that don't belong to our production environments, keeping the list clean and manageable.

Implementation

using EPiServer.PlugIn;
using EPiServer.Scheduler;
using RestSharp;
using System.Text;
using System.Text.Json;

namespace Client.Cms.Infrastructure.Jobs
{
    [ScheduledPlugIn(DisplayName = "Clear Graph Webhooks", GUID = "D4F5A6B7-C8D9-4E0F-AB12-3456789ABCDE")]
    public class ClearGraphWebhooksJob : ScheduledJobBase
    {
        private readonly IConfiguration _configuration;
        private readonly IContentLoader _contentLoader;
        private bool _stopSignaled;

        public ClearGraphWebhooksJob(IConfiguration configuration, IContentLoader contentLoader)
        {
            _configuration = configuration;
            _contentLoader = contentLoader;
            IsStoppable = true;
        }

        public override string Execute()
        {
            try
            {
                // Fetch Content Graph Gateway Address, AppKey and Secret from appSettings
                var gatewayAddress = _configuration["Optimizely:ContentGraph:GatewayAddress"];
                var appKey = _configuration["Optimizely:ContentGraph:AppKey"];
                var appSecret = _configuration["Optimizely:ContentGraph:Secret"];

                if (string.IsNullOrEmpty(gatewayAddress) || string.IsNullOrEmpty(appKey) || string.IsNullOrEmpty(appSecret))
                {
                    return "Failed: Missing configuration values for ContentGraph";
                }

                var auth = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{appKey}:{appSecret}"));
                var baseUrl = $"{gatewayAddress}/api/webhooks";

                var client = new RestClient(new RestClientOptions(baseUrl));
                var request = new RestRequest();
                request.AddHeader("Accept", "application/json");
                request.AddHeader("Authorization", $"Basic {auth}");

                var response = client.GetAsync(request).GetAwaiter().GetResult();

                if (!response.IsSuccessful)
                {
                    OnStatusChanged($"Failed to fetch webhooks: {response.StatusCode} {response.ErrorMessage}");
                    return $"Failed: {response.StatusCode} - {response.ErrorMessage}";
                }

                var json = response.Content;
                var webhooks = JsonSerializer.Deserialize<List<WebhookItem>>(json, new JsonSerializerOptions
                {
                    PropertyNameCaseInsensitive = true
                });

                if (webhooks == null || webhooks.Count == 0)
                {
                    OnStatusChanged("No webhooks found or failed to deserialize response.");
                    return "Success: No webhooks to process";
                }

                int deletedCount = 0;
                int skippedCount = 0;

                foreach (var webhook in webhooks)
                {
                    if (_stopSignaled)
                    {
                        return $"Stopped: Processed {deletedCount} deletions, {skippedCount} skipped";
                    }

                    var urls = GetUrls().ToList();
                    if (urls.Any(url => webhook.Request.Url.Contains(url)))
                    {
                        OnStatusChanged($"Skipping webhook {webhook.Id} with URL {webhook.Request.Url}");
                        skippedCount++;
                        continue;
                    }

                    DeleteWebhook(webhook.Id.ToString(), auth, baseUrl);
                    deletedCount++;
                }

                return $"Success: Deleted {deletedCount} webhooks, skipped {skippedCount}";
            }
            catch (Exception ex)
            {
                OnStatusChanged($"Error: {ex.Message}");
                return $"Failed: {ex.Message}";
            }
        }

        public override void Stop()
        {
            _stopSignaled = true;
            base.Stop();
        }

        private void DeleteWebhook(string id, string auth, string baseUrl)
        {
            var client = new RestClient(new RestClientOptions($"{baseUrl}/{id}"));
            var request = new RestRequest();
            request.AddHeader("Authorization", $"Basic {auth}");

            var response = client.DeleteAsync(request).GetAwaiter().GetResult();

            if (!response.IsSuccessful)
            {
                OnStatusChanged($"Failed to delete webhook {id}: {response.StatusCode} {response.ErrorMessage}");
            }
            else
            {
                OnStatusChanged($"Deleted webhook {id}");
            }
        }

        // Fetch URLs from Site Config Block
        private IEnumerable<string> GetUrls()
        {
            var globalSettingsBlock = _contentLoader
                .GetChildren<OptimizelySettingsBlock>(ContentReference.GlobalBlockFolder)
                .FirstOrDefault();

            return globalSettingsBlock?.FrontendUrls ?? Enumerable.Empty<string>;
        }
    }

    public class WebhookRequest
    {
        public string Url { get; set; }
        public string Method { get; set; }
        public Dictionary<string, string> Headers { get; set; }
    }

    public class WebhookItem
    {
        public Guid Id { get; set; }
        public WebhookRequest Request { get; set; }
        public string Preset { get; set; }
    }
}

How It Works

  1. Authentication: The job authenticates with Content Graph using your AppKey and Secret from configuration
  2. Fetch Webhooks: It retrieves all registered webhooks via the Content Graph API
  3. Filter by URL: The job checks each webhook URL against a configurable list of production URLs stored in a global settings block
  4. Smart Deletion: Webhooks matching production URLs are preserved; all others are deleted
  5. Progress Tracking: The job reports its progress and provides a summary of deletions and skips

Key Features

  • Content-Driven Configuration: Production URLs are stored in an Optimizely block, making them editable by content editors without code changes
  • Stoppable: The job implements IsStoppable = true and properly handles stop signals for graceful cancellation
  • Comprehensive Logging: Uses OnStatusChanged() to provide detailed progress updates visible in the Scheduled Jobs interface
  • Error Handling: Robust try-catch blocks and validation ensure the job fails gracefully with meaningful error messages

Benefits

  • Deployment previews can register webhooks and test real-time content updates
  • The webhook list stays clean through automated maintenance
  • No manual intervention required
  • Content editors control which URLs to preserve through the CMS interface

Conclusion

By leveraging Optimizely's Scheduled Jobs framework, we've created an automated solution that maintains a clean webhook registry while preserving full functionality across all environments. This approach is far more maintainable than manually managing webhooks or compromising testing capabilities on deployment previews.

The scheduled job can run daily, weekly, or at any interval that suits your deployment frequency, ensuring your Content Graph webhook list remains lean and manageable.

Dec 17, 2025

Comments

Please login to comment.
Latest blogs
A day in the life of an Optimizely OMVP: Migrating an Optimizely CMS Extension from CMS 12 to CMS 13: A Developer's Guide

With Optimizely CMS 13 now available in preview, extension developers need to understand what changes are required to make their packages compatibl...

Graham Carr | Jan 26, 2026

An “empty” Optimizely CMS 13 (preview) site on .NET 10

Optimizely CMS 13 is currently available as a preview. If you want a clean sandbox on .NET 10, the fastest path today is to scaffold a CMS 12 “empt...

Pär Wissmark | Jan 26, 2026 |

Building AI-Powered Tools with Optimizely Opal - A Step-by-Step Guide

Learn how to build and integrate custom tools with Optimizely Opal using the Opal Tools SDK. This tutorial walks through creating tools, handling...

Michał Mitas | Jan 26, 2026 |

Optimizely Opal: Unlocking AI-Powered Marketing with Opal Chat & Agents

I’ve just released a new YouTube video highlighting Optimizely Opal and how it’s transforming modern marketing with AI-powered chat and intelligent...

Madhu | Jan 24, 2026 |

From 12 to 13 preview: A Developer's Guide to testing an Optimizely CMS 13 Alloy Site

The release of Optimizely CMS 13 marks a significant step forward, embracing a more composable and headless-first architecture. While this unlocks...

Robert Svallin | Jan 23, 2026

A day in the life of an Optimizely OMVP: Opti North Manchester - January 2026 Meetup Recap

There's something special about the Optimizely community in the North. On 22nd January, we gathered at the Everyman Cinema in Manchester for the...

Graham Carr | Jan 23, 2026