Developers · Webhook

You built the blog. SiteSeed fills it.

Next.js, Astro or your own backend: SiteSeed sends each researched, fact-checked article to your endpoint as signed JSON. Your code saves it and shows it in your own design. WordPress, Webflow and Ghost need no code at all.

  • Next.js
  • Astro
  • Nuxt
  • SvelteKit
  • Express
  • Django
  • Laravel
  • Any HTTPS endpoint
POSThttps://acme.com/api/siteseedSigned by SiteSeed
x-siteseed-event:
article.published
x-siteseed-timestamp:
1791619200
x-siteseed-signature:
sha256=5f1c…
Body
{  "event": "article.published",  "article": {    "id": "art_5d21e9",    "slug": "shared-inbox-for-small-teams",    "title": "Shared Inbox for Small Teams",    "html": "<p>A shared inbox lets a team…</p>",    "keywords": ["shared inbox", "team email"]  }}

Your site answers 200 OK

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

Live on your blog, in your design

An example: one article, from SiteSeed to your site

3

events: published, unpublished, and a test you send

4

ways in: your own code, WordPress, Webflow or Ghost

3 tries

a few seconds apart when your site times out or fails, with the same deliveryId

$0

extra: in every plan, the Free plan too

What you can build

Your articles, wherever your site keeps its posts.

NEXT.JS · app/api/siteseed/route.ts
POST /api/siteseed# published   → save by article.id# unpublished → take it down

Articles in your Next.js blog

Copy the receiver, save each article by its id, and show it with your own layout and CSS.

ASTRO, HUGO, ANY STATIC SITE
# write content/blog/{slug}.htmlgit commit -am "New post" && git pushcurl -X POST $DEPLOY_HOOK

A static blog that updates itself

A small function writes the article into your repo and starts your host's deploy. The site stays static.

HEADLESS CMS · Sanity, Payload, Strapi
# article.title → title# article.slug  → slug# article.html  → body# article.image → cover

Into the CMS you already use

Your endpoint makes an entry through the CMS's own API, with the title, slug, cover and keywords.

N8N · ZAPIER · MAKE
# Filter: event = article.published# Then: Notion, Airtable, Google Docs…

No code at all

Paste the tool's catch-hook URL as the endpoint and filter on event. Then send the article anywhere the tool goes.

YOUR SITE · the answer to article.published
200 OK{  "url": "https://acme.com/blog/my-post"}

Tell SiteSeed where it lives

Answer with the page's address. SiteSeed shows it on the dashboard and links to it from your other articles.

SLACK · DISCORD · on every article
await savePost(article)# then, to your channel:await postToSlack(article.title)

Tell your team a post is live

One endpoint can do two things: save the article, then post its title to your team's channel.

Ways in

Connected in a few minutes.

In SiteSeed, open your site, then Your website. Under How do we reach your site?, pick one. Each site has one connection.

01Any framework

Your own site (webhook)

Next.js, Astro or any custom backend: we POST each article to your endpoint.

  1. Add an endpoint to your site (copy a receiver below)
  2. Enter its URL and save. Keep the signing secret as SITESEED_WEBHOOK_SECRET
  3. Press Send test payload. Green means it works
Webhook reference
02Application password

WordPress

Each article becomes a post, with its cover image. No plugin to install.

  1. In WordPress: Users › Profile › Application Passwords, make one
  2. In SiteSeed: WordPress site URL, Username, the password
  3. Press Test connection
Step-by-step guide
03Site API token

Webflow

Each article becomes an item in your blog collection. SiteSeed finds the collection and its fields.

  1. In Webflow: Site settings › Apps & integrations › API access
  2. Generate a token: CMS read and write, Sites read
  3. Paste it in SiteSeed and press Connect Webflow
Step-by-step guide
04Admin API key

Ghost

Each article becomes a post, through a custom integration you add in Ghost Admin.

  1. In Ghost Admin: Settings › Integrations › add a custom one
  2. Copy its Admin API key and API URL
  3. Paste both in SiteSeed and press Connect Ghost
Step-by-step guide

Safe by design

Your site stays in charge.

You pick what happens to each article on the Your website tab, under When an article is written. Change it any time.

Publish every article

Default

Articles go live automatically as soon as they are written and fact-checked. No approval needed.

Keep as a draft in SiteSeed

A new article goes live only when you read it and press Publish.

Send as a draft to my website

The article lands on your website as a draft. Publish it there, or here.

WordPress, Webflow and Ghost. A webhook has no draft state, so its drafts wait in SiteSeed.

Signed, with a time.

Every delivery carries an HMAC-SHA256 of its time and body. Refuse a wrong one, and one more than 5 minutes old.

Safe to receive twice.

An update, a refresh and a retry send the same article.id. Save by it and you never get a second page.

Your design, not ours.

article.html is plain HTML with no styles and no scripts. Your own CSS decides how it looks.

Reference

Everything the webhook sends, on one page.

Method

POST

One request for each article, to your endpoint.

Body

application/json

Times are Unix seconds, sentAt is ISO 8601.

Proof

x-siteseed-signature

HMAC-SHA256 of the timestamp, a dot and the raw body.

Answer

2xx

Within 10 seconds. The body can be empty.

  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 once in your site’s environment as SITESEED_WEBHOOK_SECRET.
  3. Press Send test payload. Green means it works.

Events

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.

Payload

Every field, in the order SiteSeed writes them, with a note beside each. Only what a site needs to show an article: your page's address is yours to build from the slug.

