# Webhooks

> Receive each published page as a signed JSON request and save it in any stack. Verify the signature and render the version marker.

Source: https://docs.rootsmith.io/docs/publishing/webhooks.md

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

1. Build an HTTPS endpoint that accepts `POST` requests. It must be public
   and have no query string.
2. In **Publishing**, leave the **Delivery endpoint** empty. Under
   **Without an SDK connection**, choose **Webhook**, paste the URL, and save.
3. 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.

```http
POST /your/webhook HTTP/1.1
content-type: application/json
x-rootsmith-event: page.published
x-rootsmith-time: 1767225600000
x-rootsmith-signature: 3f5a...e91c
```

```json
{
  "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:

```text
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.

```ts title="verify.ts"
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)
}
```

```ts title="app/api/rootsmith-webhook/route.ts"
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>`:

```html
<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.
