Developer reference

Webhook

For a site you built yourself. When an article is published, SiteSeed sends it to your endpoint as one POST with a JSON body. Your code saves it and shows it in your own pages.

Set up in 3 steps

  1. Add an endpoint to your site. On Next.js, copy the receiver below.
  2. In SiteSeed, open your site, then Your website, and pick Your own site (webhook). Enter the endpoint URL and save. Put the signing secret it shows you in your site’s environment as SITESEED_WEBHOOK_SECRET.
  3. Press Send test payload. Green means it works.

What you receive

POST to your endpoint
{
  "event": "article.published",                          // one of the three events below
  "sentAt": "2026-10-10T05:20:00.000Z",
  "deliveryId": "0b6f6c0e-6d1c-4c62-9a57-3f1f0d0f7a11",  // same on every retry
  "site": {
    "id": "site_8f3a2c",
    "name": "Acme"
  },
  "article": {
    "id": "art_5d21e9",                                  // never changes: your key
    "status": "published",                               // or "unpublished", or "test"
    "slug": "shared-inbox-for-small-teams",              // last part of the page address
    "title": "Shared Inbox for Small Teams",
    "description": "How to pick a shared inbox.",        // meta description
    "html": "<p>A shared inbox lets a team…</p>",        // null when unpublished
    "image": "https://cdn.example.com/cover.png",        // cover image, or null
    "imageAlt": "A team sharing one inbox",
    "author": "Jane Doe",                                // from the Writing tab, or null
    "keywords": ["shared inbox", "team email"],          // main keyword first
    "language": "en",
    "wordCount": 1480,
    "readingTime": 7,                                    // minutes
    "publishedAt": 1791619200,                           // Unix seconds
    "updatedAt": 1791619200                              // Unix seconds
  }
}

event says what happened:

  • article.published An article goes live, or one your site already has is updated. Create the page, or replace the one with the same article.id.
  • article.unpublished An article your site has is unpublished or deleted in SiteSeed. Take the page down. article.html is null.
  • integration.test You press Send test payload. Answer 2xx and store nothing.

What your endpoint does

  1. Check the signature. Compute the HMAC-SHA256 of timestamp + "." + rawBody with your secret, and compare "sha256=" + hex with the x-siteseed-signature header. The timestamp is the x-siteseed-timestamp header. Answer 401 when they differ.
  2. Save by article.id. The same article can arrive again (an update, a retry), so replace the page you have for that id. Do not make a second one.
  3. Answer 2xx within 10 seconds. The body can be empty.

Next.js receiver

Save as app/api/siteseed/route.ts and replace the two storage functions. Your endpoint is then https://yoursite.com/api/siteseed.

app/api/siteseed/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

// The signing secret SiteSeed showed once when you connected the webhook.
const SECRET = process.env.SITESEED_WEBHOOK_SECRET ?? "";

type Article = {
  id: string; status: string; slug: string; title: string;
  description: string | null; html: string | null;
  image: string | null; imageAlt: string | null; author: string | null;
  keywords: string[]; language: string | null;
  wordCount: number | null; readingTime: number | null;
  publishedAt: number | null; updatedAt: number | null;
};

// Replace these two with your own storage.
// Store by article.id, so an update replaces its page.
async function savePost(article: Article) {
  // await db.post.upsert({ where: { siteseedId: article.id }, ... })
}
async function removePost(articleId: string) {
  // await db.post.deleteMany({ where: { siteseedId: articleId } })
}

function isFromSiteSeed(body: string, timestamp: string | null, signature: string | null) {
  if (!SECRET || !timestamp || !signature) return false;
  // Signed more than 5 minutes ago: refuse it.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const hmac = createHmac("sha256", SECRET).update(timestamp + "." + body).digest("hex");
  const given = Buffer.from(signature);
  const wanted = Buffer.from("sha256=" + hmac);
  return given.length === wanted.length && timingSafeEqual(given, wanted);
}

export async function POST(request: Request) {
  // Read the body as text: the signature covers the exact bytes.
  const body = await request.text();
  const timestamp = request.headers.get("x-siteseed-timestamp");
  const signature = request.headers.get("x-siteseed-signature");
  if (!isFromSiteSeed(body, timestamp, signature)) {
    return Response.json({ error: "invalid signature" }, { status: 401 });
  }

  const { event, article } = JSON.parse(body) as { event: string; article: Article };
  if (event === "article.published") await savePost(article);
  if (event === "article.unpublished") await removePost(article.id);

  // Any 2xx confirms it. A test (integration.test) lands here and stores nothing.
  return Response.json({ ok: true });
}

More detail

Tell SiteSeed where the article lives

Optional. Answer article.published with the page’s address and SiteSeed uses it in the dashboard and in links from your other articles:

Reply to article.published
{
  "url": "https://acme.com/blog/shared-inbox-for-small-teams"
}

It must be an https address on the same host as the article address you saved in SiteSeed. Without a reply, SiteSeed uses that article address with the slug filled in.

Retries and failures

A delivery is tried up to 3 times, a few seconds apart, after a network error, a time-out or a 5xx, 408 and 429. Any other 4xx stops at once. Every attempt has the same body and the same deliveryId.

After the last attempt nothing more is sent by itself. The article shows in SiteSeed as not accepted by your site, and Send again on the article sends it once more.

The test payload

Send test payload sends one made-up article with the same fields as a real one. Its event is integration.test, its article.status is test, and its id and slug are siteseed-test-article. A receiver that only acts on article.published stores nothing. If yours stores every request, delete the test entry afterwards.

Request headers
HeaderExampleWhat it is
content-typeapplication/jsonThe body is always JSON.
user-agentSiteSeed-Webhook/1.0For a firewall rule. Not proof of anything.
x-siteseed-eventarticle.publishedThe same value as event in the body.
x-siteseed-timestamp1791619200When it was signed, in Unix seconds.
x-siteseed-signaturesha256=5f1c…sha256= then the HMAC-SHA256, in hex, of the timestamp, a dot and the raw body.
What the HTML contains

article.html is plain HTML with no styling from SiteSeed and no scripts, so your own CSS decides how it looks. Put it inside the element your stylesheet already styles for long text.

MarkupWhat it is
<h2 id>, <h3 id>Headings, each with an id. No <h1>: the title is article.title.
<p>, <ul>, <ol>, <blockquote>, <pre><code>The text.
<table>Comparison tables. Give them borders, and sideways scroll on a phone.
<figure><img><figcaption>Images, as full https addresses.
<nav class="siteseed-toc">A table of contents. Hide it with CSS if you have your own.
<section class="article-sources">The sources the facts came from.
<section class="siteseed-cluster-links">Links to your related articles, when there are any.

Images are served from SiteSeed’s storage. If your framework only loads images from hosts you list (Next.js next/image does), use a plain <img> or add the host of article.image to that list.

Zapier, Make and n8n

Paste the tool’s catch-hook URL as the endpoint. These tools answer 2xx by themselves and usually cannot check the signature, so treat the hook URL as a password. Filter on event so only article.published creates a post.

Troubleshooting
What you seeLikely cause
The signature never matchesThe body was parsed before it was hashed. Hash the raw text. In Express, read it with express.raw() on this route.
The test answers 404 or 405, yet the route existsThe endpoint redirects (to www, or to a trailing slash) and the request arrives as a GET. Save the final address.
The test cannot reach the endpointThe address is not public or not https. For local work, use a tunnel.
Articles appear twiceA retry or a Send again created a second page. Store by article.id.

Stuck? Write to [email protected] with your endpoint address and what the test shows.