article.published
{  "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  }}
article.unpublished · the same fields
{  "event": "article.unpublished",  "article": {    "id": "art_5d21e9",    "status": "unpublished",    "slug": "shared-inbox-for-small-teams",    "html": null  }}

Headers

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.

Verify

Check the signature

Hash the raw body, never the parsed JSON: the signature covers the exact bytes. Answer 401 when it differs. Each version below is run against a signed delivery in our tests.

Node.js

verify.mjs · any framework
import { createHmac, timingSafeEqual } from "node:crypto";// rawBody: the request body as text, before any JSON parsing.export function isFromSiteSeed(rawBody, timestamp, signature, secret = process.env.SITESEED_WEBHOOK_SECRET) {  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 + "." + rawBody).digest("hex");  const given = Buffer.from(signature);  const wanted = Buffer.from("sha256=" + hmac);  return given.length === wanted.length && timingSafeEqual(given, wanted);}// Express: read this route's body raw, not as JSON.// app.post("/api/siteseed", express.raw({ type: "application/json" }), (req, res) => {//   const body = req.body.toString("utf8");//   if (!isFromSiteSeed(body, req.get("x-siteseed-timestamp"), req.get("x-siteseed-signature"))) {//     return res.status(401).end();//   }//   const { event, article } = JSON.parse(body);//   res.json({ ok: true });// });

Python

verify.py · Flask, Django, FastAPI
import hashlibimport hmacimport osimport timedef is_from_siteseed(body: bytes, timestamp: str | None, signature: str | None) -> bool:    """body is the raw request body, before any JSON parsing."""    secret = os.environ.get("SITESEED_WEBHOOK_SECRET", "")    if not secret or not timestamp or not signature:        return False    try:        # Signed more than 5 minutes ago: refuse it.        if abs(time.time() - int(timestamp)) > 300:            return False    except ValueError:        return False    digest = hmac.new(secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()    return hmac.compare_digest(signature, "sha256=" + digest)# Flask:  is_from_siteseed(request.get_data(), request.headers.get("x-siteseed-timestamp"),#                          request.headers.get("x-siteseed-signature"))# Django: is_from_siteseed(request.body, request.headers.get("x-siteseed-timestamp"),#                          request.headers.get("x-siteseed-signature"))

PHP

siteseed.php · PHP 8
<?php// Read the body raw: the signature covers the exact bytes.$body = file_get_contents('php://input');$timestamp = $_SERVER['HTTP_X_SITESEED_TIMESTAMP'] ?? '';$signature = $_SERVER['HTTP_X_SITESEED_SIGNATURE'] ?? '';$secret = (string) getenv('SITESEED_WEBHOOK_SECRET');function is_from_siteseed(string $body, string $timestamp, string $signature, string $secret): bool {    if ($secret === '' || $timestamp === '' || $signature === '') return false;    // Signed more than 5 minutes ago: refuse it.    if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) return false;    $expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);    return hash_equals($expected, $signature);}if (!is_from_siteseed($body, $timestamp, $signature, $secret)) {    http_response_code(401);    exit;}$payload = json_decode($body, true);// Save by $payload['article']['id'] on article.published; remove it on article.unpublished.header('Content-Type: application/json');echo json_encode(['ok' => true]);

Your answer

Any 2xx confirms it. On article.published you may also tell SiteSeed the page's address: it must be https, on the same host as the article address you saved. Without it, SiteSeed fills the slug into that address.

Answer to article.published200 OK
{  "url": "https://acme.com/blog/shared-inbox-for-small-teams"}

Retries

Attempts
3, a few seconds apart, with the same body and deliveryId
Tried again on
A network error, a time-out, a 5xx, 408 and 429
Stops at once on
Any other 4xx
Time-out
10 seconds for each attempt
After the last
The article shows as not accepted by your site; Send again 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.

The HTML

article.html is plain HTML with no styling and no scripts. Put it inside the element your stylesheet already styles for long text.

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

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. It runs as it is on any Node 18+ server that hands a handler a Request.

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 });}

Questions

Asked before you build.

Something else? hello@siteseed.ai

Which frameworks work?

Any that can answer an HTTPS POST: Next.js, Astro, Nuxt, SvelteKit, Remix, Express, Django, Flask, Laravel, Rails or a serverless function. A static site works through a small function that writes the article and starts a deploy.

Is it in every plan?

Yes, at no extra cost, the Free plan too. Each site has one connection: the webhook, WordPress, Webflow or Ghost.

Is there a REST API or an MCP server?

Not for customers yet. SiteSeed sends each article to you, so there is nothing to poll. Tell us what you would build with one at hello@siteseed.ai.

Will the same article come twice?

Yes, on purpose. An update, a refresh of an older article, a retry and Send again all send it again with the same article.id. Save by that id and replace the page you have.

What if my site is down?

SiteSeed tries 3 times, a few seconds apart, after a time-out, a 5xx, 408 or 429. After that the article shows in SiteSeed as not accepted by your site, and Send again on the article sends it once more.

Can I check each article before it goes live?

Yes. Pick Keep as a draft in SiteSeed on the Your website tab: nothing is sent until you press Publish. Send as a draft to my website works with WordPress, Webflow and Ghost, which have a draft state.

Where do the images live?

On SiteSeed's storage, as full https addresses in article.image and in the HTML. Show them as they are, or copy them to your own storage when you save the article.

Can I test on localhost?

The endpoint must be a public https address, so use a tunnel for local work. Send test payload sends an integration.test event that a receiver answers 2xx to and stores nothing.

Your blog. Our articles.

The webhook comes with every plan, the Free plan too. No credit card to start.