For whoever builds your blog API

Publish into your own site

Send this to whoever maintains the site. It is the same document blogwriter cms spec prints, so the page and the tool can never disagree.

Blog Publishing API

A specification for a client's CMS, so blogwriter can file posts into it.

Four endpoints that let an external writing tool put a post into your CMS. Modelled on the OpenAI API, so the conventions are ones your team has already read. A working implementation is an afternoon.

Send this document to whoever maintains the client's site. Nothing here asks them to change how their site renders, stores or schedules anything.


What you are building

A small REST surface over an existing posts table. The tool on the other end writes a post, then needs to do exactly three things: find out whether that post already exists, create it if it does not, and update it if it does. Everything below supports those three moves.

Posts arrive as drafts. Nothing an integration sends should appear on the site until a person publishes it in your own admin. That is the most important behaviour on this page.


Authentication

A long-lived secret, sent as a bearer token — the same scheme as OpenAI, Stripe and GitHub.

Authorization: Bearer sk_live_a1b2c3d4e5f6
Content-Type: application/json

Reject anything without a valid key with 401. Do not accept the key in a query string: it ends up in access logs, CDN logs, and the referrer header of every image on the page.

Do not redirect your API paths. A host that 307s example.com/api/v1/posts to www.example.com/api/v1/posts looks harmless and breaks every authenticated write: the Fetch standard strips the Authorization header when a redirect crosses origins. The failure is confusing because reads usually still work — the integration sends a valid key, the API answers "missing API key", and both sides are correct. Serve /api/v1/* at the canonical host, or exempt it from the redirect rule.


The post object

Field names are snake_case. Return every field you store, even when null: an integration that can see a field can map to it, and one that cannot will guess.

{
  "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"
}
FieldTypeNotes
idstringrequiredStable and opaque. Any format; never reused.
objectstringrequiredAlways "post".
titlestringrequiredPlain text, no markup.
slugstringrequiredUnique. This is the identity an integration matches on.
statusenumrequired"draft" or "published". Nothing else.
contentstringrequiredThe body, in the format you declare below.
content_formatenumrequired"markdown" or "html". Say which you store and a writer will send it.
createdintegerrequiredUnix seconds, as OpenAI does. Not a date string.
updatedintegeroptionalUnix seconds.
excerptstringoptionalSummary text; also the meta description if you have no separate field.
cover_image_urlstringoptionalAbsolute URL. See Images.
tagsstring[]optionalPlain strings. If you need tag IDs, say so in your error.
authorstringoptionalA display name, not an ID, unless you reject unknown names.
published_atstringoptionalISO 8601, or null while it is a draft.
urlstringoptionalWhere the post will live. Saves the writer constructing it.

Two status words, and only two. WordPress accepts publish, not published, and every integration against it needs a special case. If you are designing this now, pick draft and published and stop there.


Endpoints

Four to implement, one optional. Updates use POST, not PATCH, following the OpenAI convention — one fewer method for a proxy or firewall to disagree about.

Path
GET/api/v1/postsList posts. Filterable by slug and status.
POST/api/v1/postsCreate a post. Returns 201 and the object.
GET/api/v1/posts/{id}Retrieve one post.
POST/api/v1/posts/{id}Update a post. Only the fields present in the body change.
POST/api/v1/imagesOptional. 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
}
ParameterTypeNotes
slugstringrequiredExact match. Returns zero rows or one.
statusenumoptionalFilter to draft or published.
limitintegeroptional1–100, default 20.
afterstringoptionalA post id. Returns the page after it.

Honour the filter, and include drafts. These two failures cost the most, and both are silent. An API that ignores ?slug= and returns everything looks like it works, because the client finds its post in the results. It breaks the moment the list is longer than one page. An API that hides drafts from an authenticated caller is worse. The client creates a draft, cannot find it next time, and creates it again — a duplicate on every push, with no error anywhere. Hide drafts from the public site, never from the key that wrote them.

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"]
}

Treat a missing status as "draft". If the slug is taken, return 409 with code: "slug_taken" rather than silently creating a second post with a suffixed slug.

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"
}

A partial update: fields absent from the body are left alone. A writer fixing one paragraph should not have to resend the tags to keep them.

status is the one that must not drift. An update that omits status must leave the post exactly as published or draft as it was. Never default it, never reset it to draft, never infer it from the presence of published_at. This is the most expensive failure on this page. A published article that comes back as a draft is a live URL that starts answering 404 — losing whatever ranking it had, and breaking every link pointing at it from elsewhere. It happens while doing something harmless, like correcting a sentence or adding an internal link, and nothing in the response says so. The same applies to published_at and the slug: an update must not change the address of a page that already exists. An integration cannot protect a client from this on its own. It can send the status back unchanged and read the post afterwards to check, which blogwriter does, but only the API can make it true.


Errors

The OpenAI error envelope, and an HTTP status that matches it. A 200 carrying an error body is the hardest kind of failure to debug.

409 Conflict

{
  "error": {
    "message": "A post with slug 'gym-schedule-timetable' already exists.",
    "type": "invalid_request_error",
    "param": "slug",
    "code": "slug_taken"
  }
}
StatusWhen
400Malformed body, unknown status, missing required field. Name it in param.
401Missing or invalid key.
404No post with that id.
409Slug already taken by a different post.
413Body too large. Say the limit in the message.
422Well-formed but rejected — a tag that does not exist, an unknown author.
429Rate limited. Send Retry-After.

Write the message for the person reading a terminal at midnight. "Tags must be existing tag IDs; 'gym' is not one" gets fixed in a minute. "Invalid request" does not.


Images

Doing nothing is a valid implementation. A post arrives with its pictures already hosted: cover_image_url and any image inside content will be absolute and publicly reachable. Store the URL and render it.

If you would rather the images lived on your own infrastructure, implement the optional upload endpoint and the writing tool will use it.

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 image/png, image/jpeg, image/webp and image/svg+xml. Diagrams are frequently SVG, and a CMS that silently drops them loses the most useful pictures in the post.


Reference implementation

Next.js route handlers. The shape is the same 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 });
}

One deployment note. If your host redirects the apex domain to www (or the reverse), exclude /api/ from that rule. Otherwise authenticated writes fail with a misleading "missing key" while reads keep working.


Before you ship

Each of these has broken a real integration, and each takes a minute to check.

  • [ ] 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. Check the live
  • URL afterwards, not just the response body.

  • [ ] The same update 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 in cover_image_url renders rather than being stripped.

Connecting it

Once the API is live, the writer runs:

blogwriter cms setup

It asks for the site address, detects the platform, asks for the key, writes the config and the credential, and checks the connection. A CMS built to this specification needs no field mapping at all: the names here are the names blogwriter uses.