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:

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_cache returns "success": true with an empty errors array, and the response echoes the zone id you targeted.
  • The next request to any URL carrying the purged tag returns CF-Cache-Status: MISS and Age: 0 — not EXPIRED, which would indicate the entry aged out naturally rather than being force-evicted.
  • A follow-up request within seconds returns CF-Cache-Status: HIT with a small, growing Age, 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 HIT with a large Age, that response’s origin path likely omitted the tag on that code branch.

Edge Cases

  • Comma vs. space delimiters — Cloudflare’s Cache-Tag is comma-delimited; Fastly and Varnish use space-delimited Surrogate-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": true with zero effective evictions. This is expected, not an error; it typically means the origin omitted Cache-Tag on 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.


Back to Surrogate Keys and Cache Tags