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:

The purge path from content change to cold entryThe application publishes the change, calls the zone purge endpoint with the tag, and the edge drops every entry carrying it.CMS saveproduct updatedwebhookPurge callPOST /purge_cachetags: [...]Zone indextag expandedfan-outEntries evictednext request is a MISS
Four steps from a content save to an evicted edge entry

Step-by-Step Resolution

Purge API responses and what each one meansThe four status codes the purge endpoint returns in practice, with the cause and the correct response for each.MeaningWhat to do200queued for the zoneverify with a follow-up request400malformed body or mixed purge typessend one purge type per call403token lacks Cache Purge on this zonereissue the token with the right scope429zone purge quota exceededbatch tags and back off exponentially
Reading the purge endpoint's status codes

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:

Confirming the purge actually landedThe two captures that prove the eviction happened: a warm hit before, and a cold miss immediately after.before and after the purge callCF-Cache-Status: HIT Age: 3421--- purge tags: ["product-9472"] ---CF-Cache-Status: MISS Age: 0The entry was warm and had been stored for nearly an hour.Age reset to zero with a MISS confirms the entry was dropped, not merely revalidated.
Before and after, on the same URL
  • 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