DEV Community

Alexis
Alexis

Posted on

Cache Purge on Cloudflare for C# Applications

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
);
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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()));
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Installation

dotnet add package Cloudflare.NET.Api
Enter fullscreen mode Exit fullscreen mode

Configuration

See the Getting Started guide for full setup instructions.

appsettings.json

{
  "Cloudflare": {
    "ApiToken": "your-api-token",
    "AccountId": "your-account-id"
  }
}
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Learn more:


Cloudflare.NET is an open-source, community-maintained SDK. Contributions are welcome!

Top comments (0)