# CrediBlog JSON API: implementation brief

This is a complete specification of the CrediBlog public JSON API, written to be handed to a coding
model. Everything needed to write a correct client is in this one document. If something is not in
here, it does not exist in the API.

## What this API is

CrediBlog researches, writes, and stores a business's blog content. This API is how that content is
read back out and rendered somewhere else: a Next.js site, an Astro build, a WordPress theme, a
mobile app, a static-site generator, anything that speaks HTTP.

## Scope

**Every endpoint is a `GET`.** It serves posts, categories, tags and the content calendar for one
site. Content is created and edited in the CrediBlog dashboard, and this is how it is read back out.
Give the client you generate read methods only: no `POST`, `PATCH` or `DELETE`, no write stubs
marked "not yet implemented", and no offline write queue.

It is also **single-site**. A key belongs to exactly one site and can only ever read that site. There
is no parameter, header, or id that widens that scope. Asking for another site's post returns
`404` rather than `403`, so the API does not confirm the resource exists elsewhere.

---

## 1. Connection facts

| Fact | Value |
| --- | --- |
| Base URL | `https://crediblog.com/api/v1` |
| Auth | `X-Api-Key: <key>` header on every request |
| Key format | `cb_live_a1b2c3d4e5f6.SECRET` |
| Methods | `GET` only |
| Content type | `application/json` |
| Timestamps | ISO 8601, always UTC, always `YYYY-MM-DDTHH:MM:SSZ` |
| Ids | UUID v4, lowercase hyphenated |
| Versioning | `v1` is additive-only. New fields may appear; ignore unrecognised ones rather than failing. |
| OpenAPI | `https://crediblog.com/api/v1/openapi.json` (public, no key needed) |

### Getting a key

A human creates it in the CrediBlog dashboard: open the site, find **API keys**, name it, copy it.
**The secret is shown exactly once and is stored only as a hash.** It cannot be recovered, only
replaced. A client cannot mint its own key; there is no registration endpoint.

The part before the dot (`cb_live_…`) is public and identifies the key in the
dashboard and in logs. The part after the dot is the secret. A site may hold up to
20 active keys. Rotation issues a replacement and gives the old key
24 hours of grace (or revokes it immediately, if it leaked), so a redeploy has a
window.

### Where the key goes

**Server-side only, from an environment variable.** A key grants read access to everything on the
site, including unpublished drafts. Do not put it in browser JavaScript, a mobile app binary, or a
public repository. The API's CORS policy does not admit arbitrary origins, so a browser client will
fail anyway, but the real reason is that a key in client code is a key you have published.

Because of that, two documented response headers are **not** exposed to cross-origin JavaScript:
`ETag` and the `X-RateLimit-*` family. They are on the wire and readable by any server-side
client. If you are somehow writing a browser client against an allowed origin, do not expect
`response.headers.get("ETag")` to return anything.

---

## 2. Endpoints

| Method | Path | Paged | Auth | Returns |
| --- | --- | --- | --- | --- |
| GET | `/api/v1` | no | key | Resource index, plus this key's own scopes and rate limit. |
| GET | `/api/v1/site` | no | key | Your site's details: name, tagline, byline, locale, public URL. |
| GET | `/api/v1/posts` | yes | key | List posts. Filter, sort, and page through them. |
| GET | `/api/v1/posts/{idOrSlug}` | no | key | One post by id or slug, with the article body. |
| GET | `/api/v1/categories` | yes | key | List categories. |
| GET | `/api/v1/categories/{idOrSlug}` | no | key | One category by id or slug. |
| GET | `/api/v1/tags` | yes | key | List tags. |
| GET | `/api/v1/tags/{idOrSlug}` | no | key | One tag by id or slug. |
| GET | `/api/v1/planned-topics` | yes | key | The content calendar: queued ideas and what became of them. |
| GET | `/api/v1/planned-topics/{id}` | no | key | One planned topic. Id only — topics have no slug. |
| GET | `/api/v1/openapi.json` | no | public | The OpenAPI 3.0 contract. No key required. |

