What you are building
A small REST API over your posts table. blogwriter uses it to find a post, create it, or update it.
Posts arrive as drafts. Nothing goes live until a person publishes it.
Authentication
A long-lived secret, sent as a bearer token:
Authorization: Bearer sk_live_a1b2c3d4e5f6 Content-Type: application/json
Missing or wrong key: 401. Never accept the key in a query string.
Do not redirect your API paths. A redirect between example.com and www.example.com drops the Authorization header, so every write fails. Serve /api/v1/* at the canonical host.
The post object
snake_case fields. Return every field you store, even when it is null.
{
"id": "post_9f2ab41c",
"object": "post",
"created": 1788267336,
"updated": 1788267336,
"title": "Gym Schedule and Timetable Templates",
"slug": "gym-schedule-timetable",
"status": "draft",
"content": "Someone types \"gym schedule template\" into...",
"content_format": "markdown",
"excerpt": "How to build a class timetable that members turn up to.",
"cover_image_url": "https://cdn.example.com/hero.png",
"tags": ["gym", "scheduling"],
"author": "The Editorial Team",
"published_at": null,
"canonical_url": null,
"meta_title": null,
"meta_description": null,
"url": "https://example.com/blog/gym-schedule-timetable"
}
| Field | Type | Notes | |
|---|---|---|---|
id | string | required | Stable and opaque. Any format; never reused. |
object | string | required | Always "post". |
title | string | required | Plain text, no markup. |
slug | string | required | Unique. blogwriter matches posts on it. |
status | enum | required | "draft" or "published". Nothing else. |
content | string | required | The body, in the format you declare below. |
content_format | enum | required | "markdown" or "html": whichever you store. |
created | integer | required | Unix seconds. |
updated | integer | optional | Unix seconds. |
excerpt | string | optional | Summary. Doubles as the meta description. |
cover_image_url | string | optional | Absolute URL. See Images. |
tags | string[] | optional | Plain strings. |
author | string | optional | A display name. |
published_at | string | optional | ISO 8601, or null while it is a draft. |
url | string | optional | Where the post will live. Saves the writer constructing it. |
Two status words only: draft and published.
Endpoints
Updates use POST, not PATCH.
| Path | ||
|---|---|---|
GET | /api/v1/posts | List posts. Filterable by slug and status. |
POST | /api/v1/posts | Create a post. Returns 201 and the object. |
GET | /api/v1/posts/{id} | Retrieve one post. |
POST | /api/v1/posts/{id} | Update a post. Only sent fields change. |
POST | /api/v1/images | Optional. Accept an image, return a URL. |
List posts
GET /api/v1/posts?slug=gym-schedule-timetable
{
"object": "list",
"data": [ { /* post objects */ } ],
"first_id": "post_9f2ab41c",
"last_id": "post_9f2ab41c",
"has_more": false
}
| Parameter | Type | Notes | |
|---|---|---|---|
slug | string | required | Exact match. Returns zero rows or one. |
status | enum | optional | Filter to draft or published. |
limit | integer | optional | 1–100, default 20. |
after | string | optional | A post id. Returns the page after it. |
Honour the filter, and include drafts. If ?slug= is ignored, or drafts are hidden from the key that wrote them, every push creates a duplicate.
Create a post
POST /api/v1/posts → 201 Created
{
"title": "Gym Schedule and Timetable Templates",
"slug": "gym-schedule-timetable",
"status": "draft",
"content": "Someone types...",
"excerpt": "How to build a class timetable that members turn up to.",
"cover_image_url": "https://cdn.example.com/hero.png",
"tags": ["gym"]
}
No status means draft. A slug that is taken returns 409 with code: "slug_taken".
Update a post
POST /api/v1/posts/post_9f2ab41c → 200 OK
{
"content": "A corrected opening paragraph...",
"cover_image_url": "https://cdn.example.com/hero-v2.png"
}
Only the fields you send change.
Never change status unless it is sent. A live post reset to draft starts answering 404. The same goes for published_at and the slug.
Rendering embeds in the body
content is Markdown with some HTML blocks for videos, social posts and live charts:
<figure class="bw-embed bw-embed-tv-chart" style="margin:2rem 0">
<iframe src="https://s.tradingview.com/widgetembed/?symbol=BYBIT%3AHYPEUSDT&interval=D" title="BYBIT:HYPEUSDT live chart"
loading="lazy" referrerpolicy="strict-origin-when-cross-origin" style="display:block;width:100%;border:0;aspect-ratio:16/10"></iframe>
<figcaption style="font-size:.875rem">Live chart · <a href="https://www.tradingview.com/symbols/BYBIT-HYPEUSDT/">BYBIT:HYPEUSDT on TradingView</a></figcaption>
</figure>
Render them, do not strip them. Allow these, only inside <figure class="bw-embed …">:
| Element | Attributes |
|---|---|
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 |
Iframes must be https:// and come from these hosts:
Kind (bw-embed-…) | Frame host |
|---|---|
youtube | www.youtube-nocookie.com, www.youtube.com |
x | platform.twitter.com |
tv-chart, tv-price | s.tradingview.com |
reddit | www.redditmedia.com |
instagram | www.instagram.com |
website | any https host, framed with sandbox when you add one |
bw-embed-link is a plain link card. Keep the style attributes: they size the embed. If the site sends a Content-Security-Policy, its frame-src must list these hosts.
Errors
Show
The OpenAI error shape, with a matching HTTP status. Never a 200 with an error inside.
409 Conflict
{
"error": {
"message": "A post with slug 'gym-schedule-timetable' already exists.",
"type": "invalid_request_error",
"param": "slug",
"code": "slug_taken"
}
}
| Status | When |
|---|---|
400 | Bad body or missing field. Name it in param. |
401 | Missing or invalid key. |
404 | No post with that id. |
409 | Slug already taken. |
413 | Body too large. Say the limit. |
422 | Valid but rejected, e.g. an unknown author. |
429 | Rate limited. Send Retry-After. |
Say what was wrong and what would be accepted.
Images
Show
Doing nothing works: images arrive as absolute public URLs. Store the URL.
Want them on your own storage? Add the optional upload endpoint:
POST /api/v1/images (multipart/form-data, one "file" part)
{
"object": "image",
"id": "img_4c1f88",
"url": "https://cdn.example.com/blog/4c1f88.png",
"bytes": 148213
}
Accept PNG, JPEG, WebP and SVG. Diagrams are SVG.
Reference implementation
Show
Next.js route handlers. Same shape in Express, Laravel, Rails or Django.
// app/api/v1/posts/route.ts
// Errors in one place, so every path answers the same shape.
const fail = (status: number, message: string, code: string, param: string | null = null) =>
Response.json({ error: { message, type: "invalid_request_error", param, code } }, { status });
const authed = (req: Request) =>
req.headers.get("authorization") === `Bearer ${process.env.BLOG_API_KEY}`;
const shape = (row: any) => ({
id: row.id, object: "post",
created: Math.floor(+new Date(row.created_at) / 1000),
updated: Math.floor(+new Date(row.updated_at) / 1000),
title: row.title, slug: row.slug, status: row.status,
content: row.content, content_format: "markdown",
excerpt: row.excerpt, cover_image_url: row.cover_image_url,
tags: row.tags ?? [], author: row.author, published_at: row.published_at,
url: `https://example.com/blog/${row.slug}`
});
export async function GET(req: Request) {
if (!authed(req)) return fail(401, "Missing or invalid API key.", "invalid_api_key");
const { searchParams } = new URL(req.url);
// The slug filter has to actually filter, and drafts stay visible to this key.
const rows = await db.posts.findMany({
where: {
...(searchParams.get("slug") ? { slug: searchParams.get("slug")! } : {}),
...(searchParams.get("status") ? { status: searchParams.get("status")! } : {})
},
take: Math.min(Number(searchParams.get("limit") ?? 20), 100),
orderBy: { created_at: "desc" }
});
const data = rows.map(shape);
return Response.json({
object: "list", data,
first_id: data[0]?.id ?? null,
last_id: data[data.length - 1]?.id ?? null,
has_more: false
});
}
export async function POST(req: Request) {
if (!authed(req)) return fail(401, "Missing or invalid API key.", "invalid_api_key");
const body = await req.json();
for (const f of ["title", "slug", "content"])
if (!body[f]) return fail(400, `${f} is required.`, "missing_field", f);
const status = body.status ?? "draft"; // draft unless asked otherwise
if (!["draft", "published"].includes(status))
return fail(400, `status must be "draft" or "published".`, "invalid_status", "status");
if (await db.posts.findUnique({ where: { slug: body.slug } }))
return fail(409, `A post with slug '${body.slug}' already exists.`, "slug_taken", "slug");
const row = await db.posts.create({ data: { ...body, status } });
return Response.json(shape(row), { status: 201 });
}
If your host redirects apex ↔ www, exclude /api/ from that rule.
Before you ship
Show
- [ ] No key, or a key in the query string:
401. - [ ]
/api/v1/postsanswers with no redirect. - [ ]
?slug=returns only that post. - [ ] Drafts show up in the list for the same key.
- [ ] Updating a live post without
statuskeeps it live. Check the live URL. - [ ] That update leaves
published_atand the slug alone. - [ ] A post with no
statusis a draft and is not public. - [ ] Sending only
contentkeeps the tags. - [ ] A duplicate slug returns
409. - [ ] Every error has the right status and names the field.
- [ ] An SVG cover renders.
- [ ] A
bw-embedblock renders as a frame, not just its caption.
Connecting it
Once the API is live, run blogwriter cms setup, or use Settings → The client's CMS in the app. A site built to this spec needs no field mapping.
Or a webhook
Want the least code? blogwriter can POST each post to one webhook URL instead. See the webhook spec, which has its own AI prompt.
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 endpoint and key for blogwriter.
You are working in the repository of a website with a blog. Build a small REST API so blogwriter,
an AI writing tool, can publish posts into this site, 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.
If posts are Markdown files in the repo, an API on a serverless host cannot write to them; tell me,
and propose a small database table (or the platform's KV/blob store) that the API writes and the
blog pages also read.
STEP 1: THE POSTS STORE
Each post needs these fields. Add only the ones that are missing, and map existing columns to these
names in the API rather than renaming them:
id (stable, opaque, never reused), title, slug (UNIQUE index), status ("draft" or "published",
default "draft"), content, created_at, updated_at, and nullable: excerpt, cover_image_url, author,
published_at, canonical_url, meta_title, meta_description; tags as a list of strings.
The unique index on slug is what makes the 409 below true: a check-then-insert races.
Keep published_at as its own column; never derive status from it.
STEP 2: THE API KEY
Generate a random key (e.g. `sk_live_` + `openssl rand -hex 24`). Put it in the local env file as
BLOG_API_KEY 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.
STEP 3: THE API
Authentication, on every route:
- `Authorization: Bearer <BLOG_API_KEY>`, compared in constant time. Missing or wrong: 401.
- Never accept the key in a query string.
- /api/v1/* must answer at the canonical host with no redirect: a redirect between example.com and
www.example.com drops the Authorization header, and every write then fails as 401 while reads
seem fine. Exempt /api/ from any apex <-> www redirect, or tell me which host to use.
- Exclude these routes from auth middleware, CSRF protection or bot protection meant for browsers.
The post object. Field names are snake_case. Return every field you store, even when null:
{
"id": "post_9f2ab41c", "object": "post",
"created": 1788267336, "updated": 1788267336, (unix SECONDS, integers)
"title": "Gym Schedule and Timetable Templates",
"slug": "gym-schedule-timetable",
"status": "draft", ("draft" or "published", nothing else)
"content": "Someone types \"gym schedule template\" into...",
"content_format": "markdown", ("markdown" or "html": whichever you store)
"excerpt": "How to build a class timetable that members turn up to.",
"cover_image_url": "https://cdn.example.com/hero.png", (absolute)
"tags": ["gym", "scheduling"], (plain strings)
"author": "The Editorial Team", (a display name)
"published_at": null, (ISO 8601, or null while a draft)
"canonical_url": null, "meta_title": null, "meta_description": null,
"url": "https://example.com/blog/gym-schedule-timetable" (where the post lives or will live)
}
Endpoints (updates use POST, not PATCH):
GET /api/v1/posts List. Query: slug (exact match, returns 0 or 1 rows), status (draft|
published), limit (1–100, default 20), after (a post id: the page after
it). Response:
{"object":"list","data":[...post objects],"first_id":"...",
"last_id":"...","has_more":false}
POST /api/v1/posts Create. Returns 201 and the post object.
GET /api/v1/posts/{id} One post, or 404. Also accept the slug in place of the id.
POST /api/v1/posts/{id} Update. Only the fields present in the body change. Returns 200 and
the post object. Accept PATCH on the same path too, doing the same.
Accept the slug in place of the id here as well.
POST /api/v1/images Optional. multipart/form-data with one "file" part; accept image/png,
image/jpeg, image/webp and image/svg+xml; store it and return
{"object":"image","id":"img_...","url":"https://...","bytes":148213}.
Skip this if the site has no file storage: posts arrive with their
images already hosted.
Rules that have broken real integrations. Each one is required:
- ?slug= must actually filter. An API that ignores it and returns everything looks fine until the
list is longer than one page, and then every push creates a duplicate.
- Drafts must be returned to the authenticated key, in the list and by id. Hide drafts from the
public site, never from the API key that wrote them.
- A create with no status makes a draft. A draft never appears on the public site.
- Required on create: title, slug, content. Missing one: 400 naming it.
- A create with a slug that already exists: 409 with code "slug_taken". Never create a second post
with a suffixed slug.
- An update that does not send status leaves status exactly as it was. Never default it, never reset
it to draft, never infer it from published_at. A published article turned back into a draft is a
live URL that starts answering 404. The same update leaves published_at and the slug alone unless
they are sent. Sending only content leaves the tags alone.
- When a post becomes "published" and published_at is empty, set it to now.
- An update that changes the slug of a published post adds a 301 redirect from the old address.
- If the body sends published_at in the future with status "published", hold it as scheduled and
publish it then (use the site's cron/scheduled jobs, or have the public pages treat it as
published once the time has passed), and return published_at so the caller can see it was kept.
- Errors use one envelope and a matching HTTP status; never a 200 carrying an error:
{"error":{"message":"A post with slug 'x' already exists.","type":"invalid_request_error",
"param":"slug","code":"slug_taken"}}
400 malformed body / unknown status / missing field (name it in param); 401 bad key; 404 no such
post; 409 slug taken; 413 body too large (say the limit); 422 well-formed but rejected (e.g. an
unknown author); 429 rate limited with Retry-After. Write each message for a person reading a
terminal: say what was wrong and what would be accepted.
- After a create or update, make it visible without a redeploy: revalidate/purge the blog index,
the post page, the sitemap and the RSS feed if the site caches them.
STEP 4: THE PUBLIC SITE
- The blog index, the post page, the sitemap and the RSS feed show only published posts (and
scheduled ones whose time has passed). Drafts are never public.
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 exercise the API with curl (replace KEY and the port). Show me each result.
a) No key: GET /api/v1/posts -> 401.
b) Create a draft:
curl -i -X POST http://localhost:3000/api/v1/posts -H 'Authorization: Bearer KEY' \
-H 'Content-Type: application/json' \
-d '{"title":"API test","slug":"api-test","content":"> **In short:** it works.\n\n## First\n\nHello.","excerpt":"A test.","tags":["test"]}'
-> 201, "status":"draft", not visible on /blog.
c) GET /api/v1/posts?slug=api-test -> exactly that one post, draft included.
d) The same create again -> 409 slug_taken.
e) POST /api/v1/posts/<id> with {"status":"published"} -> live at /blog/api-test.
f) POST /api/v1/posts/<id> with only {"content":"Changed."} -> still published, tags unchanged,
published_at unchanged. Check the live URL, not just the response.
g) POST /api/v1/posts with {"title":"x","slug":"y","content":"z","status":"live"} -> 400 naming status.
h) Create THE TEST POST above as a published post 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)–(g).
DONE WHEN
- Every item below is true, and every existing post renders exactly as it did before:
[ ] An unauthenticated request gets 401, and the key is never read from a query string.
[ ] /api/v1/posts answers directly at the canonical host, with no redirect.
[ ] ?slug= returns only the matching post, not the whole table.
[ ] A draft created through the API comes back in a list request made with the same key.
[ ] Updating a published post without sending status leaves it published, and leaves
published_at and the slug alone.
[ ] A post created with no status is a draft, and does not appear on the public site.
[ ] Sending only content to the update endpoint leaves the tags intact.
[ ] A duplicate slug returns 409, not a second post with a suffix.
[ ] Every error has a matching HTTP status and a message naming the field.
[ ] An SVG cover image renders rather than being stripped.
[ ] A bw-embed block renders as a frame on the live page, not as its caption alone.
- You have told me, at the end, in this exact form:
Endpoint: https://<the production domain>/api/v1/posts
API key: <the key> (also set BLOG_API_KEY on the host)
Body: markdown | html
Scheduling: supported | not supported
plus anything I still have to do by hand (deploying, adding the env variable on the host).
I will connect it in blogwriter with `blogwriter cms setup` (platform "http"), or in the app:
Settings -> The client's CMS -> Other REST, and paste the endpoint and the key.