JSON API

All request paths in the tables below are relative to /blog/api/v1, or the corresponding path below your configured mount.

Authentication

Create a token with NAME="Publishing client" SCOPES=read,write,publish bin/rails open_blog:token and keep the printed secret: only its digest is stored. Send it as Authorization: Bearer ob_…. EXPIRES_AT accepts an ISO 8601 timestamp; revoke a token by setting its revoked_at. A configured config.authenticate callback replaces token authentication and returns an actor with name and optional scopes and id.

Requests without a valid token return 401:

curl -s https://example.com/blog/api/v1/posts
{"error":{"code":"unauthenticated","message":"An authenticated actor is required.","details":[]}}

First post

Save a draft, publish it, then try an edit without a change type. Set TOKEN to the printed secret.

curl -s -X POST https://example.com/blog/api/v1/posts \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"title":"Winter garden","body":"Protect the young trees.","external_id":"garden-17"}'

The response is 201 with the full post and the write envelope. Nothing is recorded for a draft, so every record value is null:

{
  "post": {
    "id": 228,
    "slug": "winter-garden",
    "url": "https://example.com/blog/winter-garden",
    "status": "draft",
    "title": "Winter garden",
    "body_format": "markdown",
    "body": "Protect the young trees.",
    "revision_identifier": "95fde1cf890a2a8f994cbfa19ddbaa10caf5e03a5f61585ad0ce288dfea59404",
    "public_revision_identifier": null,
    "preview_url": "https://example.com/blog/preview/eyJfcmFpbHMi…",
    "label": "ai_unknown",
    "...": "see Response objects"
  },
  "created": true,
  "records": {"revision": null, "publication": null, "approval": null},
  "label": "ai_unknown",
  "findings": [
    {
      "code": "description_absent",
      "rule": "T8",
      "message": "Add a description for readers and search results.",
      "location": "description"
    },
    {
      "code": "provenance_unknown",
      "rule": "E18",
      "message": "Specify whether AI contributed to this post.",
      "location": "provenance"
    }
  ]
}
curl -s -X POST https://example.com/blog/api/v1/posts/228/publish \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
{
  "post": {"id": 228, "slug": "winter-garden", "status": "published", "public_revision_identifier": "95fde1cf…", "...": "..."},
  "created": false,
  "records": {"revision": "new", "publication": "first", "approval": null},
  "label": "ai_unknown",
  "findings": ["..."]
}

A public post keeps its history. An edit that changes the public revision must say what kind of change it is, or it is refused with 422 and nothing is written:

curl -s -X PATCH https://example.com/blog/api/v1/posts/228 \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"body":"Protect the young trees with fleece."}'
{"error":{"code":"change_type_required","message":"This update changes the public revision. Send change: substantive, correction or maintenance.","details":[]}}

Add "change":"substantive" to the same request and it returns 200 with "publication": "substantive". Read the history afterwards:

curl -s https://example.com/blog/api/v1/posts/228/records -H "Authorization: Bearer $TOKEN"
{
  "revisions": [{"identifier": "95fde1cf…", "actor": "Publishing client", "made_by_ai": null, "created_at": "2026-10-03T20:59:05.711Z"}],
  "approvals": [],
  "publications": [{"entry_type": "first", "occurred_at": "2026-10-03T20:59:05.711Z", "released_by": "Publishing client", "description": null, "note": null, "revision_identifier": "95fde1cf…"}],
  "baseline": null,
  "connections": []
}

Endpoints

