Blog

List posts

GET /api/v1/blog/posts

Metadata only — no MDX bodies, so it's cheap enough to render an index from one call. Public; a key scopes and rate-limits the request.

ParameterDefaultNotes
projectId—Required unless a key is sent
tag—Exact-match filter on a single tag
limit20Clamped to 1–100
offset0Offset pagination — see Pagination
curl "https://api.inkform.dev/api/v1/blog/posts?projectId=YOUR_PROJECT_ID&tag=engineering&limit=10"
{
  "data": [
    {
      "slug": "hello-world",
      "title": "Hello World",
      "date": "2026-09-05",
      "author": "Charan",
      "tags": ["engineering"],
      "series": null,
      "description": "Why we built this.",
      "coverImage": "https://example.com/cover.png",
      "excerpt": "Our first post.",
      "readTimeMinutes": 4
    }
  ],
  "pageInfo": { "hasNextPage": true, "endCursor": null },
  "totalCount": 37
}

totalCount is the count after the tag filter, so it's the right number for "showing 10 of 37".

Get one post

GET /api/v1/blog/posts/{slug}

Same fields plus body — the MDX with frontmatter stripped.

curl "https://api.inkform.dev/api/v1/blog/posts/hello-world?projectId=YOUR_PROJECT_ID"
{
  "data": {
    "slug": "hello-world",
    "title": "Hello World",
    "body": "Our first post.\n\n## Why\n\nBecause..."
  }
}

Returns 404 for an unknown slug, and for a slug that exists but isn't published — drafts are never served here.

Create or update a post

POST /api/v1/blog/posts

Requires a secret key with content:write. Writes a commit to your connected repo; the response carries the commit URL. Posting an existing slug updates that post.

{
  "frontmatter": {
    "title": "Hello World",
    "slug": "hello-world",
    "date": "2026-09-05",
    "author": "Charan",
    "tags": ["engineering"],
    "status": "published",
    "series": null,
    "description": "Why we built this.",
    "coverImage": null,
    "ogImage": null,
    "ogTitle": null
  },
  "doc": {
    "blocks": [
      { "id": "1", "type": "raw", "mdx": "## Why\n\nBecause the old way hurt." }
    ]
  }
}

Only title is required. slug is derived from the title if omitted, date defaults to today, and status defaults to published.

The raw block

doc.blocks is the structured block model the editor uses. If you just have markdown, the raw block is the documented escape hatch — one block, your MDX, done:

{ "id": "1", "type": "raw", "mdx": "# Anything\n\nPlain markdown or MDX." }

Structured block types (paragraph, heading, list, code, and others) exist for callers building an editor UI. For programmatic publishing, raw is almost always what you want.

curl -X POST "https://api.inkform.dev/api/v1/blog/posts" \
  -H "Authorization: Bearer inkform_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "frontmatter": { "title": "Shipped: faster builds" },
    "doc": { "blocks": [{ "id": "1", "type": "raw", "mdx": "Builds are 40% faster." }] }
  }'
{
  "data": {
    "slug": "shipped-faster-builds",
    "fileName": "shipped-faster-builds.mdx",
    "commitUrl": "https://github.com/you/repo/commit/abc123"
  }
}

A write invalidates the read cache immediately, so the post is fetchable on the next request rather than after a TTL.

Write errors

StatusCause
400Body failed validation — the message names the offending fields
403Key lacks content:write
404No blog project for this key
409Project has no connected GitHub repository