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";
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 });
}
// Refresh just the pages this document affects.
revalidatePath(`/${body.publication}/${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.
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.