---
title: "On-demand ISR revalidation with signed webhooks"
description: "Verify an HMAC-signed webhook in a Next.js route, purge the page and the lists that show it, and always answer 200, plus a crash we found in ours."
url: "https://glokki.com/blog/on-demand-isr-revalidation-with-signed-webhooks"
locale: "en"
updated: "2026-11-02T22:29:53.961Z"
---

# On-demand ISR revalidation with signed webhooks

glokki team · Oct 31, 2026

Static pages that update the moment an editor hits publish: verifying an HMAC-signed webhook in a Next.js route, what to purge besides the page itself, why to always answer 200, and a crash we found in our own handler.

glokki.com is served with ISR: every page is rendered once, cached at the
edge, and refreshed in the background at most every 60 seconds. Visitors get
static-site speed, but an editor who publishes a fix could wait up to a
minute to see it.

On-demand revalidation removes that wait. The CMS calls your site when
something is published, and your site purges exactly what changed. Here's
the core of our handler, and one bug we found in it while writing this.

## The setup

Two pieces work together:

- **A time-based fallback.** The catch-all page route exports
  `revalidate = 60`. If a webhook is ever lost, pages still catch up within
  a minute.
- **A webhook.** In the CMS, **Settings → Visual Editor → Revalidation URL**
  points at `https://glokki.com/api/revalidate`, with a shared secret. On
  every publish, the CMS sends a signed `POST` with the page's slug.

## Step 1: verify the signature

Anyone can send a `POST` to a public URL, so the handler first checks that
the request came from the CMS. Each request carries two headers:
`X-Editor-Timestamp` and `X-Editor-Signature`, an HMAC-SHA256 of
`<timestamp>.<raw body>` with the shared secret.

```ts
const timestamp = request.headers.get("X-Editor-Timestamp")
const signature = request.headers.get("X-Editor-Signature")
if (!timestamp || !signature) {
  return Response.json({ error: "Missing signature headers" }, { status: 401 })
}

const body = await request.text() // the raw body, before any JSON parsing
const expected = crypto
  .createHmac("sha256", secret)
  .update(`${timestamp}.${body}`)
  .digest("hex")

const signatureBuf = Buffer.from(signature)
const expectedBuf = Buffer.from(expected)
if (
  signatureBuf.length !== expectedBuf.length ||
  !crypto.timingSafeEqual(signatureBuf, expectedBuf)
) {
  return Response.json({ error: "Invalid signature" }, { status: 401 })
}
```

Two details matter:

- **Sign the raw body.** Parse JSON and serialize it again, and key order or
  whitespace can change, so the signature stops matching. Read
  `request.text()` once, verify it, then `JSON.parse` it.
- **Compare in constant time, and check the length first.** `===` on
  strings can leak how many leading characters matched.
  `crypto.timingSafeEqual` doesn't, but it **throws** when the two buffers
  differ in length.

That second point is the bug. Our handler called `timingSafeEqual` without
the length check, so any request with a signature of the wrong length crashed
it, and glokki.com answered `500` instead of `401`. Nothing was ever purged
by a bad request, so it wasn't a security hole. It was a crash any visitor
could trigger, and noise in the logs. The integration guide had the check
all along; our copy had lost it. A test now covers it:

```ts
it("rejects a signature of the wrong length with 401, not a crash", async () => {
  const res = await POST(request(JSON.stringify({ slug: "a" }), "short"))
  expect(res.status).toBe(401)
})
```

## Step 2: purge what changed, and what lists it

```ts
const { slug } = JSON.parse(body) as { slug?: string }
const path = slug ? `/${slug}` : "/"

revalidateTag("cms-pages", "default") // every CMS fetch made by the SDK client
revalidatePath(path)                   // the page itself
if (/^\/+blog\//.test(path)) revalidatePath("/blog") // the index that lists it
```

The last line is the one that's easy to miss. Publishing a post changes the
post, but also every page that **lists** posts, and the webhook only names
the post. Without that line, a new post would reach `/blog` up to a minute
late. If you have index pages, related-post lists or a home page that shows the
latest items, purge them too.

## Step 3: always answer 200 to what you don't handle

The revalidation URL is the CMS's one webhook to your site, and it carries
more than page publishes, e.g. cache-tag invalidations and data-model
purges, all signed the same way. The CMS treats any non-2xx answer as a failed delivery and tries again with exponential backoff, up to five attempts in total.

So a handler that returns `400` for "no slug in this payload" gets the same message five times, though it was never going to use it. Ours lets those payloads
fall through to the tag purge and answers `200`. The test suite checks that
as well.

## What we don't do (yet)

- **Reject old timestamps.** The timestamp is signed, so it can't be
  changed, but we don't refuse a valid request replayed an hour later. Here
  the worst a replay can do is purge a page early, so we've left it out. For
  a webhook with side effects, refuse timestamps older than a few minutes.
- **Purge by tag per page.** `cms-pages` purges every CMS fetch at once. For
  a site our size that's cheaper than tracking which page uses which data.
  On a large site, tag fetches per page and purge only what changed.

## Try it

`curl` is enough to see the signature check work:

```bash
curl -i -X POST https://your-site.com/api/revalidate \
  -H "X-Editor-Timestamp: 1" -H "X-Editor-Signature: nope" -d '{}'
# → 401 {"error":"Invalid signature"}
```

The full handler and the CMS settings are in the
[integration guide](/docs/integration#11-on-demand-revalidation).

To see a publish reach your site without the 60-second wait,
[create a free account](https://app.glokki.com/register?utm_source=blog&utm_campaign=isr-webhooks).

# More from the blog

## Publish something that shows up instantly

Free during beta · No credit card required

[Create free account](https://app.glokki.com/register?utm_source=blog&utm_campaign=isr-webhooks)

[Read the docs](https://glokki.com/docs/integration)