Eleven authenticated reads and one public contract document. That is the whole surface.

### Two response shapes, and only two

A **collection** endpoint returns an envelope:

```json
{
  "data": [ { "id": "…" }, { "id": "…" } ],
  "hasMore": true,
  "nextCursor": "YzF8cHVibGlzaGVkQXR8ZGVzY3w…"
}
```

A **single-resource** endpoint returns the object itself, unwrapped, with no `data` key. Do not write
one parser for both.

An **error** returns:

```json
{
  "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"
  }
}
```

---

## 3. `GET /api/v1`: index

No parameters. Returns the resource index plus the calling key's own identity and limit. Call it once
at startup to verify a key and discover the key's rate limit rather than hard-coding one.

```json
{
  "version": "v1",
  "site": "/api/v1/site",
  "resources": {
    "posts": "/api/v1/posts",
    "categories": "/api/v1/categories",
    "tags": "/api/v1/tags",
    "plannedTopics": "/api/v1/planned-topics"
  },
  "openapi": "/api/v1/openapi.json",
  "documentation": "/docs",
  "key": { "prefix": "cb_live_a1b2c3d4e5f6", "name": "Website build", "scopes": ["read"] },
  "rateLimit": { "plan": "pro", "requestsPerMinute": 6000 }
}
```

---

## 4. `GET /api/v1/site`: your site's details

No parameters. Returns one object. Use it to render a header, byline, and locale rather than
hard-coding them 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. The customer's own domain when configured. |
| `websiteUrl` | string \| null | The business's main website. |
| `locale` | string | BCP 47, e.g. en-US. Defaults to en-US. |
| `niche` | string | Content niche, PascalCase (e.g. General, RealEstate). |
| `locations` | string[] | Geographic focus, for local schema markup. |
| `author` | object \| null | { name, bio, url }. Null when no byline is set. |
| `aiDisclosure` | boolean | Whether the customer opted into an AI-assisted note on posts. |
| `listedOnCrediblog` | boolean | Whether the blog appears in the public CrediBlog directory. |
| `publishedPostCount` | integer |  |
| `updatedAt` | string \| null | ISO 8601 UTC. |

---

## 5. `GET /api/v1/posts` and `GET /api/v1/posts/{idOrSlug}`

The detail route accepts **either** the UUID **or** the slug, so route on whichever your site already
uses.

### Query parameters (list only)

| Parameter | Type | Default | Notes |
| --- | --- | --- | --- |
| `limit` | integer | 25 | 1 to 100. Anything else is a 400. |
| `cursor` | string | — | The `nextCursor` from the previous page, verbatim. Opaque, so never construct or parse one. |
| `sort` | enum | `publishedAt` | See the sort table below. |
| `order` | enum | per sort | `asc` or `desc`. |
| `fields` | csv | — | Sparse fieldset. See §8. |
| `status` | enum | — | One of `draft`, `unpublished`, `published`, `archived`. Omit to get every status. |
| `category` | string | — | Exact category **slug**, not name, not id. |
| `tag` | string | — | Exact tag **slug**. |
| `publishedFrom` | ISO 8601 | — | Inclusive lower bound. Posts with no publication date are excluded when this is set. |
| `publishedTo` | ISO 8601 | — | Inclusive upper bound. Same exclusion. |
| `q` | string | — | Case-insensitive substring match on title, excerpt, and keywords. **Article bodies are not searched.** `%` and `_` are treated literally. |
| `featured` | boolean | — | `true`/`false`, or `1`/`0`. |

`/posts/{idOrSlug}` accepts `fields` only.

### Sorting

| `sort` | Default `order` | Notes |
| --- | --- | --- |
| `publishedAt` | `desc` | The default. A post with no publication date sorts by createdAt instead. |
| `createdAt` | `desc` | When the row was created. |
| `title` | `asc` | Alphabetical, in the database collation. |
| `views` | `desc` | All-time views. |

### Fields

