Connect a consumer site
Render a public publication on your own site, and verify the revalidation webhooks Esy sends when content changes. Verification uses an HMAC signature so the shared secret never travels on the wire.
Steps
- Read the public API
Fetch a publication’s published documents from
GET /v1/publications/public/{slug}/articles(no auth) and render them. See the Publications API. - Add a webhook endpoint
Create a POST route on your site (e.g.
/api/revalidate) and set its URL as the publication’srevalidateUrl. - Store the secret
Copy the secret shown once on create/rotate into an env var named for the publication:
ESY_REVALIDATE_SECRET_<SLUG>. - Verify every request
Recompute the HMAC signature and reject anything that does not match.
What Esy sends
On publish or unpublish, Esy POSTs your endpoint with a generic JSON body:
{
"publication": "esy-research",
"slug": "the-economics-of-desalination",
"action": "publish",
"categories": ["policy", "energy"]
}…and three signature headers:
webhook-id: msg_8f0c2a1e4b7d4c9a
webhook-timestamp: 1782459381
webhook-signature: v1,K8c9…base64…3dA=| Header | Meaning |
|---|---|
webhook-id | Unique id for this delivery (use it to de-dupe if you want). |
webhook-timestamp | Unix seconds when Esy signed it — drives the replay window. |
webhook-signature | Space-separated list of v1,<base64 HMAC-SHA256> (more than one during secret rotation). |
The signature
Esy signs the exact string {id}.{timestamp}.{rawBody} with your publication’s secret:
signature = base64( HMAC_SHA256( secret, "{webhook-id}.{webhook-timestamp}.{rawBody}" ) )Verify it
Recompute the signature with your copy of the secret and compare in constant time. Reject if the timestamp is more than five minutes old.
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
// Pick the secret for the publication that sent this webhook. Keep one env var
// per publication so adding a new one never touches the others.
function secretsFor(publication: string): string[] {
const key = `ESY_REVALIDATE_SECRET_${publication.toUpperCase().replace(/-/g, "_")}`;
return [process.env[key]].filter(Boolean) as string[];
}
export function verify(rawBody: string, headers: Headers, secrets: string[]): boolean {
const id = headers.get("webhook-id");
const timestamp = headers.get("webhook-timestamp");
const signature = headers.get("webhook-signature");
if (!id || !timestamp || !signature || secrets.length === 0) return false;
// Replay guard: reject anything signed too far from now.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const signed = `${id}.${timestamp}.${rawBody}`;
const presented = signature.split(" ").map((p) => (p.startsWith("v1,") ? p.slice(3) : p));
for (const secret of secrets) {
const expected = crypto.createHmac("sha256", Buffer.from(secret, "utf8")).update(signed).digest("base64");
for (const candidate of presented) {
const a = Buffer.from(expected), b = Buffer.from(candidate);
if (a.length === b.length && crypto.timingSafeEqual(a, b)) return true;
}
}
return false;
}// app/api/revalidate/route.ts (Next.js)
import { revalidatePath } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
import { verify, secretsFor } from "@/lib/verify-webhook";
// Map each publication you serve to ITS route segment on this site. The segment
// is your section path (e.g. "learn"), NOT the publication slug ("esy-learn").
// Adding a destination is one line here.
const SECTION_PATHS: Record<string, string> = {
"esy-learn": "learn",
// "seopage-blog": "blog",
};
export async function POST(request: NextRequest) {
// Read the RAW body first — HMAC must hash the exact bytes Esy signed.
const rawBody = await request.text();
const body = JSON.parse(rawBody);
if (!verify(rawBody, request.headers, secretsFor(body.publication))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Resolve this site's path for the publication, then refresh just the pages
// it affects (the section list + the document). An unknown publication is
// rejected, not silently ignored.
const section = SECTION_PATHS[body.publication];
if (!section) {
return NextResponse.json({ error: "Unknown publication" }, { status: 400 });
}
revalidatePath(`/${section}`);
revalidatePath(`/${section}/${body.slug}`);
return NextResponse.json({ revalidated: true });
}The signature covers the exact bytes Esy sent. If you call request.json() first and re-serialize, key order or spacing can differ and the signature won’t match. Always read request.text() and parse that string.
The webhook payload’s publication is the slug (e.g. esy-learn) — a stable id, not a URL. Revalidate your route segment (the publication’s sectionPath, e.g. /learn), which you control on the consumer. Using the slug as the path is the usual cause of a wrong or doubled URL like /esy-learn/… or /learn/learn/….
One env var per publication
If your site serves several publications, give each its own secret. Adding a new publication is then just a new variable — no risk to the existing ones.
ESY_REVALIDATE_SECRET_ESY_RESEARCH=…
ESY_REVALIDATE_SECRET_ESY_LEARN=…Confirm it works
Use POST /v1/publications/{id}/verify (or the Connect panel’s “Verify connection”) to send a no-op test webhook. A 200 means your endpoint received and verified it; the result is recorded as delivery health on the publication.
Operating it
Three things trip up most first connections — all on the consumer side, and all reported as a failed delivery on the publication.
| Symptom | Cause & fix |
|---|---|
| The secret looks right, but every delivery 401s | On most hosts (Vercel included) env vars apply at build time. After setting ESY_REVALIDATE_SECRET_<SLUG>, redeploy — a running deployment keeps the old value. |
| Deliveries 401 right after a rotate | Rotating in Compose mints a new secret and the old one stops verifying immediately. Update the consumer’s env to the new value and redeploy. |
| Every delivery shows a redirect | If your site sets trailingSlash, point revalidateUrl at the canonical form (…/api/revalidate/). Otherwise each POST 308-redirects, which some clients don’t replay cleanly. |
Webhooks are best-effort: a rejected ping is recorded in delivery health but never rolls back a valid publish. Your reader’s normal cache window still picks the change up — the webhook just makes it instant. Fix the secret/redeploy and re-run “Verify connection”.