API Contract
This document specifies the conventions every HTTP endpoint in this service must follow: the response envelope, pagination, slug rules, error shape, and the full catalog of content/taxonomy endpoints. Auth mechanics live in AUTH.md; the data model lives in TAXONOMY.md; config/branding in CONFIG.md.
1. Response envelope
Every response — success or error — is a JSON object. The client checks success first, then reads data or error.
Success
{
"success": true,
"data": { /* resource or collection */ },
"pagination": { // only on list endpoints
"offset": 0,
"limit": 30,
"total": 142,
"hasMore": true
},
"meta": { // optional
"requestId": "req_01H…",
"durationMs": 14
}
}
Error
{
"success": false,
"error": {
"code": "content.not_found", // stable, machine-readable
"message": "Content not found", // human, localizable
"details": { /* optional, per-code payload */ }
},
"meta": { "requestId": "req_01H…" }
}
error.codeis stable across versions; clients branch on it.error.messageis not stable — don't string-match.error.detailsis shape-stable per code (e.g. validationdetailsis always{ field, constraint, value }[]).
Status codes
| Code | Meaning | When |
|---|---|---|
| 200 | OK | Successful read / update |
| 201 | Created | Successful resource creation |
| 204 | Not used. Always return { success: true, data: null } with 200 for consistency. | |
| 400 | Bad Request | Malformed params, validation failure |
| 401 | Unauthorized | Missing or invalid token |
| 403 | Forbidden | Authenticated but lacks permission |
| 404 | Not Found | Resource does not exist (or is inactive and caller isn't admin) |
| 409 | Conflict | Unique constraint (slug collision, duplicate email) |
| 422 | Unprocessable Entity | Semantically invalid (e.g. approving rejected content) |
| 429 | Too Many Requests | Throttled — includes Retry-After header |
| 500 | Internal Server Error | Unhandled server exception |
Implementation (NestJS)
A global response interceptor wraps controller returns. A global exception filter produces the error envelope. Controllers continue to return raw DTOs; the interceptor attaches success/data/pagination. List endpoints return a typed Paginated<T> whose { items, total } becomes data/pagination in the envelope.
Example controller return:
@Get()
list(@Query() q: ContentSearchQueryDto): Promise<Paginated<ContentDto>> {
return this.content.search(q);
}
Envelope assembly happens in the interceptor; controllers never build it manually.
2. Pagination
Offset/limit only. Cursor pagination is out of scope.
Query params
| Param | Type | Default | Bounds |
|---|---|---|---|
offset | integer | 0 | 0 ≤ offset ≤ 10000 |
limit | integer | 30 | 1 ≤ limit ≤ 200 |
Out-of-bounds values return 400 with error.code = "pagination.invalid".
Response metadata
"pagination": {
"offset": 0,
"limit": 30,
"total": 142, // total rows matching the current filter
"hasMore": true // offset + data.length < total
}
total comes from countDocuments(filter) on the same query. For very large unfiltered collections, use estimatedDocumentCount() — opt-in via ?estimate=true when approximate is acceptable.
Sort stability
Offset pagination requires a stable order. Every sort includes _id as a final tiebreaker — the service layer enforces this; controllers don't have to.
3. Slug convention
Slugs are used in place of ObjectIds in public URLs and API params wherever the entity has a meaningful human-readable identifier (tags, groups, channels, surfaces, content). ObjectIds are only in URLs for entities without a natural slug.
Rules
- Regex:
^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$- Lowercase letters, digits, internal hyphens.
- Starts and ends with a letter or digit (no leading/trailing hyphen).
- Length 1–64.
- Validated at the API boundary with a
@IsSlug()class-validator decorator. Not enforced at the database layer so Unicode / extended sets can be added later without migration. - Globally unique for: tags, content, surfaces.
- Unique within parent for: groups (per surface), channels (per group).
Reserved slugs
The following cannot be used as any slug:
new, edit, admin, api, auth, catalog, search, meta, tags, settings
Reserved-slug violations return 409 with error.code = "slug.reserved".
Collisions
Non-reserved collisions return 409 with error.code = "slug.conflict" and details = { slug, entity }.
Rename
Tags are the only entity where rename is a frequent editorial action. POST /tags/<slug>/rename with { newSlug }:
- Validates
newSlug(regex, not reserved, not already taken — rejects merges; merges need curator judgment). - Atomically updates every
Content.tagSlugsarray that contains the old slug (Mongo multi-document update in a transaction). - Writes an audit record:
{ actor, oldSlug, newSlug, contentCount, at }. - Returns the new slug in the envelope.
Other entity renames (group/channel/surface) are admin operations that cascade to the catalog; same pattern, per-entity endpoint.
Generation hint
Clients may call POST /slug/suggest with { value, entity } to get a server-validated slug candidate from a human title. The server lowercases, replaces whitespace/non-alphanumeric runs with hyphens, collapses repeats, and trims to length. Collision avoidance is the caller's problem — the endpoint does not check uniqueness.
4. Endpoint index
| Method + Path | Purpose | Auth | Paginated |
|---|---|---|---|
GET /meta | App branding + version (CONFIG.md) | public | no |
GET /catalog | Navigation catalog (surfaces/groups/channels/facets/platforms) | public | no |
GET /content | List / search content | public (approved only) | yes |
GET /content/<slug> | Content detail | public (approved only) | no |
GET /content/<slug>/related | Related-content rail | public | no (fixed limit) |
POST /content/submit | Submit new content | authenticated | no |
POST /content/<slug>/approve | Approve pending content | content.approve | no |
POST /content/<slug>/reject | Reject pending content | content.approve | no |
GET /tags | List tags | public | yes |
GET /tags/<slug> | Tag detail (with usage count) | public | no |
POST /tags | Create tag | tag.manage | no |
PATCH /tags/<slug> | Update tag label/facet | tag.manage | no |
POST /tags/<slug>/rename | Atomic rename | tag.manage | no |
POST /slug/suggest | Server slug suggestion | public | no |
| Taxonomy CRUD (surfaces/groups/channels) | catalog.manage | varies | |
| Auth endpoints | see AUTH.md |
5. Content list and search
One endpoint serves every listing, filtering, and text-search permutation. The same shape powers the default feed, a group view, a tag-filtered query, and a text search.
GET /content
?q=&tags=a,b&group=&channel=&platform=&period=&sort=&status=
&offset=&limit=
Parameters
| Param | Type | Default | Notes |
|---|---|---|---|
q | string | none | 2–200 chars; debounced client-side at 300ms |
tags | comma-separated slugs | none | OR within; ignored if empty |
group | slug | none | Single value |
channel | slug | none | Requires group |
platform | slug or comma-separated | none | OR within |
period | 24h|7d|30d|90d|all | all | Window over approvedAt |
sort | newest|oldest|alpha | newest | trending/usage reserved — see "Reserved sorts" |
status | approved|pending|rejected|all | approved | Non-approved values require content.moderate permission; otherwise 403 |
offset / limit | int | 0 / 30 | See §2 |
Filter logic
- AND across dimensions (all must match).
- OR within a dimension that accepts multiple values (
tags,platform). status=approvedis the default; public clients cannot override.
Sort implementations
sort | Mongo sort |
|---|---|
newest | { approvedAt: -1, _id: -1 } |
oldest | { approvedAt: 1, _id: 1 } |
alpha | { title: 1, _id: 1 } — requires index on title |
Reserved sorts — endpoint returns 400 with error.code = "sort.unsupported" for trending and usage. Will be implemented once signal aggregation lands.
Text search
V1 uses a MongoDB text index on title + description. Activates only when q is present. Falls back to $regex on title for tokens under 3 characters. Scoring is Mongo's default; no custom weighting in v1.
Empty results
Never 404. Return data: [] with pagination.total: 0.
Response shape
{
"success": true,
"data": [ /* ContentDto[] */ ],
"pagination": { "offset": 0, "limit": 30, "total": 142, "hasMore": true },
"meta": { "requestId": "…", "durationMs": 14 }
}
See TAXONOMY.md for ContentDto.
6. Catalog endpoint
GET /catalog
Returns the full navigation tree the client needs to render Layer 1–3 nav, the facet-grouped tag explorer, and the platform filter list.
Response (abridged)
{
"success": true,
"data": {
"surfaces": [
{
"slug": "content",
"label": "Content",
"routePrefix": "/browse",
"browseMode": "topic-groups",
"accentColor": "#3b82f6",
"displayOrder": 0,
"groups": [
{
"slug": "ai",
"label": "AI",
"color": "#a855f7",
"displayOrder": 0,
"channels": [
{ "slug": "video", "label": "Video", "displayOrder": 0 },
{ "slug": "papers", "label": "Papers", "displayOrder": 1 }
]
}
]
}
],
"facets": [
{ "key": "topic", "label": "Topic", "color": "#f59e0b" },
{ "key": "format", "label": "Format", "color": "#10b981" }
],
"platforms": [
{ "slug": "youtube", "label": "YouTube", "color": "#ff0000" },
{ "slug": "bluesky", "label": "Bluesky", "color": "#0085ff" }
],
"updatedAt": "2026-04-20T18:00:00Z",
"etag": "W/\"abc123…\""
}
}
Caching
- Response includes
ETagandCache-Control: public, max-age=300, must-revalidate. - Client issues
If-None-Matchon subsequent requests; server returns 304 when unchanged. - Writes to any taxonomy entity (
Surface,Group,Channel,Tag,Platform) invalidate the ETag.
Empty groups
Operator choice, expressed in config: either omit empty groups or include with count: 0. V1 includes all active groups regardless of count so the nav is stable as content ebbs and flows. Counts are not returned in the catalog (getting them would require a scan); clients query GET /content?group=<slug>&limit=0 if they need a count chip.
7. Error code reference
A non-exhaustive starting set. Codes are lowercased, dotted, <domain>.<condition>:
| Code | Status | Meaning |
|---|---|---|
validation.failed | 400 | DTO validation errors (details = field list) |
pagination.invalid | 400 | offset/limit out of bounds |
sort.unsupported | 400 | Requested a sort not implemented |
query.too_short | 400 | q under min length |
auth.missing_token | 401 | No Authorization header |
auth.invalid_token | 401 | Token malformed, expired, or not recognized |
auth.forbidden | 403 | Authenticated but lacks the required permission |
content.not_found | 404 | Slug does not resolve to an active, caller-visible item |
tag.not_found | 404 | |
slug.reserved | 409 | Attempted to use a reserved slug |
slug.conflict | 409 | Slug already in use |
content.state_invalid | 422 | E.g. approving already-approved content |
ratelimit.exceeded | 429 | With Retry-After header |
internal.error | 500 | Unhandled — details omitted in prod |
New codes are added as endpoints grow. Keep them domain-prefixed so grepping by domain remains useful.
8. Headers and conventions
Content-Type: application/json; charset=utf-8on every response.X-Request-Idechoed from request or generated; also appears inmeta.requestId.Cache-Control: no-storeon authenticated endpoints;public, max-age=…on read-only public endpoints where safe.ETagon catalog and/meta.- Dates are ISO 8601 UTC strings (
2026-04-21T12:34:56Z). - IDs are 24-char hex ObjectIds; clients treat them as opaque.