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
- Add an endpoint to your site. On Next.js, copy the receiver below.
- 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. - Press Send test payload. Green means it works.
What you receive
{
"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.publishedAn article goes live, or one your site already has is updated. Create the page, or replace the one with the same article.id.article.unpublishedAn article your site has is unpublished or deleted in SiteSeed. Take the page down. article.html is null.integration.testYou press Send test payload. Answer 2xx and store nothing.
What your endpoint does
- Check the signature. Compute the HMAC-SHA256 of
timestamp + "." + rawBodywith your secret, and compare"sha256=" + hexwith thex-siteseed-signatureheader. The timestamp is thex-siteseed-timestampheader. Answer401when they differ. - 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.
- 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.
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:
{
"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
| Header | Example | What it is |
|---|---|---|
| content-type | application/json | The body is always JSON. |
| user-agent | SiteSeed-Webhook/1.0 | For a firewall rule. Not proof of anything. |
| x-siteseed-event | article.published | The same value as event in the body. |
| x-siteseed-timestamp | 1791619200 | When it was signed, in Unix seconds. |
| x-siteseed-signature | sha256=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.
| Markup | What 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 see | Likely cause |
|---|---|
| The signature never matches | The 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 exists | The 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 endpoint | The address is not public or not https. For local work, use a tunnel. |
| Articles appear twice | A 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.