Reference · Guides

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

  1. 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.

  2. Add a webhook endpoint

    Create a POST route on your site (e.g. /api/revalidate) and set its URL as the publication’s revalidateUrl.

  3. Store the secret

    Copy the secret shown once on create/rotate into an env var named for the publication: ESY_REVALIDATE_SECRET_<SLUG>.

  4. 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:

request bodyjson
{
  "publication": "esy-research",
  "slug": "the-economics-of-desalination",
  "action": "publish",
  "categories": ["policy", "energy"]
}

…and three signature headers:

request headershttp
webhook-id:        msg_8f0c2a1e4b7d4c9a
webhook-timestamp: 1782459381
webhook-signature: v1,K8c9…base64…3dA=
HeaderMeaning
webhook-idUnique id for this delivery (use it to de-dupe if you want).
webhook-timestampUnix seconds when Esy signed it — drives the replay window.
webhook-signatureSpace-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:

what gets signedtext
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.

lib/verify-webhook.tsts
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.tsts
// 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 });
}
Read the raw body before parsing

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.

Map to your section path, not the slug

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.

environmentbash
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.

SymptomCause & fix
The secret looks right, but every delivery 401sOn 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 rotateRotating 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 redirectIf your site sets trailingSlash, point revalidateUrl at the canonical form (…/api/revalidate/). Otherwise each POST 308-redirects, which some clients don’t replay cleanly.
A failed delivery never blocks a publish

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”.