Webhooks
How to setup Opinly webhooks
What are webhooks?
Webhooks are how services notify each other of events.
At their core they are just a POST request to a pre-determined endpoint. The endpoint can be whatever you want, and you can just add them from the UI. You normally use one endpoint per service, and that endpoint listens to all of the event types.
What are webhooks used for with Opinly?
Currently, Opinly uses webhooks to notify you when your blog content has been updated.
This is useful if you want to invalidate your cache when your blog content has been updated rather than guessing when to revalidate.
Content events
Opinly emits content.routes-changed whenever content changes. The payload lists the
entities that changed — posts, categories, authors, tags, and the home page — each with a bare
slug and lastModified. You map each entity onto your own routes, exactly the way you already map
the entities returned by the SDK's routes() endpoint for your sitemap and static generation.
{
"type": "content.routes-changed",
"data": {
"changed": [
{ "type": "post", "slug": "my-post", "lastModified": "2026-07-06T12:00:00.000Z" },
{ "type": "category", "slug": "guides", "lastModified": "2026-07-06T12:00:00.000Z" },
{ "type": "author", "slug": "jane", "lastModified": "2026-07-06T12:00:00.000Z" },
{ "type": "tag", "slug": "seo", "lastModified": "2026-07-06T12:00:00.000Z" },
{ "type": "home", "slug": "", "lastModified": "2026-07-06T12:00:00.000Z" }
]
}
}Each entry has the same shape as an entry from routes() (type, bare slug, lastModified). A
home entry is included on any structural change — treat it as "revalidate your index and sitemap".
How to setup webhooks
We use Svix to handle webhooks.
-
Go to Settings → Developers
-
Add a new webhook
-
Add the endpoint URL. This is the URL of the endpoint you want to receive the webhook.
To test locally you can use:
-
Subscribe to
content.routes-changed -
Copy your webhooks and paste them into your
.envfile
OPINLY_WEBHOOK_SIGNING_SECRET=xxxxx- Add the following to your
app/api/opinly/route.tsfile
(or wherever you want to handle the webhook, just make sure it matches the endpoint URL you added in the previous step)
There are two caches to bust, and they need different tools:
- The data cache — your
fetchcalls to Opinly. OnerevalidateTag('opinly', { expire: 0 })drops all of them, which is why the client tags its fetches (see Backend client). Tags work regardless of whether the route rendering them is static or dynamic. - The rendered routes — the HTML/RSC payload for each page. Bust these with
revalidatePath.
Do both. revalidatePath alone is not enough: on a self-hosted/OpenNext deployment it is a silent
no-op for routes that render dynamically, because the tag→path mapping is only seeded for
prerendered routes at build time. Tagged fetches have no such limitation.
On Next.js 16+, revalidateTag's second argument is required, and it changes behaviour rather
than just silencing a warning. It supplies the expire passed to the cache handler:
{ expire: 0 }— drop the entries now. The next request refetches. Use this for a publish webhook: the post is live, so you want it visible, not queued behind a grace period.- A named profile like
'max'— keep serving the stale entries for up to that profile'sexpire('max'is 365 days) while refreshing behind them. Fine for data that may lag; wrong for "publish now".
updateTag is the other immediate option, but it throws outside a Server Action — so in a Route
Handler like this one, revalidateTag(tag, { expire: 0 }) is what you want.
// app/api/opinly/route.ts
import { Webhook } from "svix";
import { OpinlyWebhookEvent } from "@opinly/backend";
import { revalidatePath, revalidateTag } from "next/cache";
const BLOG_PREFIX = process.env.OPINLY_BLOG_PREFIX ?? "";
export async function POST(request: Request) {
const svix_id = request.headers.get("svix-id");
const svix_timestamp = request.headers.get("svix-timestamp");
const svix_signature = request.headers.get("svix-signature");
if (!svix_id || !svix_timestamp || !svix_signature) {
return new Response("Invalid request", { status: 400 });
}
const buf = Buffer.from(await request.arrayBuffer());
const wh = new Webhook(process.env.OPINLY_WEBHOOK_SIGNING_SECRET!);
let evt: OpinlyWebhookEvent;
try {
evt = wh.verify(buf, {
"svix-id": svix_id,
"svix-timestamp": svix_timestamp,
"svix-signature": svix_signature,
}) as OpinlyWebhookEvent;
} catch {
return new Response("Error verifying webhook", { status: 400 });
}
if (evt.type !== "content.routes-changed") {
// Always return a Response — a route handler that falls through returns
// `undefined` and errors at runtime.
return new Response("ok", { status: 200 });
}
// Anything changed means the cached API responses are stale. One tag, one call.
// `{ expire: 0 }` drops them immediately. Next 16 requires this second
// argument, and it is not cosmetic — see the note below.
revalidateTag("opinly", { expire: 0 });
// Then the rendered routes for what actually changed.
for (const route of evt.data.changed) {
switch (route.type) {
case "post":
revalidatePath(`${BLOG_PREFIX}/${route.slug}`);
break;
case "category":
revalidatePath(`${BLOG_PREFIX}/category/${route.slug}`);
break;
case "author":
revalidatePath(`${BLOG_PREFIX}/authors/${route.slug}`);
break;
case "tag":
revalidatePath(`${BLOG_PREFIX}/tag/${route.slug}`);
break;
case "home":
revalidatePath(BLOG_PREFIX || "/");
revalidatePath("/sitemap.xml");
break;
}
}
return new Response("ok", { status: 200 });
}If you're not on Next.js, the same split applies: invalidate whatever holds the API responses, and whatever holds the rendered pages. In an app with no cache at all (a client-side SPA), you need neither — it refetches on load.
You're done!
Start editing your blog content and you should see the changes reflected in your webhooks.