Skip to content
CrediBlog

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
// 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
// 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
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

ScopeGrants
readRead posts, categories, tags, planned topics, and the site profile.
writeReserved 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
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.

PlanRequests per minute, per keyActive keys per site
CrediBlog Pro60020
Free6020
Retired plans12020

Every response carries your current position:

HeaderMeaning
X-RateLimit-LimitRequests allowed in the current window.
X-RateLimit-RemainingRequests left in the current window.
X-RateLimit-ResetUnix seconds at which the window resets.
X-RateLimit-PolicyThe policy itself, e.g. 600;w=60.
Retry-AfterOn 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.

ParameterDefaultNotes
limit251 to 100.
cursorThe nextCursor from the previous response, verbatim.
sortPer resourceSee each resource below.
orderdesc for dates, asc for namesasc 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.

curl
# 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"
JavaScript
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.

curl
# 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
Responsemax-age
Collections (posts, categories, tags, planned topics)60 seconds
Single resources60 seconds
The site profile300 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.

curl
# 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.

FieldTypeNotes
slugstringThe site's path on crediblog.com.
namestringDisplay name.
taglinestring | null
descriptionstring | null
urlstringWhere the blog is publicly served — your own domain when you have configured one.
websiteUrlstring | nullYour own website.
localestringe.g. en-US.
nichestring
locationsstring[]Geographic focus, for local schema markup.
authorobject | nullname, bio, url — the byline.
aiDisclosurebooleanWhether you have opted into an AI-assisted note on posts.
listedOnCrediblogboolean
publishedPostCountinteger
updatedAtstring | nullISO 8601 UTC.
curl
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

ParameterValuesNotes
statusdraft | publishedOmit to get both.
categoryslugExact category slug.
tagslugPosts carrying this tag.
publishedFromISO 8601Inclusive. Unpublished posts are excluded when set.
publishedToISO 8601Inclusive.
qtextCase-insensitive match on title, excerpt, and keywords. Article bodies are not searched — searching rendered HTML matches markup and reads as broken.
featuredtrue | false

Sorting

sortDefault orderNotes
publishedAtdescThe default. Drafts have no publication date, so they order by when they were created.
createdAtdesc
titleasc
viewsdescAll-time views on the hosted blog page.

Fields

FieldTypeNotes
iduuidAlways returned.
slugstring
titlestring
excerptstring | null
statusdraft | published
publishedAtstring | nullISO 8601 UTC. Null for drafts.
createdAtstringISO 8601 UTC.
readingMinutesinteger
urlstringThe post's public permalink.
coverImageUrlstring | null
ogImageUrlstring | null
imageAttributionstring | nullCredit line the image licence requires.
imageSourceUrlstring | null
categoryobject | nullid, slug, name.
tagsobject[]id, slug, name.
keywordsstring | null
metaTitlestring | nullFor your own <title>.
metaDescriptionstring | null
viewCountinteger
featuredboolean
sourcesobject[]title and url per citation. Not in list responses unless requested.
contentHtmlstringThe article body. Not in list responses unless requested.
curl
# 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.

FieldTypeNotes
iduuid
slugstringUse it as the category or tag filter on posts.
namestring
publishedPostCountintegerPublished 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
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.

FieldTypeNotes
iduuid
titlestring
contextNotestring | nullResearch notes fed into the brief.
statusproposed | approved | generated | skipped
sourceuser | ai | researchWho proposed it.
scheduledForstring | nullISO 8601 UTC target date.
createdAtstringISO 8601 UTC.
generatedAtstring | nullISO 8601 UTC.
postIduuid | nullThe post this became, once generated.

Filter on status, source, scheduledFrom, and scheduledTo. Sort by createdAt (default), scheduledFor, or title.

curl
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.

response body
{
  "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.

StatuscodeWhat happened
400invalid_requestA parameter was missing, malformed, or out of range. The message names it.
400invalid_cursorThe cursor is corrupt, or belongs to a different sort or order.
401missing_api_keyNo X-Api-Key header.
401invalid_api_keyUnknown key, or a truncated copy/paste.
401revoked_api_keyThe key was revoked. Create a new one.
401expired_api_keyThe key passed its expiry. Create a new one.
403insufficient_scopeThe key lacks the scope this endpoint needs.
403site_inactiveThe site is deactivated or deleted. Reactivate it to resume API access.
404not_foundNo resource on this site matches. Note: another site's post is a 404 here, not a 403.
429rate_limitedOver the per-minute limit. Wait Retry-After seconds.
JavaScript
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.