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
- x-siteseed-event:
- article.published
- x-siteseed-timestamp:
- 1791619200
- x-siteseed-signature:
- sha256=5f1c…
{ "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
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.
POST /api/siteseed# published → save by article.id# unpublished → take it downArticles in your Next.js blog
Copy the receiver, save each article by its id, and show it with your own layout and CSS.
# write content/blog/{slug}.htmlgit commit -am "New post" && git pushcurl -X POST $DEPLOY_HOOKA static blog that updates itself
A small function writes the article into your repo and starts your host's deploy. The site stays static.
# article.title → title# article.slug → slug# article.html → body# article.image → coverInto the CMS you already use
Your endpoint makes an entry through the CMS's own API, with the title, slug, cover and keywords.
# 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.
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.
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.
Your own site (webhook)
Next.js, Astro or any custom backend: we POST each article to your endpoint.
- Add an endpoint to your site (copy a receiver below)
- Enter its URL and save. Keep the signing secret as
SITESEED_WEBHOOK_SECRET - Press Send test payload. Green means it works
WordPress
Each article becomes a post, with its cover image. No plugin to install.
- In WordPress: Users › Profile › Application Passwords, make one
- In SiteSeed: WordPress site URL, Username, the password
- Press Test connection
Webflow
Each article becomes an item in your blog collection. SiteSeed finds the collection and its fields.
- In Webflow: Site settings › Apps & integrations › API access
- Generate a token: CMS read and write, Sites read
- Paste it in SiteSeed and press Connect Webflow
Ghost
Each article becomes a post, through a custom integration you add in Ghost Admin.
- In Ghost Admin: Settings › Integrations › add a custom one
- Copy its Admin API key and API URL
- Paste both in SiteSeed and press Connect Ghost
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
DefaultArticles 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.
- 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 once in your site’s environment as
SITESEED_WEBHOOK_SECRET. - Press Send test payload. Green means it works.
Events
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.
{ "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": "article.unpublished", "article": { "id": "art_5d21e9", "status": "unpublished", "slug": "shared-inbox-for-small-teams", "html": null }}Headers
content-type
application/json
user-agent
SiteSeed-Webhook/1.0
x-siteseed-event
article.published
x-siteseed-timestamp
1791619200
x-siteseed-signature
sha256=5f1c…
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
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
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
<?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.
{ "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.
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.
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 });}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.