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 two events whenever content changes — you can handle either or both:
content.routes-changed(recommended) — a list of the entities that changed (posts, categories, authors, tags, and the home page), each with a bareslugandlastModified. You map each entity onto your own routes, exactly the way you already map the entities returned by the SDK'sroutes()endpoint for your sitemap and static generation. This is the right choice for the v1 SDK, where posts are addressed by a flat, unique slug.content.paths-invalidated(legacy) — pre-built path strings using nested/{category}/{post}paths. Still fully supported; use it if your routes mirror that nested shape.
Both events fire for the same change, so pick whichever maps most cleanly onto your site.
content.routes-changed payload
{
"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 the SDK's routes() endpoint (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(and/or the legacycontent.paths-invalidated) -
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)
// app/api/opinly/route.ts
import { Webhook } from "svix";
import { OpinlyWebhookEvent } from "@opinly/backend";
import { revalidatePath } 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 raw = await request.arrayBuffer();
const buf = Buffer.from(raw);
const wh = new Webhook(process.env.OPINLY_WEBHOOK_SIGNING_SECRET!);
let evt: OpinlyWebhookEvent;
// Verify the payload with the headers
try {
evt = wh.verify(buf, {
"svix-id": svix_id,
"svix-timestamp": svix_timestamp,
"svix-signature": svix_signature,
}) as OpinlyWebhookEvent;
} catch (err) {
console.log("Error verifying webhook", JSON.stringify(err, null, 2));
return new Response("Error verifying webhook", { status: 400 });
}
switch (evt.type) {
// Recommended: map each changed entity onto your own routes — the same
// mapping you already use for the SDK's routes() endpoint.
case "content.routes-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 });
}
// Always return a Response — a Route Handler that falls through returns
// `undefined` and errors at runtime. This also acknowledges the legacy
// `content.paths-invalidated` event if you subscribed to it.
default:
return new Response("ok", { status: 200 });
}
}You're done!
Start editing your blog content and you should see the changes reflected in your webhooks.