This spec is everything an indexer needs to call
POST /cache/invalidate on the ENS-metadata service so image and resolver
caches get purged when an ENS record changes.
The endpoint purges:
- KV resolver cache — avatar + header URI entries for the name.
- R2 generated-image cache — the rendered name-image SVG + PNG for the token (all cache versions).
- Cloudflare edge cache — via purge-by-tag, across every variant (avatar, header, name image SVG + PNG) in one call.
One call per record change is sufficient. You do not need to pick a specific asset to invalidate — the endpoint wipes everything tied to the name.
See also:
POST /cache/preload— the inverse endpoint that re-warms R2 + the edge cache so the first user after an invalidation gets a hot response.
Call the endpoint whenever a record you indexed could affect one of our cached assets. The pragmatic set:
TextChangedwith key ∈{avatar, header}— obvious cache invalidation.AddrChanged— needed when the name uses an NFT-based avatar whose ownership check now resolves differently.NameChanged/NameRegistered/NameWrapped/NameUnwrapped— the name-image renders the label text; a change in the wrapped/ unwrapped state or the label itself makes the cached tile stale.
If it's cheaper to invalidate on any record change for a name than to filter, do that — the endpoint is idempotent and rate-limit friendly (see below). Over-invalidation just means one extra re-render on the next request.
POST https://<service-host>/cache/invalidate
Authorization: Bearer <CACHE_INVALIDATION_TOKEN>
Content-Type: application/json
Service host depends on the environment you're hitting (workers.dev subdomain for preview, custom domain for prod). On the workers.dev preview the KV + R2 deletes still run; only the CF-edge tag purge is a no-op there (there is no zone), so don't rely on preview for end-to-end verification of the CDN purge.
items— 1–100 entries per call. Batch aggressively when possible; CF rate-limits tag purges (see below).network— required. One ofmainnet,sepolia,holesky.name— optional. Fully-qualified ENS name, lowercased, normalized. e.g."foo.eth"or"sub.foo.eth".tokenId— optional. Decimal or0x-hex token id from the NFT contract that fired the event. When sent withoutcontract, the service tries every contract in itsTOKEN_CONTRACTSlist against this tokenId — so the indexer doesn't need to know whether the event came from the base registrar or the name wrapper.contract— optional.0x-prefixed EVM address of a specific NFT contract. Only needed when you want to narrow invalidation to that one contract; otherwise omit it. RequirestokenId.
Each item must contain name or tokenId (or both). Missing both
is rejected with 400; sending contract without tokenId is also
rejected. If both name and tokenId are provided, invalidation is
maximally precise. Name-only items derive a tokenId per contract
(labelhash for the base registrar, namehash for the name wrapper).
Extra deletes on (contract, tokenId) pairs that don't actually hold
entries are harmless no-ops.
200 OK:
{
"ok": true,
"tags_purged": 4,
"kv_deleted": 6,
"r2_deleted": 4,
"items": [
{
"network": "mainnet",
"name": "foo.eth",
"kv_deleted": 2,
"r2_deleted": 2,
"tags": ["ens:mainnet:name:foo.eth"]
}
]
}tags_purged— unique tags sent to Cloudflare's purge API.kv_deleted— sum of KV keys deleted across items.r2_deleted— sum of R2 generated-image entries deleted across items.items[*]— per-item breakdown;tags[]shows the exact tags sent.
| Status | Body shape | When |
|---|---|---|
400 |
{ "error": "bad_request", "message": "..." } |
Item fails shape check, unknown network, or invalid contract address. |
401 |
{ "error": "unauthorized", "message": "unauthorized" } |
Missing or wrong bearer token. |
502 |
{ "error": "purge_failed", "message": "cloudflare purge failed (…): …" } |
Cloudflare's /zones/.../purge_cache returned an error. KV + R2 deletes already ran — safe to retry; they're idempotent. |
503 |
{ "error": "not_configured", "message": "<VAR> not configured" } |
The service hasn't set CACHE_INVALIDATION_TOKEN / CF_API_TOKEN / CF_ZONE_ID. Retry once the ops side confirms config. |
The indexer doesn't pick tags — the service derives them. But for debugging or direct-CF purges, the format is:
ens:{network}:name:{urlEncodedLowercaseName}ens:{network}:token:{lowercaseContract}:{lowercaseTokenHex}
Example: ens:mainnet:name:foo.eth,
ens:mainnet:token:0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85:0x….
All responses from /mainnet/avatar/{name}, /mainnet/header/{name},
and /mainnet/{contract}/{tokenId}/image[.png] carry these tags in the
Cache-Tag header, which Cloudflare strips before the response reaches
the client.
Cloudflare caps tag-purge requests per zone (per CF's docs):
| Plan | Rate | Bucket | Max tags per call |
|---|---|---|---|
| Free | 5 / min | 25 | 100 |
| Pro | 5 / sec | 25 | 100 |
| Business | 10 / sec | 50 | 100 |
| Enterprise | 50 / sec | 500 | 100 |
Indexer strategy:
- Batch. Collect tags over a short window (e.g. 1–5 s) and send a single call with up to 100 items. Most record-change bursts (e.g. a single tx touching multiple records on one name) collapse to one network call.
- De-duplicate. Dedupe items by the tuple you sent —
(network, name),(network, tokenId), or(network, contract, tokenId)— before flushing. - Back off. On
502(CF purge failure) or429, use exponential backoff starting at 1 s, max ~60 s. The KV + R2 deletes are already done, so backoff only delays the CDN purge. - Don't block indexing. The invalidation call should be best-effort
and async relative to the indexer's write path. Worst case a failed
purge leaves stale CDN entries for the
Cache-Control: max-agewindow (15 min for avatar/header, 1 year for name images — but those are specifically the ones R2 already wiped, so the next request re-renders fresh).
const ENDPOINT = process.env.METADATA_INVALIDATION_URL!; // e.g. "https://metadata.ens.…/cache/invalidate"
const TOKEN = process.env.METADATA_INVALIDATION_TOKEN!; // same value as CACHE_INVALIDATION_TOKEN on the service
type Item = {
network: "mainnet" | "sepolia" | "holesky";
name?: string;
contract?: string;
tokenId?: string;
};
export async function invalidateCache(items: Item[]): Promise<void> {
if (items.length === 0) return;
for (let i = 0; i < items.length; i += 100) {
const chunk = items.slice(i, i + 100);
const res = await fetch(ENDPOINT, {
method: "POST",
headers: {
authorization: `Bearer ${TOKEN}`,
"content-type": "application/json",
},
body: JSON.stringify({ items: chunk }),
});
if (res.status === 401 || res.status === 503) {
throw new Error(`invalidation config problem: ${res.status} ${await res.text()}`);
}
if (!res.ok) {
const body = await res.text();
throw new Error(`invalidation failed (${res.status}): ${body}`);
}
}
}const PENDING: Item[] = [];
let flushTimer: NodeJS.Timeout | null = null;
export function enqueueInvalidation(item: Item): void {
PENDING.push(item);
if (!flushTimer) flushTimer = setTimeout(flush, 2_000);
}
async function flush(): Promise<void> {
flushTimer = null;
if (PENDING.length === 0) return;
const batch = dedupe(PENDING.splice(0, PENDING.length));
try {
await invalidateCache(batch);
} catch (err) {
// Re-enqueue with capped retry — don't lose invalidations on transient failures.
console.error("invalidation batch failed, re-queueing", err);
for (const it of batch) PENDING.push(it);
if (!flushTimer) flushTimer = setTimeout(flush, 30_000);
}
}
function dedupe(items: Item[]): Item[] {
const seen = new Set<string>();
return items.filter((i) => {
const key = `${i.network}|${i.name ?? ""}|${i.contract ?? ""}|${i.tokenId ?? ""}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}Two env vars on the indexer:
METADATA_INVALIDATION_URL— full URL to/cache/invalidateon the target environment.METADATA_INVALIDATION_TOKEN— bearer token. Same value the service has configured asCACHE_INVALIDATION_TOKEN(stored viawrangler secret puton the service side).
Rotate the token by coordinating a change on both sides — rotate the
service secret first, then update the indexer. During the ~seconds gap,
the indexer gets 401s; retries succeed after the indexer's new token
is deployed.
- Pick a name you know the service renders (e.g. one with an avatar).
curl -I https://<service>/mainnet/avatar/<name>— note theetag.curl -I https://<service>/mainnet/<contract>/<tokenId>/image— note thecf-cache-statusheader. First request is usuallyMISS; the second isHIT.- Call
POST /cache/invalidatefor that name. - Re-issue step 3 —
cf-cache-statusshould go back toMISS(CF evicted the entry; our Worker re-renders from fresh data). - If using workers.dev preview: step 5 will not show
MISSfor the CDN layer (no zone), but the R2 cache was deleted so the re-render still happens at the Worker layer.
- Shared content-addressed caches (
ipfs/*,https/*in R2) are not purged. They're keyed by content hash, so a changed URL naturally resolves to a different cache entry. - Manual per-asset purges are not supported — use the CF dashboard or CF API directly if you need that.
- On a
*.workers.devhost, Cache-Tag headers are emitted but CF has no zone to index them against. Tag purge is effectively a no-op on preview deploys. Use a custom-domain deploy to exercise the full flow. - CF's tag-purge reflects eventually consistent across colos; new
requests globally may serve stale for a few seconds after purge
returns
200. Design retries and SLAs with this in mind.
{ "items": [ // Preferred shape when the indexer sees a token event — contract is // inferred from the service's known-contract list. { "network": "mainnet", "tokenId": "123" }, // Name-only — useful when you only know the name from the event. { "network": "mainnet", "name": "foo.eth" }, // Name + tokenId — both signals, fully precise. { "network": "mainnet", "name": "bar.eth", "tokenId": "456" }, // Targeted at a single contract — only when you want to narrow // invalidation; typically unnecessary. { "network": "mainnet", "contract": "0xD4416b13d2b3a9aBae7AcD5D6C2BbDBE25686401", "tokenId": "789" } ] }