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
  490
(4 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: Unlock the Power of Unified Search: Introducing Custom Data Management for Optimizely Graph

Bring all your data together in one searchable experience The Challenge: Siloed Data, Fragmented Search Every organisation has data spread across...

Graham Carr | Jan 16, 2026

Alt text is retrieved for images imported from CMP-DAM in Optimizely CMS 12.

Introduction Although image alt text is stored as metadata in CMP/DAM, it is not automatically rendered in browser HTML. This document outlines the...

Deepmala S | Jan 16, 2026

Announcing Stott Security Version 4.0

January 2026 marks the release of Stott Security v4, a significant update to the popular web security add-on for Optimizely CMS 12, with more than...

Mark Stott | Jan 16, 2026

Optimizely Graph Best Practices - Content Modelling and Querying

Introduction With the Mando Group team having worked extensively with Optimizely Graph over the last 12+ months, we have uncovered a number of best...

Jon Williams | Jan 16, 2026