Request Purpose Scope
GET /posts, GET /posts/:id List or read posts, including drafts read
POST /posts Create or upsert by external ID or slug write for drafts; publish with publish: true or when editing a scheduled/public post
PATCH /posts/:id Edit while preserving the current publication state write for drafts; publish for scheduled or public posts
POST /posts/:id/publish, POST /posts/:id/unpublish Release or withdraw a post publish
DELETE /posts/:id Delete an unaudited draft or archive a post with history publish
POST /posts/:post_id/approvals, POST /posts/:post_id/connections Append an approval or connection declaration publish
GET /posts/:post_id/records, GET /posts/:post_id/findings, GET /doctor Inspect history, advisory findings, or installation checks read
GET /posts/:post_id/preview Get an unpublished post’s preview link and revision identifier read
POST /images Upload an image or import one from a URL write
GET /categories, GET /tags, GET /authors, GET /series, GET /redirects List supporting records read
POST /categories, POST /authors, POST /series; PATCH their /:id routes Create or edit supporting records write
POST /redirects, DELETE /redirects/:id Add a URL move or removal, or remove its record publish
POST /adoptions Import one existing article, with optional dry run publish
POST /faq_extractions Propose FAQ pairs and source ranges without saving read
GET /pages, GET /pages/:kind List or read policy pages, including drafts read
PUT /pages/:kind Create or edit a policy page write; also publish when publishing or changing a published page
GET /report Fetch a surface report read
GET /posts/:id/views, GET /views/top Page-view reports read

Post IDs in these routes can also be slugs. Send JSON fields directly at the top level. Publish a draft with POST /posts/:id/publish and an empty JSON object, or create and publish in one request with "publish": true. Updating public content requires the change classification. PATCH preserves omitted fields; supplied FAQ and tag arrays replace their lists. Unsupported fields return an error rather than being silently discarded.

The list endpoint accepts status, category, tag, author, series, q, page, and per_page; pagination defaults to 25 and permits at most 100 posts per page.

curl -s "https://example.com/blog/api/v1/posts?status=published&per_page=2" -H "Authorization: Bearer $TOKEN"
{
  "posts": [
    {"id": 228, "slug": "winter-garden", "url": "https://example.com/blog/winter-garden", "status": "published", "title": "Winter garden",
     "description": "", "author": {"id": 405, "name": "Ada Example", "slug": "ada-example", "type": "person", "url": null},
     "category": null, "tags": [], "published_at": "2026-10-03T20:59:05Z", "modified_at": "2026-10-03T20:59:05Z",
     "revision_identifier": "95fde1cf…", "label": "ai_unknown"},
    {"id": 226, "slug": "garden-notes", "...": "..."}
  ],
  "page": 1,
  "per_page": 2,
  "total": 2
}

Individual reads include content, media, revision identifiers, and notices. Writes return {post, created, records, label, findings}; record values are null when no corresponding audit record was made. New posts return 201, scheduled creation or publication returns 202, ordinary updates return 200, and deletion of a draft without retained history returns 204.

Errors return {error: {code, message, details}} with HTTP status 401 for missing authentication, 403 for insufficient scope, 404 for missing records, 409 for identity or revision conflicts, 422 for invalid input, and 429 for rate limits. API responses use Cache-Control: no-store. Requests share config.api_rate_limit per actor across endpoints; use a shared cache store when running multiple application processes.

Response objects

API response fields are explicit:

Object Fields
Post card id, slug, url, status, title, description, author, category, tags, published_at, modified_at, revision_identifier, label
Full post Card fields plus search_title, search_description, body_format, body, series, featured, canonical_url, cover_image, cover_alt, social_image, faq, provenance, provenance_evidence, external_id, publish_at, public_revision_identifier, approved, preview_url, reading_time_minutes, word_count, created_at, updated_at
Author / category / series embedded in a post Author: {id, name, slug, type, url}. Category: {id, name, slug} or null. Series: {id, name, slug, position} or null. Tags are names; FAQs contain {question, answer}.
Image image_id, url, sha256, filename, content_type, byte_size, width, height; absent images are null
Write records revision (new, same, or null), publication (entry type or null), approval (kind or null)
History {revisions, approvals, publications, baseline, connections}; lists follow insertion order and an absent baseline is null
Revision identifier, actor, made_by_ai, created_at
Approval kind, revision_identifier, reviewer_name, facts_checked, approved_at, declared_on, declared_by, confirmed_by, evidence, recorded_by
Publication entry_type, revision_identifier, occurred_at, released_by, description, note
Baseline post_id, adopted_at, adopted_revision_id, provenance, provenance_evidence, first_published_at, first_published_evidence, declared_first_published_at, last_modified_at, last_modified_evidence, source_system, source_id, source_body_sha256, adopted_by
Connection declaration post_id, connections (a list of {party, relation}), third_party_paid, declared_by, declared_on, recorded_by
Preview preview_url, revision_identifier, expires_at
Page kind, slug, url, title, body, status, approved_by, approved_on, updated_at
Findings {findings: [...]}; each item has code, rule, message, location
Doctor {checks: [...]}; each item has name, status (ok, warning, or error), message
Category id, name, slug, description, position, posts_count
Tag id, name, slug, posts_count
Author id, name, slug, type, bio, url, profile_urls, avatar (image or null), host_reference, posts_count
Series id, name, slug, description, posts (items with id, slug, title, position)
Redirect id, old_path, new_path, source, occurred_on, post_id
Adoption post, created, records (revision, publication, approval, baseline, redirects), findings, dry_run
FAQ extraction pairs, cut, body_after, leftover, class, reasons, source_body_sha256

