Quickstart
From a key to a live page.
Four requests: create a draft, preview it, publish it, and read it back. You’ll need a Bloting account.
1. Create an API key
In the app, open Connect and create a key under REST API. Choose only the permissions the key needs. The key is shown once; store it as a secret.
Keys start with pk_live_. Send one as a bearer token on every request.
export API_KEY="pk_live_..."2. Create a draft
Content is Markdown. A leading # Title becomes the page title. Drafts are private. If you have no publication yet, create one with POST /v1/publications first; GET /v1/publications lists the ones you have.
curl https://bloting.com/v1/content \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: draft-2026-10-11-001" \
-d '{
"publication_id": "pub_...",
"markdown": "# How we test releases\n\nEvery release goes through three stages...",
"sources": [{ "url": "https://example.com/release-notes" }]
}'The response is the draft, including its id and the url it will have once published.
3. Preview and validate
Preview runs the same checks the app runs before publishing and returns one of three states: ready, needs_review or blocked.
curl https://bloting.com/v1/publishing/preview \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{ "content_id": "art_...", "check_sources": true }'ready means the technical and structural checks passed. It is not a statement that the content is factually correct.
For a key that can’t publish, the publishing_permissions check is the one that blocks, and the response has requires_approval: true. Publishing with that key creates an approval request instead.
4. Publish
curl https://bloting.com/v1/publishing/publish \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: publish-art_...-rev1" \
-d '{ "content_id": "art_..." }'There are two successful outcomes, depending on the key’s permissions:
| Status | When | Body |
|---|---|---|
200 | The key has content:publish. | status: "published" with the live url. |
202 | The key can't publish on its own. | status: "approval_required" with an approve_url for the workspace owner. |
If validation returned needs_review, a key won’t publish the page unless you pass "acknowledge_review": true, which says a person has looked at the notes. blocked pages can’t be published until the problem is fixed.
A published page is live straight away. Search engines index pages on their own schedule, so the response reports indexing: "not_observed" rather than claiming otherwise.
Authentication and permissions
Every request carries Authorization: Bearer <credential>. A credential is an API key or an OAuth 2.1 access token issued to an agent you connected. Both are limited to one workspace and to these permissions:
| Permission | Allows |
|---|---|
publications:read | Read publications |
content:read | Read content |
drafts:create | Create drafts |
drafts:edit | Edit drafts |
content:publish | Publish content |
content:delete | Delete content |
analytics:read | Read analytics |
revenue:read | Read revenue |
Deleting content needs content:delete. Without it, DELETE /v1/content/{id} returns 403; deletion is never queued for approval.
Errors
Every error has the same shape. message is written for people and safe to display; branch on code.
{
"error": {
"code": "validation_error",
"message": "\"markdown\" is required.",
"field": "markdown"
}
}| HTTP | code | Meaning |
|---|---|---|
| 401 | unauthorized | The credential is missing, revoked or expired. |
| 403 | forbidden | The credential lacks the permission this request needs. |
| 404 | not_found | No such resource in your workspace. |
| 409 | conflict | The request conflicts with the current state, such as a taken handle. |
| 422 | validation_error | A field is missing or invalid. error.field names it when known. |
| 422 | blocked | Pre-publish checks stopped the page. error.validation lists them. |
| 422 | idempotency_mismatch | The Idempotency-Key was already used with a different body. |
| 402 | limit_reached | Your plan's limit for this resource has been reached. |
| 429 | rate_limited | Too many requests. Wait for the Retry-After seconds. |
| 500 | internal_error | Something failed on our side. |
Pagination
GET /v1/content returns newest first, up to limit items (default 30, maximum 100). When has_more is true, pass next_cursor as cursor to get the next page. Cursors are opaque.
curl "https://bloting.com/v1/content?status=published&limit=50&cursor=$NEXT_CURSOR" \
-H "Authorization: Bearer $API_KEY"Idempotency
Send an Idempotency-Key header on POST requests that create or publish. If the same key arrives again with the same body within 24 hours, you get the original response with Idempotent-Replayed: true, and nothing is created twice. The same key with a different body returns 422 idempotency_mismatch. A request that failed releases its key, so you can retry with it.
Rate limits
Limits apply per credential, per minute: 60 on Free, 120 on Creator, 300 on Pro. Every response reports where you stand:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 1791646140
X-API-Version: 1Over the limit, the API answers 429 with a Retry-After header in seconds. Publishing has its own daily cap per plan.
Webhooks
Register an https endpoint with POST /v1/webhooks and choose events: content.published, content.updated, content.unpublished, content.deleted, approval.requested. The signing secret is returned once, when the webhook is created.
Each delivery is a POST with a JSON body and these headers. Failed deliveries are retried with increasing delays.
POST /your-endpoint
X-Webhook-Id: whd_...
X-Webhook-Event: content.published
X-Webhook-Signature: t=1791646140,v1=5f2b...
{
"id": "whd_...",
"type": "content.published",
"created": "2026-10-11T09:00:00.000Z",
"data": { "id": "art_...", "url": "https://...", "title": "...", "publication_id": "pub_..." }
}Verify the signature before trusting a delivery: compute HMAC-SHA256 of {t}.{raw body} with your secret, compare it to v1 in constant time, and reject timestamps older than five minutes.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, rawBody, header, toleranceSeconds = 300) {
const t = Number(/t=(\d+)/.exec(header)?.[1]);
const v1 = /v1=([0-9a-f]+)/.exec(header)?.[1];
if (!t || !v1) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(v1), b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
// Use the raw request body, exactly as received — not re-serialized JSON.
// verify(process.env.WEBHOOK_SECRET, rawBody, req.headers["x-webhook-signature"])Versioning
The version is in the path. Within /v1 we only add: new endpoints, new optional parameters, new fields in responses. Write clients that ignore fields they don’t know. Anything that would break an existing client ships as a new version.