Webhooks
Receive each published page as a signed JSON request and save it in any stack. Verify the signature and render the version marker.
Use a webhook when your site isn't on Next.js, or when your own code must save each page, such as into a CMS or a Git repository.
Set it up
- Build an HTTPS endpoint that accepts
POSTrequests. It must be public and have no query string. - In Publishing, leave the Delivery endpoint empty. Under Without an SDK connection, choose Webhook, paste the URL, and save.
- Select Set up SDK → Get connection secret and add it to your
server environment as
ROOTSMITH_CONNECTION_SECRET.
An agent sets this with update_publishing_settings, using
fallbackDelivery: "webhook" and the URL in webhookUrl. Only a person
can copy the secret.
The request
RootSmith sends one request for each publication.
POST /your/webhook HTTP/1.1
content-type: application/json
x-rootsmith-event: page.published
x-rootsmith-time: 1767225600000
x-rootsmith-signature: 3f5a...e91c{
"event": "page.published",
"siteId": "00000000-0000-0000-0000-000000000000",
"contentId": "11111111-1111-1111-1111-111111111111",
"version": 4,
"url": "https://example.com/blog/ai-search-guide",
"path": "/blog/ai-search-guide",
"title": "A guide to AI search",
"description": "How AI assistants choose which brands to name.",
"markdown": "## Why it matters\n\n...",
"sentAt": "2026-01-01T00:00:00.000Z"
}| Field | Description |
|---|---|
event | Always page.published. |
siteId | The RootSmith site ID. |
contentId | The page ID. Use it for the version marker. |
version | The version number. Use it for the version marker. |
url | The page's full URL on your domain. |
path | The page path, such as /blog/ai-search-guide. |
title | The page title. |
description | The meta description. It can be empty. |
markdown | The page body in Markdown, without the title. |
sentAt | When RootSmith sent the request, in ISO 8601. |
Any 2xx response counts as delivered. Other responses fail the
publication, and nothing on your site changes.
Verify the signature
x-rootsmith-time is the send time in Unix milliseconds.
x-rootsmith-signature is the hex HMAC-SHA256 of this string, keyed with the
connection secret:
request\n{x-rootsmith-time}\n{raw request body}Use the raw body, before JSON parsing. Reject a time more than 60 seconds from your clock.
import { createHmac, timingSafeEqual } from "node:crypto"
export function verifyRootSmith(
body: string,
time: string | null,
signature: string | null
) {
const secret = process.env.ROOTSMITH_CONNECTION_SECRET
if (!secret || !time || !signature || !/^\d{13}$/.test(time)) return false
if (Math.abs(Date.now() - Number(time)) > 60_000) return false
const expected = createHmac("sha256", secret)
.update(`request\n${time}\n${body}`)
.digest()
const given = Buffer.from(signature, "hex")
return given.length === expected.length && timingSafeEqual(given, expected)
}import { verifyRootSmith } from "./verify"
export async function POST(request: Request) {
const body = await request.text()
if (
!verifyRootSmith(
body,
request.headers.get("x-rootsmith-time"),
request.headers.get("x-rootsmith-signature")
)
)
return new Response("Invalid signature", { status: 401 })
const page = JSON.parse(body)
await savePage(page)
return new Response(null, { status: 204 })
}Show the version marker
RootSmith marks the page published when it finds this element on
https://<your domain><path>:
<div
data-rootsmith-content="{contentId}"
data-rootsmith-version="{version}"
hidden
></div>Render it from the payload's contentId and version. It can be any
element. Without it, confirm the page by hand in RootSmith. A title match
doesn't count, because an update keeps its title.
RootSmith checks right after delivery, then every five minutes for 24 hours.