Documentation

The whole API,
on one page.

Seven read-only endpoints, fourteen block types, and a schema that is versioned. If you read to the bottom you have read all of it.

v1 — stablebase https://api.blogkit.cosdk optional

Quickstart

Three steps, and the third one is your own components.

01
Create a key

In the panel, open API keys and create a live key. It is shown once — we store only a hash. Put it in an environment variable; a key in a client bundle is a public key.

02
Fetch a post

One request returns the post and every block in it. There is no second call for content.

curl https://api.blogkit.co/v1/blog/hello \
  -H "Authorization: Bearer bk_live_xxxxxxxxxxxxxxxxxxxxxx"
03
Map blocks to components

Nothing is generated and nothing is injected. A block type your renderer has not met yet returns null instead of throwing.

renderer.jsx
const map = { heading: H, paragraph: P, image: Img, code: Code }

export function Post({ blocks }) {
  return blocks.blocks.map(b => {
    const C = map[b.type]
    return C ? <C key={b.id} {...b.data} /> : null
  })
}

Authentication

A bearer token on every request. Keys belong on your server, always — the API allows any origin for GET precisely because CORS is not the security boundary. The key is.

Authorization: Bearer bk_live_xxxxxxxxxxxxxxxxxxxxxx
Accept: application/json
bk_live_…

Published posts only — a draft is a 404, so nothing leaks by accident. Cached at the edge, safe for high traffic.

bk_test_…

Published posts and drafts. 30 requests a minute, never counted against your quota. For local dev and staging.

Rotation

Keys are stored as a SHA-256 hash and shown once at creation. There is no “reveal” — if a key is lost or leaked, revoke it and mint another. Revocation takes effect within a minute everywhere. The failure codes are under errors.

Endpoints

Seven, all read-only. Every response carries an ETag — pass If-None-Match and you get a 304 for free.

GET/v1/blogspaginated list
Query
page · limitlimit 1–100, default 20
sort-published_at default
category · authorby slug, AND’d
tagrepeatable, OR’d
includecsv; blocks never default
fieldscsv; id always survives
200
{
  "data": [{ "slug", "title", "excerpt", "readingTime" }],
  "meta": { "page": 1, "total": 42, "hasNext": true }
}
GET/v1/blog/:slugone post, all blocks
{
  "id": "pst_v1Kd9",
  "slug": "why-we-moved-our-blog-to-the-edge",
  "title": "Why we moved our blog to the edge",
  "readingTime": 4,
  "publishedAt": "2026-07-27T17:29:59.000Z",
  "author": { "name": "Ayesha K.", "slug": "ayesha-k" },
  "tags": [{ "name": "edge", "slug": "edge" }],
  "blocks": { "version": 1, "blocks": [ /* see Block schema */ ] },
  "toc": [{ "id": "blk_h1a2", "level": 2, "anchor": "what-we-changed" }],
  "revision": 7
}
GET/v1/blog/:slug/relatedshared tags, then categories
GET/v1/searchfull-text, highlighted snippets
GET/v1/categorieswith published counts
GET/v1/tagsrenames resolve for 30 days
GET/v1/media/:idone image, every variant

A renamed slug keeps resolving for 30 days and the response carries renamedTo, so your site can 301 and the search ranking transfers instead of dropping. The same holds for a renamed category or tag.

Block schema

A post body is a JSON document: { version: 1, blocks: [...] }. Every block has an id, a type and a typed data object. That is the whole grammar.

A document
{
  "version": 1,
  "blocks": [
    { "id": "blk_h1a2", "type": "heading",
      "data": { "level": 2, "text": "What we changed", "anchor": "what-we-changed" } },
    { "id": "blk_p9x1", "type": "paragraph",
      "data": { "html": "We cut p95 from <strong>840ms</strong> to <strong>34ms</strong>." } },
    { "id": "blk_img4", "type": "image",
      "data": { "mediaId": "med_8Kd2", "alt": "The new architecture", "align": "wide",
                "image": { "url": "…", "width": 1600, "height": 900,
                           "variants": { "thumb": "…", "card": "…", "hero": "…" },
                           "placeholder": "data:image/webp;base64,…" } } }
  ]
}
typedata, briefly
headinglevel 2–4 · text · stable anchor
paragraphhtml — inline markup from a strict whitelist
liststyle bullet | number | check, nests one level
imagealt · caption · align · hydrated image with variants
galleryup to 30 images, layout grid | carousel, 2–4 columns
codelanguage · code · filename — never pre-highlighted
quotehtml · optional cite and citeUrl
calloutvariant info | success | warn | danger
embedprovider · https-only url · aspectRatio
tablerows of rich-text cells, optional header row
dividerstyle line | dots | space
ctalabel · scheme-checked href · variant · alignment
newslettera form that POSTs to your URL — we never collect emails
columns2–3 columns of leaf blocks; cannot nest itself

When we add a fifteenth type, old copies of the package skip it instead of throwing — and if you walk the JSON yourself you should do the same: switch on type, default to nothing. You upgrade when you want the feature, not when we ship it. Inline HTML in rich-text fields is sanitised on write by re-serialisation, so the stored string can only contain whitelisted tags.

