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.
- Base URL
https://crediblog.com/api/v1- Auth header
X-Api-Key- Version
- v1 — additive changes only
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. |
write | Reserved for creating and editing content. No write endpoints are published in v1 yet. |
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/v1returns 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 | 600 | 20 |
| Free | 60 | 20 |
| Retired plans | 120 | 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. 600;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.
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.
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 | Omit to get both. |
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 | |
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 | The article body. Not in list responses unless requested. |
# 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 publishedPostCountis 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.
| Field | Type | Notes |
|---|---|---|
id | uuid | |
title | string | |
contextNote | string | null | Research notes fed into the brief. |
status | proposed | approved | generated | skipped | |
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. |
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"
}
}Send your own X-Request-Id and we echo it back in the header and the body, so your logs and ours line up.
| 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})`);
}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.