| Field | Type | Notes |
| --- | --- | --- |
| `id` | uuid | Always returned, even when you ask for other fields. |
| `slug` | string | URL segment. Unique per site. |
| `title` | string | The published headline, not the working copy. |
| `excerpt` | string \| null | The published summary. |
| `status` | enum | One of `draft`, `published`, `archived`. |
| `publishedAt` | string \| null | ISO 8601 UTC. Null until the post is published. |
| `createdAt` | string | ISO 8601 UTC. |
| `readingMinutes` | integer | Estimated reading time. |
| `url` | string | The post's public permalink, on the customer's own domain when one is configured. |
| `coverImageUrl` | string \| null | Hero image. |
| `ogImageUrl` | string \| null | Social card image. |
| `imageAttribution` | string \| null | Credit line the image licence requires. Render it if it is not null. |
| `imageSourceUrl` | string \| null | Where the image came from. |
| `category` | object \| null | { id, slug, name }. At most one per post. |
| `tags` | object[] | [{ id, slug, name }], sorted by name, case-insensitive. |
| `keywords` | string \| null | Comma-separated SEO keywords, as one string. |
| `metaTitle` | string \| null | For your own `<title>` element. |
| `metaDescription` | string \| null | For your own meta description. |
| `viewCount` | integer | All-time views on the CrediBlog-hosted page. |
| `featured` | boolean | Editorially pinned. |
| `sources` | object[] | [{ title, url }] citations. Omitted from list responses unless you ask for it. |
| `contentHtml` | string \| null | The published article body, sanitised HTML. Omitted from list responses unless you ask for it. |

**A list response returns the light fields.** Ask for `sources`, `contentHtml` by name when you
need them, so an index page that renders titles does not download forty articles. A single-resource
response includes everything.

### `status` on the wire

A post's `status` is one of `draft`, `published`, `archived`.

- `draft`: never been published. Visible over this API because the key owns the site; **do not
  render it publicly** unless that is what you intend.
- `published`: live.
- `archived`: was live, retired deliberately. It keeps its URL history. Treat it as gone from the
  index but resolvable at its old URL if you care about not breaking links.

`?status=` additionally accepts `unpublished` as an alias of `draft`.

**If you are rendering a public site, filter with `?status=published`.** The key owns the site, so
the default is every status.

---

## 6. `GET /api/v1/categories`, `/categories/{idOrSlug}`, `/tags`, `/tags/{idOrSlug}`

Identical shape and parameters. Detail routes take a UUID or a slug.

Parameters: `limit`, `cursor`, `sort`, `order`, `fields`. No filters.

| Field | Type | Notes |
| --- | --- | --- |
| `id` | uuid | Always returned. |
| `slug` | string | Pass this as the `category` or `tag` filter on /posts. |
| `name` | string | Display name. |
| `publishedPostCount` | integer | Published posts only. Computed per page. |

| `sort` | Default `order` | Notes |
| --- | --- | --- |
| `name` | `asc` | The default. |
| `slug` | `asc` | — |

Sort by `name` or `slug`. A blog's taxonomy is small, so page it all in and sort by
`publishedPostCount` client-side.

---

## 7. `GET /api/v1/planned-topics` and `/planned-topics/{id}`

The content calendar: ideas queued for generation and what became of them. Useful for showing an
editorial pipeline, or reconciling what is about to publish.

**Addressed by UUID only.** Planned topics have no slug. A non-UUID path segment returns `404`, not
`400`.

Parameters: `limit`, `cursor`, `sort`, `order`, `fields`, plus:

| Parameter | Type | Notes |
| --- | --- | --- |
| `status` | enum | One of `proposed`, `ready`, `approved`, `generating`, `converted`, `generated`, `skipped`. |
| `source` | enum | One of `user`, `ai`, `research`. |
| `scheduledFrom` | ISO 8601 | Inclusive. Topics with no target date are excluded when set. |
| `scheduledTo` | ISO 8601 | Inclusive. Same exclusion. |

