Conventions
Base URL, response envelope, pagination, ordering, filtering, and the error format shared by every endpoint.
The external API is uniform: every list endpoint paginates the same way, every error has the same shape, and every single-resource route accepts a UUID or a slug. Learn these once and the rest of the reference reads itself.
Base URL
Every external route is prefixed with /api/v1/external/:
https://api.clog.dev/api/v1/external/posts
https://api.clog.dev/api/v1/external/pages/about
https://api.clog.dev/api/v1/external/redirectsClog is a hosted service — https://api.clog.dev is the same base URL for every consumer. The workspace is implicit from the API key — paths never carry a :workspaceId segment.
Headers
| Header | When | Value |
|---|---|---|
X-API-Key | every request | Your workspace-bound API key (clog_live_xxx). See Authenticating requests. |
Content-Type | write requests | application/json (or multipart/form-data for media upload). |
Accept | optional | application/json is the only supported response media type. |
Resource identifiers — idOrSlug
Single-resource routes accept either a UUID (resolved by id) or any other string (resolved by slug), scoped to the bound workspace:
# By id
curl https://api.clog.dev/api/v1/external/posts/0a4b3c2d-... -H "X-API-Key: clog_live_xxx"
# By slug
curl https://api.clog.dev/api/v1/external/posts/my-first-post -H "X-API-Key: clog_live_xxx"Whichever you pass, the response shape is identical.
Response envelopes
Single resource
A single-resource read or write returns the resource object directly — no wrapper:
{
"id": "0a4b3c2d-...",
"slug": "my-first-post",
"title": "My first post",
"bodyJson": [ /* ... */ ]
}Paginated list
Every list endpoint returns a uniform PaginatedResponse:
{
"data": [ /* items[] */ ],
"page": 1,
"pageSize": 10,
"order": "desc",
"orderBy": "publishedAt",
"total": 42,
"totalPages": 5
}| Field | Meaning |
|---|---|
data | The current page of items, typed to the endpoint's resource. |
page | 1-based page number returned. |
pageSize | Page size applied. |
order | asc or desc. |
orderBy | Sort key (omitted when no explicit sort was requested). |
total | Total matching items across all pages. |
totalPages | Total page count; minimum 1. |
Pagination
All list endpoints accept the same four query params:
| Param | Default | Notes |
|---|---|---|
page | 1 | 1-based page number. |
pageSize | 10 | Items per page. |
order | desc | asc or desc. |
orderBy | endpoint-specific | Must be one of the endpoint's allowed sort keys; see the endpoint reference. |
Example — second page of 25 published posts by newest first:
curl 'https://api.clog.dev/api/v1/external/posts?status=published&page=2&pageSize=25&order=desc&orderBy=publishedAt' \
-H "X-API-Key: clog_live_xxx"Allowed orderBy values vary per endpoint — e.g. posts accept createdAt | updatedAt | publishedAt | title | status | seoScore | readabilityScore, while media accepts createdAt | sizeBytes | mimeType. Each endpoint's reference page lists its allow-list.
Filtering
Filters are passed as query params. The available filters vary per endpoint — the reference pages enumerate them — but a few patterns recur:
| Pattern | Where | Behaviour |
|---|---|---|
q=<term> | posts, pages, categories, tags, authors | Case-insensitive search across the entity's user-facing text fields (e.g. title, excerpt, bodyText for posts). |
status=<value> | posts, pages | Restrict by lifecycle status. |
authorId, categoryId, tagId | posts | Restrict by FK. categoryId= (empty) filters to uncategorised. |
dateFrom, dateTo | posts | ISO datetime bounds on createdAt. |
isActive=true|false | authors, api-keys | Boolean filter on the relevant active/inactive predicate. |
mimeTypePrefix=image/ | media | Restrict by a MIME prefix. |
Error envelope
Every error response — regardless of status — returns the same JSON shape:
{
"code": "BAD_REQUEST",
"message": "Invalid post body: 'title' is required.",
"details": { /* optional, see below */ }
}| Field | Meaning |
|---|---|
code | Machine-readable error code. One of the values below. Use this for programmatic branching. |
message | Human-readable summary. Safe to log; not safe to expose to end users without translation. |
details | Optional extra context. Shape depends on the code (Zod validation tree, Prisma constraint info, per-row bulk errors, etc.). |
Error codes
| Code | HTTP | Typical cause |
|---|---|---|
BAD_REQUEST | 400 | Zod validation failure, invalid state transition, cross-workspace FK miss, fromPath === toPath on a redirect. details usually carries the Zod issues. |
UNAUTHORIZED | 401 | Missing, unknown, revoked, or expired X-API-Key. Deliberately generic. |
FORBIDDEN | 403 | Key is valid but the creator's membership doesn't grant the route's required permission. |
NOT_FOUND | 404 | Resource doesn't exist in the bound workspace. (A slug present in another workspace is still a 404 here.) |
METHOD_NOT_ALLOWED | 405 | The HTTP method isn't supported on this path. |
PAYMENT_REQUIRED | 402 | Reserved. Not currently emitted by v1 endpoints. |
CONFLICT | 409 | Duplicate slug, blocked delete due to references (e.g. deleting an author still attached to posts), duplicate redirect fromPath. |
RATE_LIMITED | 429 | Global rate limit tripped. Back off and retry. |
INTERNAL | 500 | Unhandled server error. Treat as transient; retry with backoff. |
Handling errors
async function fetchPost(slug: string) {
const res = await fetch(
`https://api.clog.dev/api/v1/external/posts/${slug}`,
{ headers: { 'X-API-Key': process.env.CLOG_API_KEY! } },
);
if (res.status === 404) return null; // post doesn't exist; treat as missing
if (!res.ok) {
const err = await res.json().catch(() => ({ code: 'INTERNAL', message: res.statusText }));
throw new Error(`Clog ${err.code}: ${err.message}`);
}
return res.json();
}Branch on code, not on the human-readable message — messages are not part of the API contract and may change without notice.
Date and time
All timestamps are ISO 8601 strings in UTC ("2026-05-23T12:34:56.789Z"). All dateFrom / dateTo filters accept the same format. There is no separate date-only type in v1.
Idempotency and retries
- Reads (
GET) are safe to retry — they don't mutate state. - Soft-delete on
DELETE— deleting a post or page stampsdeletedAtand frees the slug. The body returned is the pre-delete snapshot withdeletedAtset; the row is hidden from all subsequent reads. - Tag attach / detach is idempotent: re-attaching an attached tag (or detaching a missing one) is a silent no-op.
- Link-health reports (
POST /external/link-health) upsert on(workspaceId, path)and bumphitCount— repeat reports for the same path are explicitly safe to send.
For other write operations, treat retries as you would any non-idempotent HTTP call: retry only after confirming the prior call did not commit, and prefer idempotent designs in your own code (e.g. by computing a deterministic slug client-side).
Related
- Authenticating requests — the
X-API-Keycontract. - Rendering content — what the post / page response actually looks like and how to render it.
- Endpoint reference — the full per-endpoint spec.
- Reference → Error codes — the same error-code list as a lookup page.