Developer documentation
The CrediBlog JSON API
CrediBlog researches, writes, and stores your content. This API is how you render it yourself — in Next.js, Astro, WordPress, a mobile app, or anything that speaks HTTP. Every response is scoped to one site, authenticated by a key you create in your dashboard.
It reads your content out: eleven GET endpoints covering your posts, your categories and tags, your planned topics, and your site profile. You write in the dashboard, and this is how the result reaches everywhere else.
One button, the whole specification on your clipboard — enough for a coding model to write a working client in one pass. Prefer to fetch it? /docs/llms.txt.
- Base URL
https://crediblog.com/api/v1- Auth header
X-Api-Key- Methods
- GET
On this page
Start here
Calling the API
Reference
Quickstart
Create a key in your dashboard: open your site, scroll to API keys, name it, and copy the key. It is shown once. Put it in CREDIBLOG_API_KEY and never in client-side code.
These ten lines render your posts on your own Next.js site.
// app/blog/page.tsx — your Next.js site, rendering posts stored in CrediBlog.
const headers = { "X-Api-Key": process.env.CREDIBLOG_API_KEY! };
const base = "https://crediblog.com/api/v1";
export default async function Blog() {
const res = await fetch(`${base}/posts?status=published&limit=10`, { headers });
const { data } = await res.json();
return <ul>{data.map((p: { id: string; slug: string; title: string }) => (
<li key={p.id}><a href={`/blog/${p.slug}`}>{p.title}</a></li>
))}</ul>;
}And the article page:
// app/blog/[slug]/page.tsx — one article.
const headers = { "X-Api-Key": process.env.CREDIBLOG_API_KEY! };
export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const res = await fetch(`https://crediblog.com/api/v1/posts/${slug}`, { headers });
if (res.status === 404) return <p>Not found.</p>;
const post = await res.json();
return (
<article>
<h1>{post.title}</h1>
<time dateTime={post.publishedAt}>{post.publishedAt}</time>
<div dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
</article>
);
}Authentication
Send your key in the X-Api-Key header on every request. There is no OAuth dance and no token to refresh.
curl https://crediblog.com/api/v1/posts \
-H "X-Api-Key: cb_live_a1b2c3d4e5f6.YOUR_SECRET"A key looks like cb_live_a1b2c3d4e5f6.SECRET. The part before the dot is public — it is what your dashboard shows so you can tell keys apart, and it is safe in a log. The part after the dot is the secret. We store only a hash of it, so if you lose a key we cannot recover it; revoke it and create another.
Scopes
| Scope | Grants |
|---|---|
read | Read posts, categories, tags, planned topics, and the site profile. This is the only scope any endpoint needs. |
write | Grants nothing today. A key issued with it can do exactly what a read key can do, and nothing more. |
Key lifecycle
- An expiry is optional. An expired key stays visible in your dashboard so you can see why an integration stopped.
- Revoking is immediate and permanent. The key row is kept, not deleted, so usage stays auditable.
- Rotating replaces a key with a new one carrying the same name and scopes. You choose what happens to the old one: it can keep working for 24 more hours so you can deploy without a gap, or stop immediately if it leaked. Rotation never extends a key past an expiry it already had, and a site that is at its key limit can still rotate.
- Every key tracks last used and a request count, so you can tell which key an unknown integration is using before you turn it off.
- Deactivating or deleting a site stops all of its keys working — you get
403 site_inactive.
Start here to confirm a key works. GET /api/v1 returns the resource index plus the key's own scopes and rate limit.
curl https://crediblog.com/api/v1 \
-H "X-Api-Key: $CREDIBLOG_API_KEY"
# {
# "version": "v1",
# "resources": { "posts": "/api/v1/posts", ... },
# "key": { "prefix": "cb_live_a1b2c3d4e5f6", "name": "Website build", "scopes": ["read"] },
# "rateLimit": { "plan": "pro", "requestsPerMinute": 600 }
# }Rate limits
Limits are per key, per minute, in a fixed window aligned to the clock. They are on your plan, not negotiated per endpoint.
| Plan | Requests per minute, per key | Active keys per site |
|---|---|---|
| CrediBlog Pro | 6,000 | 20 |
| Free | 600 | 20 |
| Retired plans and anything unmapped | 1,200 | 20 |
Every response carries your current position:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed in the current window. |
X-RateLimit-Remaining | Requests left in the current window. |
X-RateLimit-Reset | Unix seconds at which the window resets. |
X-RateLimit-Policy | The policy itself, e.g. 6000;w=60. |
Retry-After | On a 429 only: whole seconds to wait. Always at least 1. |
Over the limit you get 429 with error.code = "rate_limited". Honour Retry-After rather than retrying immediately. If you are polling for new posts, the cheapest fix is conditional requests — see Caching — because a 304 costs you a request but no bandwidth and no parsing.
Don't hard-code the numbers above. GET /api/v1 reports the calling key's own limit in rateLimit.requestsPerMinute, which is what an upgrade changes.
Pagination
Every collection is cursor-paginated. There is no offset and no page, on purpose: your blog publishes while you are reading it, and offset pagination on shifting data silently skips or repeats rows.
| Parameter | Default | Notes |
|---|---|---|
limit | 25 | 1 to 100. |
cursor | — | The nextCursor from the previous response, verbatim. |
sort | Per resource | See each resource below. |
order | desc for dates, asc for names | asc or desc. |
A response ends with hasMore and nextCursor. Stop when nextCursor is null — you never need an extra empty request to find out you are done.
# First page
curl "https://crediblog.com/api/v1/posts?limit=25" -H "X-Api-Key: $CREDIBLOG_API_KEY"
# {
# "data": [ ... ],
# "hasMore": true,
# "nextCursor": "YzF8cHVibGlzaGVkQXR8ZGVzY3w..."
# }
# Next page — same sort and order, cursor verbatim
curl "https://crediblog.com/api/v1/posts?limit=25&cursor=YzF8cHVibGlzaGVkQXR8ZGVzY3w..." \
-H "X-Api-Key: $CREDIBLOG_API_KEY"async function allPosts(apiKey) {
const out = [];
let cursor = null;
do {
const url = new URL("https://crediblog.com/api/v1/posts");
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, { headers: { "X-Api-Key": apiKey } });
if (!res.ok) throw new Error((await res.json()).error.code);
const page = await res.json();
out.push(...page.data);
cursor = page.nextCursor; // null means you are done
} while (cursor);
return out;
}Caching and conditional requests
Every successful response carries an ETag computed from the exact bytes returned, and Cache-Control: private, max-age=…, must-revalidate. It is always private: responses are scoped to one key and must never land in a shared cache.
Send the ETag back as If-None-Match. If nothing changed you get 304 Not Modified with no body.
# First call — note the ETag
curl -i "https://crediblog.com/api/v1/posts?limit=5" -H "X-Api-Key: $CREDIBLOG_API_KEY"
# HTTP/1.1 200 OK
# ETag: "8f14e45fceea167a5a36dedd4bea2543"
# Cache-Control: private, max-age=60, must-revalidate
# Poll with the validator — 304, no body, no bandwidth
curl -i "https://crediblog.com/api/v1/posts?limit=5" \
-H "X-Api-Key: $CREDIBLOG_API_KEY" \
-H 'If-None-Match: "8f14e45fceea167a5a36dedd4bea2543"'
# HTTP/1.1 304 Not Modified| Response | max-age |
|---|---|
| Collections (posts, categories, tags, planned topics) | 60 seconds |
| Single resources | 60 seconds |
| The site profile | 300 seconds |
Because the ETag is derived from the response body, it changes whenever anything you can see changes — including a field you added to fields. Cache per URL, not per resource. We also send Vary: Accept, X-Api-Key, so any cache in front of you keys on the key as well as the URL.
A 304 still costs one request against your rate limit. What it saves is the body, the transfer, and the parse.
Choosing fields
List responses omit the two heavy post fields — contentHtml and sources — because an index page that only needs titles should not download forty articles. Single-resource responses include everything.
Use fields to be explicit either way: to trim a list further, or to opt back into the article bodies. id is always returned. An unknown field name is a 400 invalid_request listing the valid ones, so a typo fails in development rather than silently in production.
# A list view that skips the article bodies entirely
curl "https://crediblog.com/api/v1/posts?fields=slug,title,excerpt,publishedAt,coverImageUrl" \
-H "X-Api-Key: $CREDIBLOG_API_KEY"Site
GET/api/v1/site
The blog profile: everything you need to render your own header, byline, and branding without hard-coding it in two places.
| Field | Type | Notes |
|---|---|---|
slug | string | The site's path on crediblog.com. |
name | string | Display name. |
tagline | string | null | |
description | string | null | |
url | string | Where the blog is publicly served — your own domain when you have configured one. |
websiteUrl | string | null | Your own website. |
locale | string | e.g. en-US. |
niche | string | |
locations | string[] | Geographic focus, for local schema markup. |
author | object | null | name, bio, url — the byline. |
aiDisclosure | boolean | Whether you have opted into an AI-assisted note on posts. |
listedOnCrediblog | boolean | |
publishedPostCount | integer | |
updatedAt | string | null | ISO 8601 UTC. |
curl https://crediblog.com/api/v1/site -H "X-Api-Key: $CREDIBLOG_API_KEY"
# {
# "slug": "acme-roofing",
# "name": "Acme Roofing",
# "tagline": "Straight answers about roofs",
# "url": "https://acmeroofing.com/blog",
# "locale": "en-US",
# "author": { "name": "Dana Reyes", "bio": "...", "url": "https://..." },
# "publishedPostCount": 42,
# "updatedAt": "2026-07-14T08:12:00Z"
# }Posts
GET/api/v1/posts
GET/api/v1/posts/{idOrSlug}
The single endpoint accepts either the post id or its slug, so you can route on whichever your site already uses.
Filters
| Parameter | Values | Notes |
|---|---|---|
status | draft | published | archived | Omit to get every status. unpublished is accepted as an alias of draft. |
category | slug | Exact category slug. |
tag | slug | Posts carrying this tag. |
publishedFrom | ISO 8601 | Inclusive. Unpublished posts are excluded when set. |
publishedTo | ISO 8601 | Inclusive. |
q | text | Case-insensitive match on title, excerpt, and keywords. Article bodies are not searched — searching rendered HTML matches markup and reads as broken. |
featured | true | false |
Sorting
| sort | Default order | Notes |
|---|---|---|
publishedAt | desc | The default. Drafts have no publication date, so they order by when they were created. |
createdAt | desc | |
title | asc | |
views | desc | All-time views on the hosted blog page. |
Fields
| Field | Type | Notes |
|---|---|---|
id | uuid | Always returned. |
slug | string | |
title | string | |
excerpt | string | null | |
status | draft | published | archived | archived is content that was live and was deliberately retired; it keeps its URL history. |
publishedAt | string | null | ISO 8601 UTC. Null for drafts. |
createdAt | string | ISO 8601 UTC. |
readingMinutes | integer | |
url | string | The post's public permalink. |
coverImageUrl | string | null | |
ogImageUrl | string | null | |
imageAttribution | string | null | Credit line the image licence requires. |
imageSourceUrl | string | null | |
category | object | null | id, slug, name. |
tags | object[] | id, slug, name. |
keywords | string | null | |
metaTitle | string | null | For your own <title>. |
metaDescription | string | null | |
viewCount | integer | |
featured | boolean | |
sources | object[] | title and url per citation. Not in list responses unless requested. |
contentHtml | string | null | The published article body. Not in list responses unless requested — where it is absent, not null. |
# Published posts in one category, newest first
curl "https://crediblog.com/api/v1/posts?status=published&category=maintenance&limit=10" \
-H "X-Api-Key: $CREDIBLOG_API_KEY"
# Everything published in Q1, oldest first
curl "https://crediblog.com/api/v1/posts?publishedFrom=2026-01-01&publishedTo=2026-03-31&order=asc" \
-H "X-Api-Key: $CREDIBLOG_API_KEY"
# Search titles, excerpts, and keywords
curl "https://crediblog.com/api/v1/posts?q=gutter" -H "X-Api-Key: $CREDIBLOG_API_KEY"Categories and tags
GET/api/v1/categories
GET/api/v1/categories/{idOrSlug}
GET/api/v1/tags
GET/api/v1/tags/{idOrSlug}
Both resources have the same shape and the same parameters.
| Field | Type | Notes |
|---|---|---|
id | uuid | |
slug | string | Use it as the category or tag filter on posts. |
name | string | |
publishedPostCount | integer | Published posts only. |
Sort by name (the default) or slug. Sorting by publishedPostCount is not offered: cursor pagination over a count that changes as you publish cannot be made stable, and a blog's taxonomy is small enough to sort client side.
curl https://crediblog.com/api/v1/categories -H "X-Api-Key: $CREDIBLOG_API_KEY"
curl https://crediblog.com/api/v1/tags?sort=slug -H "X-Api-Key: $CREDIBLOG_API_KEY"
curl https://crediblog.com/api/v1/categories/maintenance -H "X-Api-Key: $CREDIBLOG_API_KEY"Planned topics
GET/api/v1/planned-topics
GET/api/v1/planned-topics/{id}
Your content calendar: ideas queued for generation and what became of them. Useful for showing an editorial pipeline on your own site, or for reconciling what CrediBlog is about to publish. Unlike posts, categories and tags, a planned topic is addressed by id only — anything else in the path is a 404.
| Field | Type | Notes |
|---|---|---|
id | uuid | |
title | string | |
contextNote | string | null | Research notes fed into the brief. |
status | proposed | approved | generating | generated | skipped | See the table below — the filter accepts two spellings for three of these. |
source | user | ai | research | Who proposed it. |
scheduledFor | string | null | ISO 8601 UTC target date. |
createdAt | string | ISO 8601 UTC. |
generatedAt | string | null | ISO 8601 UTC. |
postId | uuid | null | The post this became, once generated. Fetch it from /api/v1/posts/{id}. |
Status vocabulary
The internal names were changed after v1 shipped and the wire deliberately kept the published spelling, so existing integrations did not break. status filters accept both.
| Emitted | Also accepted as a filter | Meaning |
|---|---|---|
proposed | — | Suggested, not yet decided on. |
approved | ready | Authorised to spend a generation slot. Not authorisation to publish. |
generating | — | Being written right now. |
generated | converted | A draft exists; postId names it. |
skipped | — | Declined — but read the note below. |
Filter on status, source, scheduledFrom, and scheduledTo. Sort by createdAt (default), scheduledFor, or title.
curl "https://crediblog.com/api/v1/planned-topics?status=approved&sort=scheduledFor&order=asc" \
-H "X-Api-Key: $CREDIBLOG_API_KEY"Errors
Every failure uses one envelope. Branch on code, show message, quote requestId to support.
{
"error": {
"code": "invalid_cursor",
"message": "That cursor isn't valid. Use the nextCursor value from the previous response verbatim, or omit it to start from the first page.",
"requestId": "0HN7GK2R1M9QK:00000003"
}
}Every response — success or failure — carries X-Request-Id and X-Correlation-Id. Log both. They are what turns “this call returned the wrong thing at 14:02” into a question support can answer.
Send your own X-Request-Id and we echo it back in the header and the body, so your logs and ours line up. One constraint worth knowing: it must be at most 96 characters of A–Z a–z 0–9 - _ . :. Anything else — a slash, a brace, a space — is dropped silently and you get our id instead, so a UUID in braces will not correlate and nothing will tell you.
| Status | code | What happened |
|---|---|---|
| 400 | invalid_request | A parameter was missing, malformed, or out of range. The message names it. |
| 400 | invalid_cursor | The cursor is corrupt, or belongs to a different sort or order. |
| 401 | missing_api_key | No X-Api-Key header. |
| 401 | invalid_api_key | Unknown key, or a truncated copy/paste. |
| 401 | revoked_api_key | The key was revoked. Create a new one. |
| 401 | expired_api_key | The key passed its expiry. Create a new one. |
| 403 | insufficient_scope | The key lacks the scope this endpoint needs. |
| 403 | site_inactive | The site is deactivated or deleted. Reactivate it to resume API access. |
| 404 | not_found | No resource on this site matches. Note: another site's post is a 404 here, not a 403. |
| 429 | rate_limited | Over the per-minute limit. Wait Retry-After seconds. |
const res = await fetch(url, { headers: { "X-Api-Key": key } });
if (res.status === 429) {
const wait = Number(res.headers.get("Retry-After") ?? 5);
await new Promise((r) => setTimeout(r, wait * 1000));
// then retry
}
if (!res.ok) {
const { error } = await res.json();
// Branch on error.code, never on error.message.
throw new Error(`${error.code} (request ${error.requestId})`);
}Build with an LLM
Everything on this page, plus the parts a model needs and a human skims past, is available as one self-contained brief. Copy it, paste it into whatever model you code with, and ask for a client. It is written so the answer comes back right the first time rather than after three rounds of “actually, pagination works like this”.
It contains every route, parameter, field and error code; the pagination loop written out in TypeScript and Python; the conditional-request flow; backoff that honours Retry-After; and a list of the mistakes clients actually make against this API. It also states plainly that there are no write endpoints, so nothing gets built toward a POST that will never exist.
# The whole specification, as one markdown file.
curl -s https://crediblog.com/docs/llms.txt -o crediblog-api.md
# Or hand it straight to a model that can read a URL:
# "Implement a read-only CrediBlog client in <language>.
# The full API specification is at https://crediblog.com/docs/llms.txt"The brief is generated from the same lists the API is implemented from, and a test fails if the two drift — so it cannot quietly describe an API we no longer serve. Note that /llms.txt at the site root is a different document: that one is the llmstxt.org index of the site for crawlers, not an API specification.
OpenAPI and versioning
The machine-readable contract lives at /api/v1/openapi.json. It needs no API key, so you can generate a client before you have one.
Within v1 we only make additive changes: new endpoints, new optional parameters, new fields on existing resources. Write your client so that an unrecognised field is ignored rather than fatal. Anything that would break an existing integration ships as v2 at a new path.
Something unclear, or a field you need that is not here? Email support@crediblog.com with the requestId if it is about a specific call. Rate limits and acceptable use are set out in the Acceptable Use Policy.