| Field | Type | Notes |
| --- | --- | --- |
| `id` | uuid | Always returned. |
| `title` | string | The working title of the idea. |
| `contextNote` | string \| null | Research notes fed into the brief. |
| `status` | enum | One of `proposed`, `approved`, `generating`, `generated`, `skipped`. Read the note below on `skipped`. |
| `source` | enum | One of `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, when a draft was produced. |
| `postId` | uuid \| null | The post this became. Fetch it from /api/v1/posts/{id}. |

| `sort` | Default `order` | Notes |
| --- | --- | --- |
| `createdAt` | `desc` | The default. |
| `scheduledFor` | `desc` | A topic with no target date sorts by createdAt instead. |
| `title` | `asc` | — |

### The `status` vocabulary, precisely

Emitted values are `proposed`, `approved`, `generating`, `generated`, `skipped`.

The filter accepts two spellings for the same three states. Both work:

| Filter value | Also accepted as | Meaning |
| --- | --- | --- |
| `proposed` | — | Suggested, not yet decided on. |
| `approved` | `ready` | Authorised to spend a generation slot. **Does not** mean authorised to publish. |
| `generating` | — | Being written right now. |
| `generated` | `converted` | A draft exists; `postId` names it. |
| `skipped` | — | Declined. |

> **One thing to know about `skipped`.** On the wire it covers both a topic somebody declined and
> a topic that stopped after repeated generation failures, but `?status=skipped` matches only the
> declined ones. A full unfiltered read can therefore return more items reading
> `"status": "skipped"` than `?status=skipped` returns. Do not treat `skipped` as proof a human
> declined the topic, and do not build reconciliation logic on the two counts agreeing.

---

## 8. Sparse fieldsets: `fields=`

`fields` is a comma-separated allow-list. `id` is always returned whether you ask for it or not.

```
GET /api/v1/posts?fields=slug,title,excerpt,publishedAt,coverImageUrl
GET /api/v1/posts?fields=slug,title,contentHtml     # opt back into the body on a list
```

An **unknown field name is a `400 invalid_request`** listing the valid names, so a typo fails
immediately rather than silently returning less data than you expected. Field names are matched
case-insensitively but are always emitted in the canonical casing above.

---

## 9. Pagination: keyset, and the single thing clients get wrong

There is **no `offset` and no `page`.** Every collection is keyset-paginated on
`(sortValue, id)`. A blog publishes while you are reading it, and offset paging over shifting
posts silently skips and repeats.

The rules, all four of which matter:

1. Read `nextCursor` from the response and pass it back as `?cursor=`, **byte for byte**. It is
   opaque. Do not decode it, do not build one, do not persist one across a sort change.
2. **Stop when `nextCursor` is `null`.** That is the termination condition. It is exactly
   equivalent to `hasMore === false`, and it saves you the extra request that offset paging needs to
   discover it is done.
3. **Keep `sort` and `order` identical for the whole pass.** A cursor carries the sort and
   direction it was minted under and is re-checked on use. Change either mid-pass and you get
   `400 invalid_cursor`, rather than a page that quietly skips posts.
4. `limit` is capped at 100. Asking for more is a `400`, not a silent clamp.

### The loop, written out

```ts
async function* pagesOf(path: string, params: Record<string, string>, key: string) {
  let cursor: string | null = null;
  do {
    const url = new URL("https://crediblog.com/api/v1" + path);
    for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
    if (cursor) url.searchParams.set("cursor", cursor);

    const res = await fetch(url, { headers: { "X-Api-Key": key } });
    if (!res.ok) throw await apiError(res);          // see §12

    const page: { data: unknown[]; hasMore: boolean; nextCursor: string | null } = await res.json();
    yield page.data;
    cursor = page.nextCursor;                        // null => finished
  } while (cursor);
}

