Skip to content

Purging from a CMS webhook

Goal: product descriptions live in a headless CMS. When an editor publishes a change, the product pages that show it are cleared within seconds, without waiting for their lifetime to run out.

A publish in the CMS never passes through GraphPilot, so automatic invalidation does not see it. The CMS has to say what changed, and a small handler turns that into a purge.

The running example is shop-api. Product carries @surrogateKey(of: "id"), so every cached response that shows product p01 is tagged Product:id:p01. The CMS stores each product’s copy under the same id and calls a webhook on every publish with a body such as {"entry": {"productId": "p01"}}.

  • An organization API key with the purge permission and nothing else, created in the portal under Settings → API keys (see API keys and permissions). Store it where the handler runs, for example as GRAPHPILOT_API_KEY.
  • A place to run the handler: a serverless function, or a route in an app you already run.
  • A CMS that can call a webhook on publish.

Read the key off a real response rather than building it by hand. With response.surrogate_key_header switched on:

Terminal window
curl -sS -D- -o /dev/null "https://$GRAPHPILOT_HOST/graphql" \
-H 'content-type: application/json' \
-d '{"query":"query ProductPage { product(id: \"p01\") { id name } }"}' \
| grep -i '^gp-surrogate-key:'
gp-surrogate-key: ck:… q:product Product Query catalogue Product:category.id:audio Product:id:p01

Product:id:p01 is the key to purge when the copy of p01 changes.

purge-on-publish.ts
export async function handlePublish(request: Request): Promise<Response> {
// Check the CMS's webhook signature here, before anything else.
const { entry } = await request.json();
const response = await fetch("https://api.graphpilot.io/purge", {
method: "POST",
headers: {
authorization: `Bearer ${process.env.GRAPHPILOT_API_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({
serviceId: "shop-api",
keys: [`Product:id:${entry.productId}`],
soft: true,
}),
});
// Let the CMS retry on a failure: purging the same key again is safe.
return new Response(null, { status: response.ok ? 204 : 502 });
}

serviceId takes the service’s subdomain. soft: true marks the entries stale instead of removing them: with the stale window Product has, readers keep getting the old copy for the moment it takes to fetch the new one. Use a hard purge (leave soft out) when the old copy must not be shown again, such as a wrong price.

A purge that ran answers 200 with the keys it covered:

{ "success": true, "purgedKeyCount": 1, "purgedKeys": ["Product:id:p01"] }

purgedKeyCount counts keys, not cached responses: a key nothing was stored under still counts. A refused purge answers with a status that matches its extensions.code; the list is under over REST.

Publish a change to p01 in the CMS, then ask for the product page again:

Terminal window
curl -sS -D- -o /dev/null "https://$GRAPHPILOT_HOST/graphql" \
-H 'content-type: application/json' \
-d '{"query":"query ProductPage { product(id: \"p01\") { id name } }"}' \
| grep -i '^gp-cache:'

After a soft purge the first answer is gp-cache: STALE, served from the old copy while GraphPilot fetches the new one; the next is a HIT with the new copy. After a hard purge it is a MISS.

The service’s Purges page lists the purge with the trigger Manual and the outcome Marked 1 key stale. Actor is empty, because an API key sent it. To find the purges of one product, filter by Key with Product:id:p01. A purge takes effect at once and can take up to a minute to appear in the list.

  • One product’s key does not reach everything that mentions it. A purge matches keys exactly, type name included. A cached review list tagged Review:productId:p01 is not reached by Product:id:p01; name both when the CMS change affects both.
  • A new product does not appear in a cached list on its own. No cached list contains it yet, so its key clears nothing. Purge a key the list carries as well; see lists and creates.
  • Bulk publishes need batching. One call takes at most 1000 keys. Split a larger import into several calls rather than purging everything.
  • No soft purge for everything. {"all": true} is always hard and sends every next request to your origin. Prefer keys.