TL;DR: The Cloudflare.NET SDK lets you purge cached content programmatically: invalidate specific URLs when CMS content changes, or purge entire subdomains for multi-tenant platforms.
Cloudflare's CDN caches your content at edge locations worldwide. This is great for performance, but creates a problem: when content changes, visitors might see stale versions until the cache expires.
The manual solution is logging into the Cloudflare dashboard and clicking "Purge Cache." This doesn't scale when your CMS publishes dozens of articles per day, or when tenant content updates need immediate visibility.
The Cloudflare.NET SDK provides typed access to the cache purge API, letting you invalidate content from your .NET application.
Purge Options
The API supports several purge strategies, all available on every plan:
| Strategy | Use Case | Limit |
|---|---|---|
| Specific URLs | Individual pages changed | 30 URLs per request |
| By prefix | Section of site updated | 30 prefixes per request |
| By host | Entire subdomain changed | 30 hostnames per request |
| Everything | Full cache clear | Use sparingly |
Rate limits vary by plan (Free: 5 requests/minute, Pro/Business: 5-10 requests/second, Enterprise: 50 requests/second).
For most applications, purging specific URLs is the right approach.
Scenario 1: CMS Publish Webhook
The Problem
Your content team uses a headless CMS (Contentful, Sanity, Strapi, or similar). When they publish an article, the cached version on Cloudflare doesn't update until the TTL expires. Editors see their changes immediately in the CMS preview, then complain that the live site shows old content.
The Solution
Create a webhook endpoint that receives publish events from your CMS and purges the affected URLs:
[ApiController]
[Route("webhooks")]
public class CmsWebhookController(
ICloudflareApiClient cloudflare,
ILogger<CmsWebhookController> logger) : ControllerBase
{
private const string ZoneId = "your-zone-id";
private const string BaseUrl = "https://example.com";
private const string DefaultLocale = "en-US";
[HttpPost("contentful")]
public async Task<IActionResult> HandleContentfulPublish(
[FromBody] ContentfulWebhookPayload payload,
[FromHeader(Name = "X-Contentful-Topic")] string topic)
{
// X-Contentful-Topic format: ContentManagement.Entry.publish
if (!topic.EndsWith(".publish", StringComparison.OrdinalIgnoreCase))
{
return Ok();
}
var urlsToPurge = BuildUrlsForEntry(payload);
if (urlsToPurge.Count == 0)
{
logger.LogInformation("No URLs to purge for entry {EntryId}", payload.Sys.Id);
return Ok();
}
logger.LogInformation(
"Purging {Count} URLs for entry {EntryId}: {Urls}",
urlsToPurge.Count,
payload.Sys.Id,
string.Join(", ", urlsToPurge));
await cloudflare.Zones.PurgeCacheAsync(ZoneId,
new PurgeCacheRequest(Files: urlsToPurge));
return Ok(new { purged = urlsToPurge.Count });
}
private List<string> BuildUrlsForEntry(ContentfulWebhookPayload payload)
{
var urls = new List<string>();
var contentType = payload.Sys.ContentType?.Sys.Id;
switch (contentType)
{
case "blogPost":
var slug = GetLocalizedField(payload.Fields, "slug");
if (slug != null)
{
urls.Add($"{BaseUrl}/blog/{slug}");
urls.Add($"{BaseUrl}/blog"); // Blog index page
}
break;
case "page":
var pageSlug = GetLocalizedField(payload.Fields, "slug");
if (pageSlug != null)
{
urls.Add($"{BaseUrl}/{pageSlug}");
}
break;
case "siteSettings":
// Global settings changed, purge homepage
urls.Add(BaseUrl);
urls.Add($"{BaseUrl}/");
break;
}
return urls;
}
private static string? GetLocalizedField(JsonElement? fields, string fieldName)
{
// Contentful fields are nested by locale: fields.slug["en-US"]
if (fields is not { } f)
return null;
if (!f.TryGetProperty(fieldName, out var fieldElement))
return null;
if (!fieldElement.TryGetProperty(DefaultLocale, out var localizedValue))
return null;
return localizedValue.GetString();
}
}
public record ContentfulWebhookPayload(
[property: JsonPropertyName("sys")] ContentfulSys Sys,
[property: JsonPropertyName("fields")] JsonElement? Fields
);
public record ContentfulSys(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("contentType")] ContentfulLink? ContentType
);
public record ContentfulLink(
[property: JsonPropertyName("sys")] ContentfulSys Sys
);
Handling the 30 URL Limit
If a single publish event affects more than 30 URLs, batch the requests. Add this method to the controller:
private async Task PurgeBatchedAsync(List<string> urls)
{
foreach (var batch in urls.Chunk(30))
{
await cloudflare.Zones.PurgeCacheAsync(ZoneId,
new PurgeCacheRequest(Files: batch.ToList()));
logger.LogInformation("Purged batch of {Count} URLs", batch.Length);
}
}
Scenario 2: Multi-Tenant Cache Invalidation
The Problem
You're running a multi-tenant platform where each tenant has a subdomain (acme.yourapp.com, globex.yourapp.com). When a tenant updates their content, you need to purge their subdomain's cache without affecting other tenants.
The Solution
Purge by hostname when tenant content changes:
public class TenantCacheService(
ICloudflareApiClient cloudflare,
ILogger<TenantCacheService> logger)
{
private const string ZoneId = "your-zone-id";
private const string BaseDomain = "yourapp.com";
public async Task InvalidateTenantCacheAsync(string tenantSlug)
{
var hostname = $"{tenantSlug}.{BaseDomain}";
logger.LogInformation("Purging cache for tenant hostname: {Hostname}", hostname);
await cloudflare.Zones.PurgeCacheAsync(ZoneId,
new PurgeCacheRequest(Hosts: [hostname]));
logger.LogInformation("Cache purged for {Hostname}", hostname);
}
public async Task InvalidateMultipleTenantsCacheAsync(IEnumerable<string> tenantSlugs)
{
var hostnames = tenantSlugs
.Select(slug => $"{slug}.{BaseDomain}")
.ToList();
logger.LogInformation("Purging cache for {Count} tenant hostnames", hostnames.Count);
await cloudflare.Zones.PurgeCacheAsync(ZoneId,
new PurgeCacheRequest(Hosts: hostnames));
}
// For more granular control, purge specific URLs instead of the entire hostname
public async Task InvalidateTenantCacheByUrlsAsync(
string tenantSlug,
IEnumerable<string> paths)
{
var hostname = $"{tenantSlug}.{BaseDomain}";
var urls = paths
.Select(path => $"https://{hostname}{path}")
.ToList();
// Batch to stay under 30 URL limit
foreach (var batch in urls.Chunk(30))
{
await cloudflare.Zones.PurgeCacheAsync(ZoneId,
new PurgeCacheRequest(Files: batch.ToList()));
}
}
}
Installation
dotnet add package Cloudflare.NET.Api
Configuration
See the Getting Started guide for full setup instructions.
appsettings.json
{
"Cloudflare": {
"ApiToken": "your-api-token",
"AccountId": "your-account-id"
}
}
Required Permissions
Your API token needs the Cache Purge permission at Zone level.
When creating a custom API token in the Cloudflare dashboard:
- Permission: Zone → Cache Purge → Purge
- Zone Resources: Select the specific zone or "All zones"
Dependency Injection Setup
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddCloudflareApiClient(builder.Configuration);
builder.Services.AddScoped<TenantCacheService>();
Error Handling
try
{
await cloudflare.Zones.PurgeCacheAsync(zoneId, request);
}
catch (CloudflareApiException ex) when (ex.Errors.Any(e => e.Code == 10000))
{
// Authentication error
logger.LogError("Authentication failed: {Message}", ex.Errors.First().Message);
}
catch (CloudflareApiException ex) when (ex.Errors.Any(e => e.Code == 7003))
{
// Zone not found or invalid identifier
logger.LogError("Invalid zone: {Message}", ex.Errors.First().Message);
}
catch (CloudflareApiException ex) when (ex.Errors.Any(e => e.Code == 1094))
{
// Exceeded 30 file limit
logger.LogError("Too many URLs: {Message}", ex.Errors.First().Message);
}
catch (CloudflareApiException ex)
{
foreach (var error in ex.Errors)
{
logger.LogError("[{Code}] {Message}", error.Code, error.Message);
}
}
Summary
| Scenario | What You Get |
|---|---|
| CMS Webhook | Automatic cache invalidation when content is published |
| Multi-Tenant | Per-tenant cache purge without affecting other customers |
The purge API is simple, but automating it removes the "why is the old content still showing?" conversation from your workflow.
Get started:
dotnet add package Cloudflare.NET.Api
Learn more:
Cloudflare.NET is an open-source, community-maintained SDK. Contributions are welcome!
Top comments (0)