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
- Authentication: The job authenticates with Content Graph using your AppKey and Secret from configuration
- Fetch Webhooks: It retrieves all registered webhooks via the Content Graph API
- Filter by URL: The job checks each webhook URL against a configurable list of production URLs stored in a global settings block
- Smart Deletion: Webhooks matching production URLs are preserved; all others are deleted
- 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.
Comments