Developer Demo

Webhooks & On-Demand Revalidation

How Optimizely Graph tells this app that content changed - so a publish in the CMS shows up on the site in seconds, not when the ISR TTL happens to expire. Covers registration, payload types, the receiver route, and testing locally.

The publish-to-revalidate loop#

Every CMS query in this app is cached by Next.js ISR with a TTL and a named tag (see Caching). The webhook is the push half of that strategy: instead of waiting out the TTL, Graph notifies the app the moment its index changes and the app invalidates the affected tags.

Publish flow, end to end
Editor clicks Publish in the CMS
        |
        v
CMS syncs the content item to Optimizely Graph
        |
        v
Graph finishes indexing and POSTs to every registered webhook:
  POST https://your-app.com/api/webhooks?secret=...
        |
        v
The route handler verifies the secret, then invalidates the ISR cache:
  revalidateTag("page")  revalidateTag("navigation")  ...
        |
        v
Next visitor request re-fetches from Graph and gets the new content

Without the webhook the site still updates - but only when each page's
ISR TTL (60s for pages, 300s for navigation) expires. The webhook makes
publishes visible in seconds instead of minutes.

Registering a webhook#

Webhooks are registered against Graph's management endpoint with Basic auth (OPTIMIZELY_APP_KEY / OPTIMIZELY_APP_SECRET). Registration controls only the URL and method - you can't attach custom headers - so this app appends its shared secret as a query parameter. The interactive script wraps this in a prompt for your public base URL.

POST https://cg.optimizely.com/api/webhooks
// scripts/register-webhook.mjs (run with: npm run webhook:register)
//
// Graph's webhook API authenticates with HTTP Basic auth using the
// OPTIMIZELY_APP_KEY / OPTIMIZELY_APP_SECRET pair (the same credentials
// used for the Content Source API - created in CMS Settings > API Keys).

const credentials = Buffer.from(`${APP_KEY}:${APP_SECRET}`).toString("base64");