// Every published post, newest first, 100 at a time.
const all = [];
for await (const batch of pagesOf("/posts", { status: "published", limit: "100" }, key)) {
  all.push(...batch);
}
```

```python
def pages_of(session, path, params, key):
    cursor = None
    while True:
        q = dict(params)
        if cursor:
            q["cursor"] = cursor
        r = session.get(f"https://crediblog.com/api/v1{path}", params=q, headers={"X-Api-Key": key})
        r.raise_for_status()
        page = r.json()
        yield page["data"]
        cursor = page.get("nextCursor")
        if not cursor:          # None -> finished. Do NOT loop on len(data).
            return
```

---

## 10. Conditional requests: `ETag` / `304`

Every `200` carries an `ETag` that is a SHA-256 of the exact bytes returned (first 32 hex
characters, quoted). It cannot drift from the payload the way a hand-maintained `Last-Modified`
column can.

Send it back as `If-None-Match`. If nothing changed you get **`304 Not Modified` with no body**,
you pay one request against the rate limit and zero bytes and zero parsing.

```bash
# 1. First read — keep the ETag
curl -i "https://crediblog.com/api/v1/posts?status=published&limit=5" -H "X-Api-Key: $CREDIBLOG_API_KEY"
# HTTP/1.1 200 OK
# ETag: "8f14e45fceea167a5a36dedd4bea2543"
# Cache-Control: private, max-age=60, must-revalidate
# Vary: Accept, X-Api-Key

# 2. Poll with it
curl -i "https://crediblog.com/api/v1/posts?status=published&limit=5" \
  -H "X-Api-Key: $CREDIBLOG_API_KEY" \
  -H 'If-None-Match: "8f14e45fceea167a5a36dedd4bea2543"'
# HTTP/1.1 304 Not Modified
```

```ts
// A poller that costs almost nothing when nothing changed.
const cache = new Map<string, { etag: string; body: unknown }>();

async function getCached(url: string, key: string) {
  const seen = cache.get(url);
  const res = await fetch(url, {
    headers: {
      "X-Api-Key": key,
      ...(seen ? { "If-None-Match": seen.etag } : {}),
    },
  });

  if (res.status === 304) return seen!.body;         // unchanged; no body was sent
  if (!res.ok) throw await apiError(res);

  const body = await res.json();
  const etag = res.headers.get("ETag");
  if (etag) cache.set(url, { etag, body });
  return body;
}
```

Two things to get right:

- **The ETag is per URL, not per resource.** Adding a field to `fields` changes the bytes and
  therefore the ETag. Key your cache on the full URL.
- **`Cache-Control` is always `private`.** Responses are scoped to one key. Never put one in a
  shared or CDN cache. `Vary: Accept, X-Api-Key` says the same thing to any cache that reads it.

`max-age` by endpoint: collections 60s, single resources
60s, the site profile 300s.

---

## 11. Rate limits

Per key, per minute, in a fixed window aligned to the wall clock. It is **not** a sliding window and not
per endpoint. A `304` still counts as a request; the saving is bandwidth and parsing, not quota.

| Plan | Requests / minute / key |
| --- | --- |
| CrediBlog Pro | 6000 |
| Free | 600 |
| Retired plans and anything unmapped | 1200 |

Do not hard-code these. `GET /api/v1` reports the calling key's own limit in
`rateLimit.requestsPerMinute`, and every response carries the live 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, e.g. `6000;w=60`. |

Over the limit you get `429` with `error.code = "rate_limited"` and a `Retry-After` header in
whole seconds, always at least 1.

### Backoff, correctly

```ts
async function call(url: string, key: string, attempt = 0): Promise<Response> {
  const res = await fetch(url, { headers: { "X-Api-Key": key } });

  if (res.status === 429 && attempt < 5) {
    // Honour the server's number. It knows when the window resets; a guess does not.
    const wait = Number(res.headers.get("Retry-After") ?? 1);
    await new Promise((r) => setTimeout(r, Math.max(1, wait) * 1000));
    return call(url, key, attempt + 1);
  }
  return res;
}
```

Only `429` and `5xx` are worth retrying. **Every `4xx` other than `429` is a bug in the
request**, so retrying it burns quota and changes nothing.

---

## 12. Errors

One envelope for every failure. **Branch on `error.code`, never on `error.message`.** The codes
are contract, the messages are written for a human and may be reworded.

| HTTP | `error.code` | Meaning | What to do |
| --- | --- | --- | --- |
| 400 | `invalid_request` | A parameter was missing, malformed, or out of range. | Read `error.message` — it names the parameter and lists the valid values. This is a bug in your request; do not retry it unchanged. |
| 400 | `invalid_cursor` | The cursor is corrupt, or was minted under a different sort/order. | Restart the pass from the first page with no cursor. Never hand-build a cursor. |
| 401 | `missing_api_key` | No X-Api-Key header. | Send the key. Do not retry without it. |
| 401 | `invalid_api_key` | Unknown key, or a truncated copy/paste. | Check the key. Do not retry. |
| 401 | `revoked_api_key` | The key was revoked. | Stop. A human must issue a new key. |
| 401 | `expired_api_key` | The key passed its expiry. | Stop. A human must issue a new key. |
| 403 | `insufficient_scope` | The key lacks the scope the endpoint needs. Reads need `read`. | Stop. Re-issue the key with the `read` scope. |
| 403 | `site_inactive` | The site the key belongs to is deactivated or deleted. | Stop. Nothing on the client side fixes this. |
| 404 | `not_found` | No such resource on this site. Another site's post is a 404 here, not a 403. | Treat as absent. Do not retry. |
| 429 | `rate_limited` | Over the per-minute limit for this key. | Sleep for the whole seconds in the `Retry-After` header, then retry the same request. |

Those are all of them. There are no others.

```ts
class CrediblogError extends Error {
  constructor(readonly code: string, readonly status: number, readonly requestId: string, message: string) {
    super(message);
  }
  /** Only these are worth trying again. */
  get retryable() { return this.status === 429 || this.status >= 500; }
}

