BlogEngineering
On-demand ISR revalidation with signed webhooks
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 team3 min read

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 signedPOSTwith 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.
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, thenJSON.parseit. - Compare in constant time, and check the length first.
===on strings can leak how many leading characters matched.crypto.timingSafeEqualdoesn'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:
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
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-pagespurges 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:
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.
To see a publish reach your site without the 60-second wait, create a free account.