await fetch("https://cg.optimizely.com/api/webhooks", {
  method: "POST",
  headers: {
    Authorization: `Basic ${credentials}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    disabled: false,
    request: {
      // Graph only lets you control the URL, not custom headers - so the
      // shared secret rides along as a query parameter.
      url: "https://your-app.com/api/webhooks?secret=<OPTIMIZELY_REVALIDATE_SECRET>",
      method: "post",
    },
    topic: ["*.*"],   // all events; narrow to e.g. ["doc.updated"] if preferred
  }),
});

// List registered webhooks at any time:
//   curl -u '<app-key>:<app-secret>' https://cg.optimizely.com/api/webhooks

Payload topics#

Graph publishes three topics. This app subscribes to all of them (topic: ["*.*"]) and treats every event the same way - bust all content tags - because any publish can affect navigation labels, the banner, or page content. A larger site could switch on the topic and revalidate more surgically.

bulk.completed / doc.updated / doc.expired
// The three payload topics Graph sends:
//
//   bulk.completed  - Graph finished processing a content sync
//                     (fires after a publish, and after seed scripts run)
//   doc.updated     - a single content item was updated in the index
//   doc.expired     - a content item reached its StopPublish date
//
// Example doc.updated body:
{
  "timestamp": "2026-07-02T09:41:00Z",
  "tenantId": "...",
  "topic": "doc.updated",
  "subject": {
    "type": "doc",
    "event": "updated",
    "docId": "abc123_en_published"
  }
}

The receiver route#

The handler does two things: verify the shared secret, then map the event to cache invalidations. Verification matters even on a demo - an unauthenticated receiver lets anyone repeatedly flush the ISR cache, forcing every request back to Graph. The tag names here are the same ones declared by each graphqlFetch call (next: { tags: ["page"] }), which is what makes targeted busting possible.

src/app/api/webhooks/route.ts
// src/app/api/webhooks/route.ts
export async function POST(request: NextRequest) {
  // 1. Verify the sender. Anyone who can POST here can flush the whole ISR
  //    cache, so the registered URL carries a shared secret. Deny by default.
  const secret =
    request.nextUrl.searchParams.get("secret") ??
    request.headers.get("x-revalidate-secret");

  if (secret !== process.env.OPTIMIZELY_REVALIDATE_SECRET) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  // 2. Invalidate by tag. Every graphqlFetch in the app declares the tag its
  //    data belongs to ("page", "navigation", "banner", "quotes") - the
  //    webhook busts them all because any publish can affect any of them.
  revalidatePath("/", "layout");
  revalidateTag("page");
  revalidateTag("navigation");
  revalidateTag("banner");
  revalidateTag("quotes");

  return NextResponse.json({ received: true, timestamp: Date.now() });
}

Testing locally#

Graph needs a public URL to deliver events, so local development requires a tunnel. You can also exercise the receiver directly with curl - useful for checking the 401 path before registering anything.

Tunnel + manual testing
# Graph can't reach localhost - expose your dev server with a tunnel first:
npx ngrok http 3000          # or: cloudflared tunnel --url http://localhost:3000

# Then register the tunnel URL:
npm run webhook:register
# > Enter your public base URL: https://<random>.ngrok-free.app

# Test the receiver by hand (should be 401 without the secret):
curl -X POST http://localhost:3000/api/webhooks
curl -X POST "http://localhost:3000/api/webhooks?secret=$OPTIMIZELY_REVALIDATE_SECRET" \
  -H "Content-Type: application/json" -d '{"topic":"doc.updated"}'

Key Things to Know#

  • Webhooks are the push half of the caching strategy. ISR TTLs guarantee eventual freshness; the webhook makes a publish visible in seconds.
  • Registration is URL-only. Graph won't send custom headers, so authenticate the receiver with a secret in the registered URL's query string.
  • Always verify the sender. An open receiver is a free cache-flush endpoint. Compare against OPTIMIZELY_REVALIDATE_SECRET and deny by default.
  • Revalidate by tag, not by path. Tags declared on each graphqlFetch let one webhook bust exactly the queries a publish affects.
  • Local dev needs a tunnel. Graph can't reach localhost - use ngrok or cloudflared, then re-run npm run webhook:register.
Source files2 files
src/app/api/webhooks/route.ts
import { type NextRequest, NextResponse } from "next/server";
import { revalidatePath, revalidateTag as _revalidateTag } from "next/cache";

// Next.js 16 requires a second `profile` arg in its types, but route handlers
// have no valid profile to pass (updateTag is Server Actions only). Cast to the
// single-arg overload so the runtime's immediate-invalidation path is used.
const revalidateTag = _revalidateTag as (tag: string) => void;

export async function GET() {
  return NextResponse.json({ ok: true });
}

/**
 * Optimizely Graph webhook receiver.
 *
 * Register this URL via `npm run webhook:register` — the script appends
 * ?secret=OPTIMIZELY_REVALIDATE_SECRET to the registered URL, since Graph
 * webhook registration only controls the URL, not custom headers.
 *
 * Webhook payload types:
 *   - bulk.completed  – Graph finished processing a content sync
 *   - doc.updated     – a single content item was updated
 *   - doc.expired     – a content item reached its StopPublish date
 */
export async function POST(request: NextRequest) {
  const secret =
    request.nextUrl.searchParams.get("secret") ??
    request.headers.get("x-revalidate-secret");

  if (secret !== process.env.OPTIMIZELY_REVALIDATE_SECRET) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  try {
    const body = await request.json();

    console.log("[Optimizely Graph Webhook] Event received:", body);

    revalidatePath("/", "layout");
    revalidateTag("page");
    revalidateTag("navigation");
    revalidateTag("banner");
    revalidateTag("footer");
    revalidateTag("settings");
    revalidateTag("quotes");

    return NextResponse.json({ received: true, timestamp: Date.now() });
  } catch (error) {
    console.error("[Webhook] Failed to parse body:", error);
    return NextResponse.json({ error: "Failed to parse body" }, { status: 400 });
  }
}
scripts/register-webhook.mjs
/**
 * Registers an Optimizely Graph webhook pointing to /api/webhooks.
 *
 * Usage:
 *   npm run webhook:register
 *
 * You will be prompted for your public base URL (e.g. https://your-app.vercel.app
 * or an ngrok tunnel for local testing).
 *
 * Credentials are read from .env.local:
 *   OPTIMIZELY_APP_KEY
 *   OPTIMIZELY_APP_SECRET
 *   OPTIMIZELY_REVALIDATE_SECRET (appended to the URL so /api/webhooks can verify the sender)
 */

import { createInterface } from "readline";
import { config } from "dotenv";

config({ path: ".env.local" });

const APP_KEY = process.env.OPTIMIZELY_APP_KEY;
const APP_SECRET = process.env.OPTIMIZELY_APP_SECRET;

if (!APP_KEY || !APP_SECRET) {
  console.error(
    "Error: OPTIMIZELY_APP_KEY and OPTIMIZELY_APP_SECRET must be set in .env.local"
  );
  process.exit(1);
}

const rl = createInterface({ input: process.stdin, output: process.stdout });
const question = (prompt) =>
  new Promise((resolve) => rl.question(prompt, resolve));

const baseUrl = await question(
  "Enter your public base URL (e.g. https://your-app.vercel.app): "
);
rl.close();

const REVALIDATE_SECRET = process.env.OPTIMIZELY_REVALIDATE_SECRET;
if (!REVALIDATE_SECRET) {
  console.error(
    "Error: OPTIMIZELY_REVALIDATE_SECRET must be set in .env.local — the receiver at /api/webhooks rejects unauthenticated calls"
  );
  process.exit(1);
}

const webhookUrl = `${baseUrl.trim().replace(/\/$/, "")}/api/webhooks?secret=${encodeURIComponent(REVALIDATE_SECRET)}`;
const credentials = Buffer.from(`${APP_KEY}:${APP_SECRET}`).toString("base64");

console.log(`\nRegistering webhook → ${webhookUrl}`);

const response = await fetch("https://cg.optimizely.com/api/webhooks", {
  method: "POST",
  headers: {
    Authorization: `Basic ${credentials}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    disabled: false,
    request: {
      url: webhookUrl,
      method: "post",
    },
    topic: ["*.*"],
  }),
});

const text = await response.text();

if (!response.ok) {
  console.error(`Failed to register webhook (HTTP ${response.status}):`, text);
  process.exit(1);
}

console.log("\nWebhook registered successfully!");
try {
  console.log(JSON.parse(text));
} catch {
  console.log(text);
}
console.log(
  "\nTo list all registered webhooks, run:\n" +
    "  curl -u '<app-key>:<app-secret>' https://cg.optimizely.com/api/webhooks"
);