async function apiError(res: Response) {
  let code = "unknown", message = res.statusText, requestId = res.headers.get("X-Request-Id") ?? "";
  try {
    const body = await res.json();
    if (body?.error) ({ code, message, requestId } = body.error);
  } catch { /* a proxy 502 is not JSON — do not let the parse failure mask the status */ }
  return new CrediblogError(code, res.status, requestId, message);
}
```

### Correlation ids: log these

| Header | Direction | Meaning |
| --- | --- | --- |
| `X-Request-Id` | request | Your own correlation id. Echoed back verbatim if it is at most 96 characters of [A-Za-z0-9-_.:]; anything else is ignored and the server's own id is returned instead. |
| `X-Request-Id` | response | The request's correlation id. Log it — it is what makes a support question answerable. |
| `X-Correlation-Id` | response | A second id spanning any internal work the request triggered. Log it alongside X-Request-Id. |

Log `X-Request-Id` on every response, success or failure. It is echoed in `error.requestId` too.
It is the single thing that makes "this call returned the wrong data at 14:02" answerable by support
instead of unanswerable. If you send your own `X-Request-Id`, keep it to 96 characters of
`[A-Za-z0-9-_.:]`. Anything else is silently dropped and you get the server's id back, which
means your logs and theirs will not line up and you will not be told.

---

## 13. Complete header reference

| Header | Direction | Meaning |
| --- | --- | --- |
| `X-Api-Key` | request | Your API key. Required on every endpoint except /api/v1/openapi.json. |
| `If-None-Match` | request | An ETag you already hold. Matches produce 304 with no body. |
| `X-Request-Id` | request | Your own correlation id. Echoed back verbatim if it is at most 96 characters of [A-Za-z0-9-_.:]; anything else is ignored and the server's own id is returned instead. |
| `ETag` | response | SHA-256 of the exact response bytes, first 32 hex characters, quoted. Send it back as If-None-Match. |
| `Cache-Control` | response | Always `private, max-age=N, must-revalidate`. Never cache a response in a shared cache: it is scoped to one key. |
| `Vary` | response | `Accept, X-Api-Key`. Key your own cache on the API key as well as the URL. |
| `X-Request-Id` | response | The request's correlation id. Log it — it is what makes a support question answerable. |
| `X-Correlation-Id` | response | A second id spanning any internal work the request triggered. Log it alongside X-Request-Id. |
| `X-RateLimit-Limit` | response | Requests allowed in the current window. |
| `X-RateLimit-Remaining` | response | Requests left in the current window. |
| `X-RateLimit-Reset` | response | Unix seconds at which the window resets. |
| `X-RateLimit-Policy` | response | The policy, e.g. `6000;w=60`. |
| `Retry-After` | response | On a 429 only. Whole seconds to wait, always at least 1. |
| `WWW-Authenticate` | response | On a 401 only. `ApiKey realm="crediblog", header="X-Api-Key"`. |

---

## 14. Worked example: end to end, in `curl`

```bash
export CREDIBLOG_API_KEY="cb_live_a1b2c3d4e5f6.SECRET"
export CB="https://crediblog.com/api/v1"

