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";

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 });
}
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.

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.