approved describes a facts-checked approval for the public revision. Labels are none, ai_assisted, or ai_unknown. Drafts and scheduled posts include a preview URL; public and archived posts return null. Stored body and FAQ text are returned without changing their formatting. Connection writes append a complete declaration; send an empty connections array to declare none. Omitted declaration dates use the operation’s date. Approval requests require revision_identifier, name, and boolean facts_checked:

curl -s -X POST https://example.com/blog/api/v1/posts/228/approvals \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"revision_identifier":"95fde1cf890a2a8f994cbfa19ddbaa10caf5e03a5f61585ad0ce288dfea59404","name":"Casey","facts_checked":true}'

Errors

These error codes are used by the current endpoints:

HTTP status Codes
401 unauthenticated
403 scope_required
404 not_found
409 identity_conflict, slug_reserved, post_is_public, revision_mismatch, already_changed_in_gem
422 validation_failed, unknown_field, change_type_required, correction_note_required, approval_required, approval_incomplete, refused_by_host, body_format_not_permitted, slug_not_supported, image_not_permitted
429 rate_limited

details is always an array. It can identify rejected fields, a required scope, or messages supplied by the host publication hook. No token secret appears in API response objects.

Images

Upload images as multipart file data, or send {"url":"https://images.example.com/photo.png"} to /images. Repeated bytes reuse the same image and URL. Cover, social image, and author avatar inputs accept {image_id: ...}, {signed_id: ...}, or {url: ...}. URL imports verify the file bytes, enforce the configured size limit and a ten-second deadline, allow at most three redirects, and refuse private or other nonpublic addresses at every hop. SVG is refused. config.image_fetch_policy = :open permits internal image servers while retaining the other limits; Doctor reports this setting.

curl -s -X POST https://example.com/blog/api/v1/images \
  -H "Authorization: Bearer $TOKEN" -F "file=@cover.png"

Supporting records and imports

Supporting record lists use the same pagination envelope, with the corresponding plural key. Counts include stored posts, including drafts. Category writes accept name, slug, description, and position; series writes accept name, slug, and description; author writes accept the author fields above except id and posts_count. An avatar attached directly by the host appears as null until its blob is imported as a gem image. Redirect writes accept old_path, new_path (null for removal), optional post_id, and optional occurred_on (defaults to today); API-created redirects have source manual.

POST /adoptions accepts the same snapshot fields as the Ruby adoption operation. dry_run: true returns the proposed content and records without retaining rows or uploaded files. POST /faq_extractions accepts body and optional standalone_questions; it returns proposed text changes without applying them.

Policy pages

PUT /pages/:kind saves one of the four policy pages. See reader pages for the kinds, the fields, and how published pages appear to readers.

curl -s -X PUT https://example.com/blog/api/v1/pages/responsible_party \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"title":"Who runs this blog","body":"My Company, Example Street 1.","status":"published","approved_by":"Casey"}'