# Verify the key and learn its limit
curl -s "$CB" -H "X-Api-Key: $CREDIBLOG_API_KEY"

# Your site's details, for your header and byline
curl -s "$CB/site" -H "X-Api-Key: $CREDIBLOG_API_KEY"

# Ten published posts, newest first, titles only
curl -s "$CB/posts?status=published&limit=10&fields=slug,title,excerpt,publishedAt" \
  -H "X-Api-Key: $CREDIBLOG_API_KEY"

# One article, by slug, with the body
curl -s "$CB/posts/how-to-clean-your-gutters" -H "X-Api-Key: $CREDIBLOG_API_KEY"

# Everything in one category, oldest first
curl -s "$CB/posts?status=published&category=maintenance&order=asc" \
  -H "X-Api-Key: $CREDIBLOG_API_KEY"

# Published in Q1
curl -s "$CB/posts?publishedFrom=2026-01-01&publishedTo=2026-03-31" \
  -H "X-Api-Key: $CREDIBLOG_API_KEY"

# Search titles, excerpts, and keywords
curl -s "$CB/posts?q=gutter" -H "X-Api-Key: $CREDIBLOG_API_KEY"

# The taxonomy
curl -s "$CB/categories" -H "X-Api-Key: $CREDIBLOG_API_KEY"
curl -s "$CB/tags?sort=slug&limit=100" -H "X-Api-Key: $CREDIBLOG_API_KEY"

# The calendar: what is authorised and scheduled
curl -s "$CB/planned-topics?status=approved&sort=scheduledFor&order=asc" \
  -H "X-Api-Key: $CREDIBLOG_API_KEY"
```

## 15. Worked example: a minimal TypeScript client

```ts
export interface Post {
  id: string;
  slug: string;
  title: string;
  excerpt: string | null;
  status: "draft" | "published" | "archived";
  publishedAt: string | null;
  createdAt: string;
  readingMinutes: number;
  url: string;
  coverImageUrl: string | null;
  category: { id: string; slug: string; name: string } | null;
  tags: { id: string; slug: string; name: string }[];
  contentHtml?: string | null;   // list responses omit it unless requested
  sources?: { title: string | null; url: string | null }[];
}

export interface Page<T> { data: T[]; hasMore: boolean; nextCursor: string | null }

export class Crediblog {
  constructor(private key: string, private base = "https://crediblog.com/api/v1") {}

  private async get<T>(path: string, params: Record<string, string | number> = {}): Promise<T> {
    const url = new URL(this.base + path);
    for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));

    for (let attempt = 0; ; attempt++) {
      const res = await fetch(url, { headers: { "X-Api-Key": this.key } });
      if (res.status === 429 && attempt < 5) {
        await new Promise((r) => setTimeout(r, Math.max(1, Number(res.headers.get("Retry-After") ?? 1)) * 1000));
        continue;
      }
      if (!res.ok) throw await apiError(res);
      return res.json() as Promise<T>;
    }
  }

  post(idOrSlug: string) { return this.get<Post>(`/posts/${encodeURIComponent(idOrSlug)}`); }

  /** Every matching post, paged correctly. */
  async posts(filters: Record<string, string | number> = {}): Promise<Post[]> {
    const out: Post[] = [];
    let cursor: string | null = null;
    do {
      const page: Page<Post> = await this.get("/posts", {
        limit: 100, ...filters, ...(cursor ? { cursor } : {}),
      });
      out.push(...page.data);
      cursor = page.nextCursor;
    } while (cursor);
    return out;
  }
}

