Purging Cloudflare Cache Tags with the API
A product update, a CMS publish event, or a pricing change often invalidates dozens of rendered URLs at once — a product detail page, a category listing, a search result, a recommendation widget. Purging each URL individually is slow and error-prone, and purging the entire zone collapses your hit ratio and spikes origin load. Cloudflare’s Cache-Tag mechanism solves this by letting the origin attach logical labels to a response, so a single authenticated API call can evict every cached object sharing that label, regardless of how many distinct URLs it touches.
Prerequisite Concepts
Before scripting a purge workflow, make sure you understand the surrounding model:
- Surrogate keys and cache tags — the general concept of attaching metadata to cached responses so they can be purged as a group, independent of any single vendor.
- Tag-based cache invalidation patterns — how tag indexes are built at the edge and how purge dispatch interacts with
max-ageands-maxage. - Mastering
max-ageands-maxagedirectives — a response needs an explicit shared-cache TTL before Cloudflare will index its tags at all.
Step-by-Step Resolution
Step 1 — Emit Cache-Tag at the origin
Cloudflare reads tags from a response header named Cache-Tag. Unlike Fastly’s space-delimited Surrogate-Key, Cloudflare expects a comma-separated list:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, s-maxage=86400, stale-while-revalidate=3600
Cache-Tag: product-9472,category-footwear,tenant-eu
The response must also be public with a shared-cache TTL. If Cache-Control contains private or no-store, Cloudflare never stores the object, so the tag header is emitted for nothing — no index entry is ever created. Strip Cache-Tag before it reaches the browser with a Transform Rule; it has no meaning to end clients and only adds header overhead.
Step 2 — Warm the cache and confirm the tag was indexed
Request the URL twice. The first request should miss; the second should hit, confirming Cloudflare both stored the object and built the tag index entry:
# First request — expect MISS
curl -sI https://shop.example.com/products/9472 \
| grep -iE 'cf-cache-status|cache-tag|age'
# Second request — expect HIT, Age > 0
curl -sI https://shop.example.com/products/9472 \
| grep -iE 'cf-cache-status|age'
The CF-Cache-Status header reports Cloudflare’s own cache decision independently of your origin’s headers — MISS on the first call, HIT on the second, with Age incrementing on each subsequent request. If it never transitions to HIT, purging by tag will silently no-op later, because there is nothing indexed to evict.
Step 3 — Create a scoped API token and locate your zone ID
Generate a token in the Cloudflare dashboard under My Profile → API Tokens with the Zone → Cache Purge → Edit permission, scoped to the specific zone. Avoid using your Global API Key for automation — a scoped token limits blast radius if it leaks.
Look up the numeric zone_id if you don’t already have it cached in your deploy config:
curl -s "https://api.cloudflare.com/client/v4/zones?name=shop.example.com" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
| jq -r '.result[0].id'
Store the returned ID (a 32-character hex string) in your deployment secrets alongside the token — every purge call needs both.
Step 4 — POST to purge_cache with a tags array
The purge endpoint is POST /zones/{zone_id}/purge_cache. The request body carries a tags array; Cloudflare treats every tag as an exact string match against indexed Cache-Tag values:
curl -sX POST "https://api.cloudflare.com/client/v4/zones/023e105f4ecef8ad9ca31a8372d0c353/purge_cache" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"tags": ["product-9472", "category-footwear"]
}'
A successful call returns:
{
"result": { "id": "023e105f4ecef8ad9ca31a8372d0c353" },
"success": true,
"errors": [],
"messages": []
}
Two limits matter here. First, a single request accepts at most 30 tags (the same ceiling applies to purge-by-hostname and purge-by-prefix requests) — exceeding it returns a 400 without purging anything, so batch large invalidation jobs into multiple sequential calls rather than one oversized array. Second, tags, files, hosts, and prefixes are mutually exclusive purge types: a single JSON body may only contain one of these keys. Sending {"tags": [...], "files": [...]} in the same request also returns a 400.
Handle the common failure responses explicitly in your deploy script:
# 400 — malformed body or purge-type conflict
{"success": false, "errors": [{"code": 1004, "message": "Invalid or missing purge type"}]}
# 403 — token lacks Cache Purge permission for this zone
{"success": false, "errors": [{"code": 10000, "message": "Authentication error"}]}
# 429 — rate limit exceeded for the zone's purge API quota
{"success": false, "errors": [{"code": 1015, "message": "You are being rate limited"}]}
On a 429, back off with exponential delay and retry — do not fire the same request in a tight loop, since repeated retries against a rate-limited endpoint only extend the window before you’re unblocked. On a 403, regenerate the token with the correct zone scope rather than falling back to the Global API Key.
Step 5 — Verify the purge with CF-Cache-Status
Immediately re-request the purged URL. The very next response should show a miss with Age: 0, proving the prior entry was evicted rather than merely marked stale:
# Immediately after the purge call — expect MISS, Age: 0
curl -sI https://shop.example.com/products/9472 \
| grep -iE 'cf-cache-status|age'
CF-Cache-Status: MISS
Age: 0
Wait a few seconds and repeat the request — it should flip back to HIT as the edge repopulates from a fresh origin fetch:
sleep 3
curl -sI https://shop.example.com/products/9472 \
| grep -iE 'cf-cache-status|age'
If a second URL shares the category-footwear tag (say, the category listing page), check it too — it should also show MISS immediately after the purge, confirming the tag covered every URL you expected. For a side-by-side look at how this compares to Fastly’s key model and delimiter conventions, see surrogate-key vs cache-tag across CDN vendors.
Expected Output / Verification
A correctly executed purge exhibits all of the following:
purge_cachereturns"success": truewith an emptyerrorsarray, and the response echoes the zoneidyou targeted.- The next request to any URL carrying the purged tag returns
CF-Cache-Status: MISSandAge: 0— notEXPIRED, which would indicate the entry aged out naturally rather than being force-evicted. - A follow-up request within seconds returns
CF-Cache-Status: HITwith a small, growingAge, confirming the edge repopulated from origin rather than continuing to miss. - Every URL sharing the purged tag shows the same MISS-then-HIT pattern. If one URL still shows a stale
HITwith a largeAge, that response’s origin path likely omitted the tag on that code branch.
Edge Cases
- Comma vs. space delimiters — Cloudflare’s
Cache-Tagis comma-delimited; Fastly and Varnish use space-delimitedSurrogate-Key. If your origin serves both CDNs (blue/green migration, multi-CDN failover), generate the header per vendor rather than reusing one string — see Fastly surrogate-key invalidation patterns for the space-delimited equivalent. - WAF or Transform Rule stripping the tag before Cloudflare’s cache layer sees it — tag header removal for browsers should happen at the response-egress stage, after the cache lookup and store, not before. Ordering a strip rule too early prevents indexing entirely.
- Propagation latency across PoPs — a purge broadcasts globally but does not land at every PoP in the same millisecond. Do not assume instant global consistency for tightly-timed release choreography; allow a short buffer before declaring a rollout complete.
- Purging an object that was never cached — the API still returns
"success": truewith zero effective evictions. This is expected, not an error; it typically means the origin omittedCache-Tagon that response, or the object aged out before the purge ran.
Frequently Asked Questions
Do I need an Enterprise plan to purge by Cache-Tag?
Yes. Cache-Tag purging is available only on Enterprise zones. Lower-tier plans can still purge by exact file URL or purge the entire zone, but neither the Cache-Tag header nor tag-scoped purge_cache calls are honored outside Enterprise.
What is the maximum number of tags per purge_cache call?
30 tags per request — the same ceiling Cloudflare applies to purge-by-hostname and purge-by-prefix calls. Split larger invalidation jobs into multiple sequential requests instead of exceeding the array size, since an oversized array is rejected outright.
Can I combine tag purge and file purge in the same API call?
No. tags, files, hosts, and prefixes are mutually exclusive keys in the purge_cache body. Sending more than one in a single request returns a 400 error; issue separate calls for each purge type.
What does a 429 response from purge_cache mean?
The zone has exceeded Cloudflare’s purge API rate limit. Retry with exponential backoff and consolidate multiple logical tags into fewer, larger requests (up to the 30-tag ceiling) rather than issuing one call per tag.
Related
- How CDN cache keys are generated — the primary key structure that a
Cache-Tagindex sits alongside; understanding key fragmentation clarifies which objects a tag actually covers. - Origin shielding and request collapsing — shield nodes are shared caches in their own right and must receive purge propagation, not just the outer edge PoPs.
- How to read and interpret the Age header — the diagnostic signal used throughout this workflow to confirm eviction and repopulation.
- Cache-Control best practices for REST APIs — applies the same tagging discipline to JSON API responses invalidated by this workflow.
Back to Surrogate Keys and Cache Tags