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
  364
(3 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
Climbing the Relevancy Ladder: Unlocking Vertex AI Search Tiers in Commerce Search v3

Author: Naveen Samala, Staff Software Engineering at Optimizely What If Your Search Could Predict What Each Customer Will Buy? Imagine a search...

Patrick Lam | Jan 6, 2026

Introducing MicroMediator: A Lightweight High-Performance Mediator for .NET

A lightweight, high-performance mediator pattern library for .NET. Learn why MicroMediator exists, how it compares, and how it fits cleanly into ma...

Andy Blyth | Jan 5, 2026 |

Triggering Opal Workflows When Publishing Content

Over the course of November, my colleagues at Netcel and I took part in Optimizely’s Opal Innovation Challenge. We were tasked to be inventive and ...

Mark Stott | Jan 5, 2026

Optimizely: Our Journey in the Opal Innovation Challenge

When we signed up for Optimizely’s Opal Innovation Challenge, we knew it wasn’t just about building a solution—it was about solving a real marketin...

Madhu | Jan 3, 2026 |