Minesh Shah (Netcel)
OMVPOpal Administrator CertificationSaaS CMS Administrator Certification +8
Dec 17, 2025
visibility 1237
star star star star star
(6 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

error Please login to comment.
Latest blogs
The SharePoint Connector for Optimizely CMS 11 is Now Open-Source

Hello everyone, Today we are announcing that we have open-sourced Optimizely Connect for SharePoint, the connector that enables integration between...

Bien Nguyen | Nov 10, 2026

From Zero to App Directory: Building an Optimizely SaaS CMS App with OCP

A hands-on guide to extending the SaaS CMS editor with Optimizely Connect Platform (OCP): a sidebar extension, a secure backend function, the CMS...

Chirag Khanna | Nov 9, 2026 |

A note on using async computed fields with Optimizely Graph

Computed fields can be async, but do they work smoothly with the Graph C# SDK?

Johan Kronberg | Nov 9, 2026 |

Visual Builder without Next.js, Part 2: The Routing Decision Travels, the Engine Contract Doubles

One content tree, two engines, one edge: a strangler for Astro and Next.js on CMS 13. The same routing decision in a Next.js proxy and in a...

Piotr | Nov 8, 2026 |