What you are building
One route, e.g. POST /api/webhooks/blogwriter, that:
- checks the bearer token (
401if wrong); - reads the event:
publish_articles(new posts) orupdate_article(one changed post); - saves each article, updating the one with the same
idinstead of adding a copy; - answers
200.
Need blogwriter to read posts back or check fields? Use the REST API instead.
Authentication
A long random token, shared between the site and the writer. It arrives as a bearer token:
POST /api/webhooks/blogwriter Authorization: Bearer 3f9c1a0e7b... Content-Type: application/json
Wrong or missing token: 401. Compare in constant time. The URL must be HTTPS.
Generate one with:
openssl rand -hex 32
Do not redirect the webhook path. A redirect between example.com and www.example.com drops the Authorization header, so every post is refused.
The events
Every request is JSON with event_type, timestamp (ISO 8601) and data.
publish_articles
A new post, in the data.articles array. Usually one; handle any number.
{
"event_type": "publish_articles",
"timestamp": "2026-09-24T12:00:00.000Z",
"data": {
"articles": [
{
"id": "8d3f2c1a-4b5e-4f60-9a7b-1c2d3e4f5a6b",
"title": "Gym Schedule and Timetable Templates",
"content_markdown": "> **In short:** ...\n\n## Why timetables fail\n\n...",
"content_html": "<blockquote><strong>In short:</strong> ...</blockquote>\n<h2 id=\"why-timetables-fail\">Why timetables fail</h2>...",
"meta_description": "How to build a class timetable that members turn up to.",
"created_at": "2026-09-24T00:00:00.000Z",
"image_url": "https://cdn.example.com/hero.png",
"slug": "gym-schedule-timetable",
"tags": ["gym", "scheduling"],
"status": "draft"
}
]
}
}
update_article
A post sent before, changed. The whole post, not a diff, in data.article.
{
"event_type": "update_article",
"timestamp": "2026-09-25T09:30:00.000Z",
"data": {
"article": {
"id": "8d3f2c1a-4b5e-4f60-9a7b-1c2d3e4f5a6b",
"title": "Gym Schedule and Timetable Templates",
"content_markdown": "...",
"content_html": "...",
"meta_description": "...",
"created_at": "2026-09-24T00:00:00.000Z",
"image_url": "https://cdn.example.com/hero.png",
"slug": "gym-schedule-timetable",
"tags": ["gym", "scheduling"]
}
}
}
No status here means: keep the status it has.
The article object
| Field | Type | Notes | |
|---|---|---|---|
id | string | always | blogwriter's id for the post. Never changes. Match on it. |
title | string | always | Plain text, no markup. |
slug | string | always | The address. Can change between sends when a post is retitled. |
content_markdown | string | always | The body as Markdown, with some HTML blocks. |
content_html | string | always | The same body as ready-to-render HTML. |
meta_description | string | always | Empty string when there is none. |
created_at | string | always | ISO 8601. The post's date. |
image_url | string | optional | The cover. Absolute URL, or null. |
tags | string[] | always | Plain strings; may be empty. |
status | enum | optional | "draft" or "published". Absent on an update means keep it. |
publish_at | string | optional | ISO 8601. Present when the writer scheduled the post. |
Store content_html or content_markdown, whichever the site renders.
Matching a post
Look up by id first, then by slug. Found: update it. Not found: create it.
A retitled post keeps its id but gets a new slug: move it and 301 the old address.
Handle both events the same way. Upserting on id makes a retry or a duplicate harmless.
Status
statuspresent: set it. New posts arrive as"draft"unless the writer published them.statusabsent on an update: keep the current status. Never reset a live post to draft.statusabsent on a new post: treat it as a draft.
No draft state on the site? Publish on arrival, and tell the writer that is what happens.
publish_at means scheduled: hold it as a draft until then. Echo it in your reply if you can.
Rendering the body
The body includes a table of contents and embeds (videos, X and Reddit posts, TradingView charts). Let them through your sanitizer; the allowed elements and hosts are in the REST API spec and in the prompt below.
Responding
Answer 200 once the post is stored. This body is the most useful:
200 OK
{
"ok": true,
"id": "8d3f2c1a-4b5e-4f60-9a7b-1c2d3e4f5a6b",
"url": "https://example.com/blog/gym-schedule-timetable",
"status": "draft",
"publish_at": null
}
url is shown to the writer as the link to the post. publish_at echoed back confirms a schedule.
| Status | When |
|---|---|
200 | Stored. |
400 | Bad JSON, unknown event, or a missing field. Say which. |
401 | Missing or wrong token. |
500 | Your side failed. The writer sees your message, so make it say what. |
blogwriter does not retry. A failed send is shown to the writer with your message.
Images
Show
Images arrive as absolute public URLs. Storing the URL is enough. Accept SVG: diagrams are SVG.
Reference implementation
Show
A Next.js route handler. Same shape in Express, Laravel, Rails or Django.
// app/api/webhooks/blogwriter/route.ts
import { timingSafeEqual } from "node:crypto";
const TOKEN = process.env.BLOGWRITER_WEBHOOK_TOKEN!;
function authed(req: Request) {
const h = req.headers.get("authorization") ?? "";
if (!h.startsWith("Bearer ")) return false;
const a = Buffer.from(h.slice(7)), b = Buffer.from(TOKEN);
return a.length === b.length && timingSafeEqual(a, b);
}
async function save(article: any) {
for (const f of ["id", "title", "slug"])
if (!article?.[f]) throw Object.assign(new Error(`article.${f} is required`), { status: 400 });
// id first, so a retitled post is updated rather than copied; slug second, to adopt old posts.
const existing =
(await db.posts.findUnique({ where: { external_id: article.id } })) ??
(await db.posts.findUnique({ where: { slug: article.slug } }));
const fields = {
external_id: article.id,
title: article.title,
slug: article.slug,
content: article.content_markdown, // or content_html, if the site stores HTML
excerpt: article.meta_description || null,
cover_image_url: article.image_url || null,
tags: article.tags ?? [],
...(article.status ? { status: article.status } : {}), // absent = keep what it has
...(article.publish_at ? { publish_at: article.publish_at } : {})
};
const row = existing
? await db.posts.update({ where: { id: existing.id }, data: fields })
: await db.posts.create({ data: { status: "draft", created_at: article.created_at, ...fields } });
if (existing && existing.slug !== row.slug)
await db.redirects.upsert({ where: { from: `/blog/${existing.slug}` },
create: { from: `/blog/${existing.slug}`, to: `/blog/${row.slug}` }, update: { to: `/blog/${row.slug}` } });
return row;
}
export async function POST(req: Request) {
if (!authed(req)) return Response.json({ error: "Invalid access token." }, { status: 401 });
let body: any;
try { body = await req.json(); } catch { return Response.json({ error: "Body is not JSON." }, { status: 400 }); }
const articles =
body.event_type === "publish_articles" ? body.data?.articles ?? [] :
body.event_type === "update_article" ? [body.data?.article] : null;
if (!articles) return Response.json({ error: `Unknown event_type "${body.event_type}".` }, { status: 400 });
try {
const rows = [];
for (const a of articles) rows.push(await save(a));
const last = rows[rows.length - 1];
return Response.json({ ok: true, id: last?.external_id, url: `https://example.com/blog/${last?.slug}`,
status: last?.status, publish_at: last?.publish_at ?? null });
} catch (e: any) {
return Response.json({ error: e.message }, { status: e.status ?? 500 });
}
}
The posts table needs one extra column: external_id, unique, holding the article id.
Before you ship
Show
- [ ] No token or a wrong one:
401, nothing stored. - [ ] HTTPS, with no redirect in front of it.
- [ ] Sending the same
publish_articlestwice leaves one post. - [ ] An update with a new slug moves the post and redirects the old one.
- [ ] A draft is not on the site, the sitemap or the feed.
- [ ] Updating a live post with no
statuskeeps it live. - [ ] An unknown
event_typegets400. - [ ] A
bw-embedblock renders as a frame, not just its caption. - [ ] An SVG
image_urlrenders.
Setting one up for a new client
- Make a token:
openssl rand -hex 32. - Get the receiver built: send this page, or paste the prompt below into an AI tool in the site's repo.
- Connect it: Settings → The client's CMS → Webhook, or
blogwriter cms setup webhook. - Check it:
blogwriter cms push <post.md> --dry-runshows the exact event. - Send one draft and look at it before publishing.
blogwriter saves the article id in the post as cmsId. Keep it: that is what makes the next send an update.
Give this to your AI
Open your AI coding tool (Claude Code, Cursor, Lovable…) in the site's repo and paste the box below. It builds and tests everything, then gives you the URL and token for blogwriter.
You are working in the repository of a website with a blog. Build a webhook receiver so this site
can receive blog posts from blogwriter, an AI writing tool, and make the blog render everything
blogwriter writes. Everything you need is in this message; do not guess at anything it specifies.
Work through the steps in order and do not stop until the "DONE WHEN" list at the end passes.
STEP 0 — LOOK BEFORE CHANGING ANYTHING
Find out and tell me in two or three lines:
- the framework and how API routes are written here (Next.js app or pages router, Express, Astro,
SvelteKit, Laravel, Rails, Django, etc.);
- where blog posts are stored (a database table and its ORM, a headless CMS, Markdown files in the
repo) and the fields each post has;
- how a post body becomes HTML on the blog page (Markdown renderer, sanitizer, MDX, templates);
- whether the site sends a Content-Security-Policy header;
- where environment variables live and how the site is deployed.
Change as little as that allows. Reuse the existing posts store and the existing blog page. If there
is no blog yet, create one: a posts table (below), a /blog index page listing published posts
newest first, and a /blog/[slug] page.
STEP 1 — THE POSTS STORE
Each post needs these fields. Add only the ones that are missing:
id (the site's own), external_id (text, UNIQUE — blogwriter's article id), title, slug (UNIQUE),
status ("draft" or "published", default "draft"), content (the body), excerpt (nullable),
cover_image_url (nullable), tags (list of strings), created_at, updated_at,
publish_at (nullable timestamp), published_at (nullable timestamp).
The unique constraints matter: they are what stops a retry from creating a second copy.
If posts are Markdown files in the repo rather than a database, the receiver cannot write to the
repo on a serverless host; tell me, and propose storing webhook posts in a small database table
(or the platform's KV/blob store) that the blog pages also read from.
Also make somewhere to keep redirects from old slugs to new ones (a table, or the framework's
redirect config if it can be updated at runtime).
STEP 2 — THE ACCESS TOKEN
Generate a random token (e.g. `openssl rand -hex 32`). Put it in the local env file as
BLOGWRITER_WEBHOOK_TOKEN and make sure that file is git-ignored. Never commit it and never write it
into source code. Tell me to add the same variable to the hosting provider's environment settings,
and tell me the exact name.
STEP 3 — THE RECEIVER ROUTE: POST /api/webhooks/blogwriter
Authentication:
- Every request carries `Authorization: Bearer <token>`. Compare it to BLOGWRITER_WEBHOOK_TOKEN in
constant time (e.g. crypto.timingSafeEqual on equal-length buffers). Missing or wrong: answer 401
with {"error":"Invalid access token."} and store nothing.
- Only POST. Never accept the token from a query string.
- The route must answer at the site's canonical host with no redirect in front of it (a redirect
between example.com and www.example.com drops the Authorization header). If the site or its host
redirects apex <-> www, exempt /api/webhooks/* or tell me which host to use.
- Exclude this route from any auth middleware, CSRF protection or bot protection that would block a
server-to-server POST.
The request body is JSON with three keys: event_type, timestamp (ISO 8601), data.
Two event types:
event_type "publish_articles": data.articles is an ARRAY of article objects (usually one).
event_type "update_article": data.article is ONE article object (the whole post, not a diff).
Anything else: answer 400 with {"error":"Unknown event_type \"<value>\"."}.
A body that is not JSON: answer 400.
An example publish_articles body:
{
"event_type": "publish_articles",
"timestamp": "2026-09-24T12:00:00.000Z",
"data": { "articles": [ {
"id": "8d3f2c1a-4b5e-4f60-9a7b-1c2d3e4f5a6b",
"title": "Gym Schedule and Timetable Templates",
"content_markdown": "> **In short:** ...\n\n## Why timetables fail\n\n...",
"content_html": "<blockquote><strong>In short:</strong> ...</blockquote>\n<h2 id=\"why-timetables-fail\">Why timetables fail</h2>...",
"meta_description": "How to build a class timetable that members turn up to.",
"created_at": "2026-09-24T00:00:00.000Z",
"image_url": "https://cdn.example.com/hero.png",
"slug": "gym-schedule-timetable",
"tags": ["gym", "scheduling"],
"status": "draft"
} ] }
}
An update_article body is the same article under "data": { "article": { ... } }, and often has no
"status" key at all.
Article fields:
id string, always blogwriter's id; stable across every send of this post
title string, always plain text
slug string, always the post's address; may change when a post is retitled
content_markdown string, always the body as Markdown, with some HTML blocks on purpose
content_html string, always the same body as HTML: heading ids set, embeds in place
meta_description string, always "" when there is none
created_at ISO 8601, always the post's date
image_url string or null the cover image, an absolute URL
tags string[], always may be empty
status "draft"|"published", sometimes absent
publish_at ISO 8601, sometimes present — a schedule
Handling each article (the same code for both event types):
1. If id, title or slug is missing: answer 400 naming the field.
2. Find the stored post by external_id = article.id; if none, by slug = article.slug.
3. If found, update it; if not, create it. This makes every event safe to receive twice, and a
retitled post (new slug, same id) is updated instead of copied.
4. Store: external_id=id, title, slug, excerpt=meta_description (null if ""), cover_image_url=
image_url, tags, and the body. Store content_markdown if the blog renders Markdown itself;
store content_html if the blog stores and renders HTML. Pick one based on STEP 0 and say which.
5. Status — this is the rule that matters most:
- status present: set it.
- status ABSENT on an existing post: do not touch its status. A published post stays published.
Never default a missing status to "draft" on an update: that takes a live article off the
site and its URL starts answering 404.
- status absent on a new post: create it as "draft".
- When a post becomes "published" and published_at is empty, set published_at to now. Never
change published_at on a post that already has it.
6. publish_at present: keep the post a draft and publish it at that time. If the site has a cron or
scheduled job mechanism, use it; if not, have the public pages treat a post as published once
publish_at has passed. If neither is possible, ignore publish_at and tell me.
7. Slug changed on an existing post: move it to the new slug and add a 301 redirect from the old
/blog/<old-slug> to the new one.
8. Images: image_url and images inside the body are absolute public URLs already. Storing the URLs
is enough. Do not strip SVG images.
Response: on success answer 200 with
{"ok":true,"id":"<article id>","url":"<public URL of the post>","status":"<its status now>",
"publish_at":"<ISO or null>"}
(for several articles, describe the last one). On a failure on this side answer 500 with
{"error":"<a message a person can act on>"}. Answer within 30 seconds; do slow work (image
downloads, search indexing, cache purges) after responding or in the background.
After storing, make the post visible without a redeploy: revalidate/purge the blog index, the post
page, the sitemap and the RSS feed if the site caches them (e.g. Next.js revalidatePath).
STEP 4 — THE PUBLIC SITE
- The blog index, the post page, the sitemap and the RSS feed show only status = "published" posts
(and scheduled ones whose publish_at has passed). Drafts are never public.
- The post page uses the title, excerpt as meta description, cover_image_url as og:image, and
created_at / published_at as the date.
THE BLOG PAGES — render everything blogwriter writes
A post body is Markdown (tables included) with a few HTML blocks in it on purpose. Render it with
these on top, and change nothing about how existing posts look.
1. Heading ids. Every heading gets an `id` so the contents list can jump to it. The rule: the
heading's text, lowercased, apostrophes removed, every run of characters that is not a letter or
digit turned into one hyphen, hyphens trimmed from both ends, cut to 72 characters. This is the
GitHub / rehype-slug / markdown-it-anchor convention, so a renderer already doing that is fine.
Example: "What is Hype Coin (HYPE)? Price & 2026 Outlook" -> what-is-hype-coin-hype-price-2026-outlook
2. Raw HTML that must be rendered, not stripped or escaped. Keep the site's sanitizer and allow only:
- <nav class="bw-toc"> with p, ol, li and a[href] inside: the table of contents.
- <figure class="bw-embed bw-embed-<kind>"> blocks: embeds. Allow inside them:
figure[class, style]; iframe[src, title, loading, referrerpolicy, sandbox, allow,
allowfullscreen, style, width, height]; figcaption, blockquote, p, cite, strong, span [style];
a[href, rel, target, style].
Render an iframe only if its src is https:// and its host matches its kind:
bw-embed-youtube www.youtube-nocookie.com, www.youtube.com
bw-embed-x platform.twitter.com (an X/Twitter post)
bw-embed-tv-chart s.tradingview.com (live chart)
bw-embed-tv-price s.tradingview.com (live price)
bw-embed-reddit www.redditmedia.com
bw-embed-instagram www.instagram.com
bw-embed-website any https host; add sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
bw-embed-link no iframe: a link card
- Keep inline `style` attributes on these elements. They carry each embed's size (a chart's
aspect ratio, a price widget's height, an X post's height), and the site has none of
blogwriter's CSS. None of these blocks contains <script>, and none may ever be allowed one.
If the site already turns YouTube links into its own player, keep that, but do not let it
swallow the other kinds.
3. Blockquotes. Every post opens with `> **In short:** ...`, the answer in two or three lines. Style
blockquotes so it reads as a summary box, not as small grey text.
4. Images. Markdown images and the cover image are usually absolute URLs; also accept root-relative
paths (/images/x.png) resolved against this site, and SVG files (diagrams are SVG). Never drop
an SVG.
5. Metadata. Use the meta title / meta description when set (else title / excerpt), the canonical
URL when set, and the cover image as the social (og:image) image. Drafts never render publicly,
are never in the sitemap, and never in the RSS feed.
6. Content-Security-Policy. If the site sends one, frame-src must include every host in the embed
list above and img-src must allow https images.
THE TEST POST (real blogwriter output — use it to check the rendering)
---
title: "blogwriter render test"
slug: "blogwriter-render-test"
description: "A test post with every block blogwriter writes."
---
> **In short:** if this sentence sits in a styled quote box, the blockquote works.
Opening paragraph with a [link to another post](/blog/another-post) and **bold** text.
<nav class="bw-toc" aria-label="Table of contents">
<p class="bw-toc-title">Table of contents</p>
<ol>
<li><a href="#what-is-hype-coin-hype-price-2026-outlook">What is Hype Coin (HYPE)? Price & 2026 Outlook</a>
</li>
<li><a href="#live-data">Live data</a>
</li>
<li><a href="#what-people-said">What people said</a>
</li>
</ol>
</nav>
## What is Hype Coin (HYPE)? Price & 2026 Outlook
A table:
| Measure | Figure |
|---|---|
| Price | $91.52 |
| Market cap | $20.4 billion |

## Live data
<figure class="bw-embed bw-embed-tv-chart" style="margin:2rem 0">
<iframe src="https://s.tradingview.com/widgetembed/?symbol=BYBIT%3AHYPEUSDT&interval=D&theme=light&style=1&locale=en&hide_side_toolbar=1&allow_symbol_change=0&saveimage=0&withdateranges=1" title="BYBIT:HYPEUSDT live chart" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" style="display:block;width:100%;border:0;aspect-ratio:16/10;border-radius:12px"></iframe>
<figcaption style="font-size:.875rem;opacity:.72;margin-top:.6rem;line-height:1.5">Live chart · <a href="https://www.tradingview.com/symbols/BYBIT-HYPEUSDT/" rel="noopener nofollow" target="_blank">BYBIT:HYPEUSDT on TradingView</a></figcaption>
</figure>
## What people said
<figure class="bw-embed bw-embed-x" style="margin:2rem 0">
<iframe src="https://platform.twitter.com/embed/Tweet.html?id=20&dnt=true" title="Post on X by jack" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" style="display:block;width:100%;border:0;max-width:550px;height:234px;border-radius:12px"></iframe>
<figcaption style="font-size:.875rem;opacity:.72;margin-top:.6rem;line-height:1.5">jack · <a href="https://x.com/jack/status/20" rel="noopener nofollow" target="_blank">See the post on X</a></figcaption>
</figure>
<figure class="bw-embed bw-embed-link" style="margin:2rem 0">
<a href="https://www.coingecko.com/en/coins/hyperliquid" rel="noopener" target="_blank" style="display:block;padding:1rem 1.2rem;border:1px solid rgba(127,127,127,.3);border-radius:12px;text-decoration:none;color:inherit">
<strong style="display:block">Hyperliquid price</strong><span style="font-size:.875rem;opacity:.7">coingecko.com</span>
</a>
</figure>
Check the test post on desktop and on mobile:
- the In short box is styled;
- the contents list links jump to their headings;
- the table, the SVG image and the link card render;
- the TradingView chart and the X post render as real frames, not just their captions;
- an iframe from a host not in the list, or a <script> inside a bw-embed figure, is dropped.
STEP 5 — TEST IT, FOR REAL
Run the site locally and send these with curl (replace TOKEN and the port). Show me each response.
a) No token — expect 401, nothing stored:
curl -i -X POST http://localhost:3000/api/webhooks/blogwriter -H 'Content-Type: application/json' -d '{}'
b) New draft — expect 200, post stored as draft, not visible on /blog:
curl -i -X POST http://localhost:3000/api/webhooks/blogwriter -H 'Authorization: Bearer TOKEN' \
-H 'Content-Type: application/json' \
-d '{"event_type":"publish_articles","timestamp":"2026-09-24T12:00:00Z","data":{"articles":[{"id":"test-1","title":"Webhook test","content_markdown":"> **In short:** it works.\n\n## First\n\nHello.","content_html":"<blockquote><strong>In short:</strong> it works.</blockquote><h2 id=\"first\">First</h2><p>Hello.</p>","meta_description":"A test.","created_at":"2026-09-24T00:00:00Z","image_url":null,"slug":"webhook-test","tags":["test"],"status":"draft"}]}}'
c) The same request again — expect 200 and still exactly one post.
d) Publish it — send (b) with "status":"published" — expect it on /blog/webhook-test.
e) Update with NO status and a new slug — expect it still published, at /blog/webhook-test-2, and
/blog/webhook-test redirecting there:
curl -i -X POST http://localhost:3000/api/webhooks/blogwriter -H 'Authorization: Bearer TOKEN' \
-H 'Content-Type: application/json' \
-d '{"event_type":"update_article","timestamp":"2026-09-25T09:00:00Z","data":{"article":{"id":"test-1","title":"Webhook test, renamed","content_markdown":"Changed.","content_html":"<p>Changed.</p>","meta_description":"","created_at":"2026-09-24T00:00:00Z","image_url":null,"slug":"webhook-test-2","tags":[]}}}'
f) Unknown event — "event_type":"ping" — expect 400.
g) Send THE TEST POST above as a published article (its body as content_markdown, and rendered as
content_html) and check it against the list under it, on desktop and mobile widths.
Then delete the test posts. If the project has an automated test setup, add tests for (a)–(f).
DONE WHEN
- All of STEP 5 behaves as described, and every existing post renders exactly as it did before.
- You have told me, at the end, in this exact form:
Webhook URL: https://<the production domain>/api/webhooks/blogwriter
Access token: <the token> (also set BLOGWRITER_WEBHOOK_TOKEN on the host)
Body stored: content_markdown | content_html
Drafts: supported | not supported (posts publish on arrival)
Scheduling: supported | not supported
plus anything I still have to do by hand (deploying, adding the env variable on the host).
I will paste the URL and the token into blogwriter: Settings -> The client's CMS -> Webhook, or
`blogwriter cms setup webhook`.