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 webhooks announcement. If you want the broader blog setup first, read how to build a blog with Next.js and a headless CMS.
Prerequisites
Before you start, make sure you have the following:
A Next.js app using the App Router. This guide uses App Router APIs like
revalidatePath.A Marble workspace. You need a workspace with posts and access to the Webhooks settings page.
A webhook endpoint URL. In production this might be
https://yourdomain.com/api/revalidate. For local testing, use a tunnel such as ngrok so Marble can reach your local app.A webhook secret. Marble generates this for each webhook. Your app uses it to verify the request signature.
Add the secret to your Next.js environment:
# .env.local
MARBLE_WEBHOOK_SECRET=your_webhook_secret_hereWhat Marble sends
JSON webhooks receive a stable event envelope. For post events, the event name is available as payload.type, and the post data is available as payload.data.
{
"id": "evt_123",
"type": "post.published",
"createdAt": "2026-05-22T14:42:31.120Z",
"workspaceId": "org_123",
"resource": {
"type": "post",
"id": "post_123"
},
"actor": {
"type": "user",
"id": "user_123"
},
"data": {
"id": "post_123",
"title": "Getting Started with Marble",
"slug": "getting-started-with-marble",
"description": "Learn how to publish your first post with Marble.",
"coverImage": "https://media.marblecms.com/example.png",
"status": "published",
"featured": false,
"publishedAt": "2026-05-22T14:00:00.000Z",
"createdAt": "2026-05-21T18:12:09.431Z",
"updatedAt": "2026-05-22T14:42:30.951Z"
}
}Webhook requests also include useful headers:
x-marble-event: the event type, such aspost.published.x-marble-event-id: the durable workspace event ID.x-marble-delivery-id: the delivery ID for this webhook send.x-marble-timestamp: the timestamp used for the request.x-marble-signature: the HMAC SHA-256 signature for the request body.
Create the webhook types
Create types/webhook.ts with the fields your revalidation handler needs:
export type MarblePostEvent =
| "post.published"
| "post.updated"
| "post.deleted";
export type PostWebhookPayload = {
id: string;
type: MarblePostEvent;
createdAt: string;
workspaceId: string;
resource: {
type: "post";
id: string;
} | null;
actor: {
type: "user" | "api_key" | "mcp" | "system";
id: string | null;
} | null;
data: {
id: string;
slug?: string;
title?: string;
description?: string | null;
coverImage?: string | null;
status?: string;
changes?: string[];
};
};Verify the webhook signature
Create lib/marble/webhook.ts. The important part is that you verify the signature against the exact raw request body. Do not call request.json() before signature verification.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifySignature(
secret: string,
signatureHeader: string,
bodyText: string
) {
const expectedHex = signatureHeader.replace(/^sha256=/, "");
const computedHex = createHmac("sha256", secret)
.update(bodyText)
.digest("hex");
const expected = Buffer.from(expectedHex, "hex");
const computed = Buffer.from(computedHex, "hex");
if (expected.length !== computed.length) {
return false;
}
return timingSafeEqual(expected, computed);
}Handle the webhook event
Next, add a handler that decides what to revalidate. This example handles post events and revalidates the blog index plus the individual post page when a slug is present.
import { revalidatePath, revalidateTag } from "next/cache";
import type { PostWebhookPayload } from "@/types/webhook";
export async function handleWebhookEvent(payload: PostWebhookPayload) {
const event = payload.type;
const data = payload.data;
if (event.startsWith("post.")) {
revalidatePath("/blog");
if (data.slug) {
revalidatePath(`/blog/${data.slug}`);
}
revalidateTag("posts");
return {
revalidated: true,
now: Date.now(),
message: "Post event handled",
};
}
return {
revalidated: false,
now: Date.now(),
message: "Event ignored",
};
}revalidatePath("/blog") marks the blog index as stale. The next visitor gets fresh content. revalidateTag("posts") is useful if your server data fetching uses tags such as fetch(url, { next: { tags: ["posts"] } }).
Create the route handler
Now create app/api/revalidate/route.ts:
import { NextResponse } from "next/server";
import { handleWebhookEvent, verifySignature } from "@/lib/marble/webhook";
import type { PostWebhookPayload } from "@/types/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 });
}
let payload: PostWebhookPayload;
try {
payload = JSON.parse(bodyText) as PostWebhookPayload;
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!payload.type || !payload.data) {
return NextResponse.json(
{ error: "Invalid payload structure" },
{ status: 400 }
);
}
try {
const result = await handleWebhookEvent(payload);
return NextResponse.json(result);
} catch {
return NextResponse.json(
{ error: "Failed to process webhook" },
{ status: 500 }
);
}
}Create the webhook in Marble
In your Marble dashboard, go to Settings → Webhooks. Create a new webhook with your deployed endpoint URL, choose JSON as the format, and select the post events you care about, such as post.published, post.updated, and post.deleted.
After creating the webhook, copy its secret and add it to your deployment environment as MARBLE_WEBHOOK_SECRET.
Local testing
For local testing, run your Next.js app and expose it with a tunnel:
ngrok http 3000Use the generated HTTPS URL as your Marble webhook endpoint, for example:
https://example.ngrok-free.app/api/revalidateThen publish or update a post in Marble and watch your local server logs.
Wrapping up
In a few steps, we built a webhook endpoint that:
reads the raw request body,
verifies the Marble signature,
parses the current webhook envelope,
and revalidates the affected Next.js routes.
That gives you a blog that can stay static for readers while still updating quickly when content changes in Marble.
Next steps
Check out the Marble Next.js example to try a fuller setup locally.
Read the webhooks documentation for all event types and payload details.
Read how to build a blog with Next.js and a headless CMS if you want the full blog structure around this endpoint.