// Usage
const cb = new Crediblog(process.env.CREDIBLOG_API_KEY!);
const published = await cb.posts({ status: "published" });
```

---

## 16. Common mistakes

Each of these is something clients actually do against this API. Do not do them.

1. **Paginating by offset.** There is no `offset` and no `page`. Adding `?page=2` does nothing:
   unknown query parameters are ignored, so you silently get page one again, forever. Use `cursor`.

2. **Looping until `data` is empty.** The API never sends an empty final page; it tells you the pass
   is over by setting `nextCursor` to `null` while `data` is still full. Looping on
   `data.length > 0` costs one wasted request at best, and if you also ignore `nextCursor` you
   loop forever re-fetching page one. **Stop on `nextCursor === null`.**

3. **Changing `sort` or `order` mid-pass.** The cursor is bound to both. You get
   `400 invalid_cursor`, not a subtly wrong page, but a client that catches and swallows 400s will
   look like it just stopped early.

4. **Constructing or mutating a cursor.** It is base64url, which makes it look decodable. It carries a
   version marker and is validated. Round-trip it verbatim.

5. **Never sending `If-None-Match`.** A poller that re-downloads an unchanged 100-post feed every
   minute is burning bandwidth on both sides for nothing. One header turns that into a `304`.

6. **Hot-looping through a `429`.** Retrying immediately guarantees another `429` and can keep a
   key limited for the rest of the window. Read `Retry-After` and sleep for it.

7. **Retrying a `400` or a `401`.** They are deterministic. The request is wrong, or the key is.
   Retrying wastes quota and delays the real fix.

8. **Rendering drafts.** The API returns every status by default because the key owns the site. A
   public page wants `?status=published`. Nothing filters it for you.

9. **Assuming `contentHtml` is present in a list.** It is omitted from list responses unless you
   name it in `fields`. It is `undefined`, not `""`, so a template that does not check will render
   the string "undefined".

10. **Treating `status: "archived"` as unknown.** It is a real value. A client with a strict enum
    that only knows `draft` and `published` will throw on real data.

11. **Branching on `error.message`.** The messages are prose and are reworded. Branch on
    `error.code`.

12. **Caching in a shared cache.** `Cache-Control: private` means it: responses are scoped to one
    key and one site.

13. **Building toward a write endpoint.** Every endpoint is a `GET`. Content is created and edited
    in the dashboard, or pushed to you by webhook.

---

## 17. Checklist for the client you are about to write

- [ ] Key read from an environment variable, never a literal, never shipped to a browser.
- [ ] `X-Api-Key` on every request.
- [ ] Pagination loop terminates on `nextCursor === null`, with `sort`/`order` held constant.
- [ ] `limit` never exceeds 100.
- [ ] `If-None-Match` sent when a cached ETag exists; `304` handled as "use the cached body".
- [ ] `429` honours `Retry-After`; `4xx` other than `429` is not retried.
- [ ] Errors parsed from `error.code`, with `error.requestId` logged.
- [ ] `X-Request-Id` logged on success too.
- [ ] Post `status` type includes `draft`, `published`, `archived`.
- [ ] Unknown response fields ignored rather than fatal.
- [ ] Public rendering filters `?status=published`.
- [ ] Client exposes read methods only.

---

*Generated from `apps/web/lib/apiContract.ts`, which `e2e/api-contract.spec.ts` reconciles against
the C# in `src/Crediblog.Api/Endpoints/V1/`. The machine-readable contract is at
`https://crediblog.com/api/v1/openapi.json`; the human page is `https://crediblog.com/docs`.*