Block options

Every type carries a few optional presentation fields, set from the block’s options panel in the editor. They are enums and numbers, never CSS — a closed set validated on write, which is what lets them mean something whether you render our HTML or walk the JSON with your own components.

typeoptions
headingalign
paragraphalign · lead
listspacing
imagewidth · height · fit · rounded · shadow
gallerygap · rounded
codewrap
quotealign · size
calloutcompact
embedrounded
tablestriped · density · layout · per-column align
dividerspacing
ctasize · fullWidth
newsletterlayout · align
columnsgap · valign
Rendered
<table class="bk-table" data-striped data-density="compact">
  <thead><tr><th data-align="left">Region</th><th data-align="right">p95</th></tr></thead>
  …
</table>

<figure class="bk-image bk-image--wide" data-rounded>
  <img src="…" alt="The new architecture" width="400" height="225" loading="lazy">
</figure>

renderBlocks emits an enum as data-name="value" and a true as a bare data-name. An option the author never set emits nothing at all, so [data-striped] selects the tables that opted in and no others. An image’s width and height become the <img> attributes of the same name — set one and the other is derived from the intrinsic ratio, so the browser still reserves the right box.

Rendering

Two ways in: a React component, or a function that returns an HTML string. Both come from the same package and produce the same markup.

import { BlogContent } from 'blogkit/react'

<BlogContent blocks={post.blocks} />

Overriding a block

A custom renderer receives exactly { block, data, children }. That is the whole contract, and it is deliberately small so it can stay stable across schema versions.

<BlogContent
  blocks={post.blocks}
  renderers={{
    image: ({ data }) => (
      <Image
        src={data.image.variants.hero}
        alt={data.alt}
        width={data.image.width}
        height={data.image.height}
        placeholder="blur"
        blurDataURL={data.image.placeholder}
      />
    ),
    code: ({ data }) => <MyShikiBlock code={data.code} lang={data.language} />,
  }}
/>

Theming

The stylesheet is optional, small and scoped. Your styles win by default because ours barely exist.

option one
Use the stylesheet

One import, readable defaults, everything scoped under .bk-content. No resets, no globals, nothing with !important.

option two
Style the classes

Skip the import and target the classes directly. Every block renders with a bk- class, and the prefix is configurable if it collides.

option three
Replace the block

For anything structural, override the renderer — your component, your markup, no classes of ours involved at all.

.bk-content { font-size: 17px; line-height: 1.75; }
.bk-heading { font-family: var(--your-display-face); }
.bk-quote   { border-left: 3px solid var(--your-accent); }

Caching

The API is built to be hit through its cache, and the pricing is built so cached traffic barely counts.

write
Publish invalidates

Publishing, unpublishing or editing a live post drops that project’s cache within seconds. s-maxage=60 is the worst case, not the wait.

read
Warm on first hit

The next request repopulates the edge. p95 is 34 ms after that, from every region we serve.

revalidate
Or ask again

A post’s ETag is its id plus revision — send If-None-Match and an unchanged post answers 304 with no body.

Cache-Control: public, s-maxage=60, stale-while-revalidate=86400
ETag: "pst_v1Kd9-r7"

X-Blogkit-Cache: HIT

These headers do nothing on their own in Node. fetch there has no HTTP cache, so s-maxage is addressed to a shared cache that is not in the path, and every render becomes an origin request. Give the client somewhere to put the response — fetchOptions for your framework’s data cache, or cache: true for an in-process one that revalidates with If-None-Match and serves stale if we are unreachable. If you see MISS on every request you are probably varying the URL: parameter order does not matter, but extra parameters do.

Errors

One envelope for every failure. code is the contract and safe to switch on; message is for your logs and may change wording.

{
  "error": {
    "code": "post_not_found",
    "message": "No published post with slug \"missing\".",
    "details": { "slug": "missing" },
    "docs": "https://blogkit.co/docs#errors",
    "requestId": "req_8fk2Lm1q"
  }
}
401invalid_api_key
401key_revoked
403forbidden_scope
404post_not_found
422invalid_query
429rate_limited
429plan_limit_reached
500internal_error

The npm client throws typed errors — BlogkitNotFound, BlogkitRateLimited, BlogkitInvalidKey — so you can catch precisely. A 5xx is ours and is never cached; quote the requestId if you write in.

Rate limits

Counted per project, per minute — minting more keys does not buy throughput. A cache hit at the edge counts as a fifth of a request, so in practice the ceiling is about your build, not your traffic.

Free
60per minute

5k / mo · Enough to wire up a site and see it render.

Starter
300per minute

50k / mo · A personal site, with a preview route.

Pro
600per minute

500k / mo · A product blog that ships weekly, with previews.

Advanced
1,200per minute

2M / mo · Per project, not per key. Raise it by asking.

A 429 carries Retry-After, and the npm client honours it and retries a GET once. Over quota we keep serving to 110% while emailing you at 80% and 100% — only past that grace band do you see plan_limit_reached. Any bk_test_ key is capped at 30 a minute and never counted at all.

That is the whole API.

Nothing further to read. Updated 30 Jul 2026.