Webhooks let Marble keep your site fresh the moment content changes. Instead of waiting on a full rebuild or manually refreshing caches, Marble sends a signed HTTP request whenever a post is published, updated, or deleted. Your Next.js app can catch that event, verify it, and immediately revalidate the right pages or cache tags.
In this guide, we’ll walk through a minimal, secure example for the Next.js App Router using revalidatePath
and revalidateTag
. By the end, you’ll have an endpoint that automatically keeps your blog up to date. no manual deploys, no stale content.
If you want a quick intro to webhooks in Marble, read our announcement.
Prerequisites
Before you start, make sure you have the following:
A Next.js app using the App Router – this guide specifically uses the App Router APIs like
revalidatePath
.A Marble workspace – you’ll need your Workspace ID and to create a webhook in the Marble dashboard.
Environment variables – set up the values below in a
.env.local
file at the root of your Next.js project for local development. Make sure to also add them as environment variables in your production host (Vercel, Netlify, Fly, etc.).
Here’s what your .env.local
should look like:
# .env.local
MARBLE_WORKSPACE_KEY=<your_workspace_id_here> # workspace id from Marble settings
MARBLE_API_URL=https://api.marblecms.com/v1
MARBLE_WEBHOOK_SECRET=<your_webhook_secret_here> # the secret generated for your webhook
What you’ll create
By the end of this guide, you’ll have a single API route in your Next.js app that:
Verifies incoming requests using the webhook secret to ensure they’re really from Marble.
Parses the event payload so you can react to posts being published, updated, or deleted.
Revalidates the right pages and cache tags so your blog instantly serves fresh content on the next request.
This example uses the default Node.js runtime. If you plan to use the Edge runtime, check the Next.js docs to confirm supported APIs.
Step 1: Set up the API endpoint
Create a route handler at app/api/revalidate/route.ts
. This will:
read the signature header and secret,
reject the request if either is missing,
read the raw body and parses it.
// app/api/revalidate/route.ts
import { NextResponse } from "next/server";
import type { PostEventData } from "@/lib/marble/types";
import { verifySignature, handleWebhookEvent } from "@/lib/marble/webhook";
export async function POST(request: Request) {
const signature = request.headers.get("x-marble-signature");
const secret = process.env.MARBLE_WEBHOOK_SECRET;
// Basic checks: signature + secret must exist
if (!secret || !signature) {
return NextResponse.json(
{ error: "Secret or signature missing" },
{ status: 400 },
);
}
// Read the raw body text (we need the exact text for HMAC verification)
const bodyText = await request.text();
//TODO: Verify signature
const ok = true
if (!ok) {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
// Parse payload (we'll validate structure after parsing)
const payload = JSON.parse(bodyText) as PostEventData;
if (!payload.event || !payload.data) {
return Response.json(
{ error: "Invalid payload structure" },
{ status: 400 },
);
}
try {
// TODO: handle the event
} catch (err) {
console.error("Error processing webhook:", err); // You can log for debugging
return NextResponse.json({ error: "Failed to process webhook" }, { status: 500 });
}
}
Notes
We read
request.text()
so the exact raw body can be used for signature verification. If you callrequest.json()
first, the string representation could change and the HMAC will fail.verifySignature
andhandleWebhookEvent
are helpers we’ll create next.
Step 2: Verify the webhook signature
Create a lib/marble/webhook.ts
:
// lib/marble/webhook.ts
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifySignature(secret: string, signatureHeader: string, bodyText: string) {
// Strip possible "sha256=" prefix
const expectedHex = signatureHeader.replace(/^sha256=/, "");
const computedHex = createHmac("sha256", secret).update(bodyText).digest("hex");
// Convert to buffers for constant-time compare
const expected = Buffer.from(expectedHex, "hex");
const computed = Buffer.from(computedHex, "hex");
// lengths must match for timingSafeEqual
if (expected.length !== computed.length) return false;
return timingSafeEqual(expected, computed);
}
What we just did
Stripped the
sha256=
prefix from the header,
We use
timingSafeEqual
to avoid leaking information via timing side-channels.Compute the HMAC over the exact raw string, any change in whitespace/serialization will break verification.
Step 3: Handle the webhook event
// lib/marble/webhook.ts
import { revalidatePath, revalidateTag } from "next/cache";
import type { PostEventData } from "@/types/blog";
export async function handleWebhookEvent(payload: PostEventData) {
const event = payload.event;
const data = payload.data;
// Handle any post.* events (published, updated, deleted, etc.)
if (event.startsWith("post")) {
// Revalidate the blog index and the single post page
revalidatePath("/blog");
revalidatePath(`/blog/${data.slug}`);
// If your data fetches use tags, revalidate that tag as well:
// e.g. fetch(..., { next: { tags: ["posts"] } })
revalidateTag("posts");
return {
revalidated: true,
now: Date.now(),
message: "Post event handled",
};
}
return {
revalidated: false,
now: Date.now(),
message: "Event ignored",
};
}
What this does
revalidatePath("/blog")
marks the/blog
route cache as stale & the next visitor will trigger a fresh fetch and get updated content.revalidateTag("posts")
is useful only if your serverfetch
calls includenext: { tags: ["posts"] }
. Tags let you invalidate multiple cached fetches at once.The full payload schema and type definitions are available in our docs.
Step 4: Put it all together
// app/api/revalidate/route.ts
import { NextResponse } from "next/server";
import type { PostEventData } from "@/types/blog";
import { verifySignature, handleWebhookEvent } from "@/lib/marble/webhook";
export async function POST(request: Request) {
const signature = request.headers.get("x-marble-signature");
const secret = process.env.MARBLE_WEBHOOK_SECRET;
if (!secret || !signature) {
return NextResponse.json({ error: "Secret or signature missing" }, { status: 400 });
}
const bodyText = await request.text();
if (!verifySignature(secret, signature, bodyText)) {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
const payload = JSON.parse(bodyText) as PostEventData;
if (!payload.event || !payload.data) {
return Response.json(
{ error: "Invalid payload structure" },
{ status: 400 },
);
}
try {
const result = await handleWebhookEvent(payload);
return NextResponse.json(result);
} catch (err) {
return NextResponse.json({ error: "Failed to process webhook" }, { status: 500 });
}
}
For local testing use ngrok
or a similar tunnel so Marble can reach your local dev server.
Wrap Up
In just a few steps, we built a webhook endpoint in small, testable pieces:
a route handler that validates headers and body,
a utility to verify the HMAC signature,
and a handler that revalidates paths/tags in Next.js.
With just a few lines of code, you now have:
A signed, verifiable webhook endpoint.
Automatic revalidation of your blog index and post pages.
Fresh content served on-demand, without rebuilding your whole app.
Next Steps
Check out the full code example to try it locally.
Explore our documentation for other integration guides.