Skip to main content

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.code is stable across versions; clients branch on it.
  • error.message is not stable — don't string-match.
  • error.details is shape-stable per code (e.g. validation details is always { field, constraint, value }[]).

Status codes

CodeMeaningWhen
200OKSuccessful read / update
201CreatedSuccessful resource creation
204Not used. Always return { success: true, data: null } with 200 for consistency.
400Bad RequestMalformed params, validation failure
401UnauthorizedMissing or invalid token
403ForbiddenAuthenticated but lacks permission
404Not FoundResource does not exist (or is inactive and caller isn't admin)
409ConflictUnique constraint (slug collision, duplicate email)
422Unprocessable EntitySemantically invalid (e.g. approving rejected content)
429Too Many RequestsThrottled — includes Retry-After header
500Internal Server ErrorUnhandled 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

ParamTypeDefaultBounds
offsetinteger00 ≤ offset ≤ 10000
limitinteger301 ≤ 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 }:

  1. Validates newSlug (regex, not reserved, not already taken — rejects merges; merges need curator judgment).
  2. Atomically updates every Content.tagSlugs array that contains the old slug (Mongo multi-document update in a transaction).
  3. Writes an audit record: { actor, oldSlug, newSlug, contentCount, at }.
  4. 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 + PathPurposeAuthPaginated
GET /metaApp branding + version (CONFIG.md)publicno
GET /catalogNavigation catalog (surfaces/groups/channels/facets/platforms)publicno
GET /contentList / search contentpublic (approved only)yes
GET /content/<slug>Content detailpublic (approved only)no
GET /content/<slug>/relatedRelated-content railpublicno (fixed limit)
POST /content/submitSubmit new contentauthenticatedno
POST /content/<slug>/approveApprove pending contentcontent.approveno
POST /content/<slug>/rejectReject pending contentcontent.approveno
GET /tagsList tagspublicyes
GET /tags/<slug>Tag detail (with usage count)publicno
POST /tagsCreate tagtag.manageno
PATCH /tags/<slug>Update tag label/facettag.manageno
POST /tags/<slug>/renameAtomic renametag.manageno
POST /slug/suggestServer slug suggestionpublicno
Taxonomy CRUD (surfaces/groups/channels)catalog.managevaries
Auth endpointssee AUTH.md

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

ParamTypeDefaultNotes
qstringnone2–200 chars; debounced client-side at 300ms
tagscomma-separated slugsnoneOR within; ignored if empty
groupslugnoneSingle value
channelslugnoneRequires group
platformslug or comma-separatednoneOR within
period24h|7d|30d|90d|allallWindow over approvedAt
sortnewest|oldest|alphanewesttrending/usage reserved — see "Reserved sorts"
statusapproved|pending|rejected|allapprovedNon-approved values require content.moderate permission; otherwise 403
offset / limitint0 / 30See §2

Filter logic

  • AND across dimensions (all must match).
  • OR within a dimension that accepts multiple values (tags, platform).
  • status=approved is the default; public clients cannot override.

Sort implementations

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

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 ETag and Cache-Control: public, max-age=300, must-revalidate.
  • Client issues If-None-Match on 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>:

CodeStatusMeaning
validation.failed400DTO validation errors (details = field list)
pagination.invalid400offset/limit out of bounds
sort.unsupported400Requested a sort not implemented
query.too_short400q under min length
auth.missing_token401No Authorization header
auth.invalid_token401Token malformed, expired, or not recognized
auth.forbidden403Authenticated but lacks the required permission
content.not_found404Slug does not resolve to an active, caller-visible item
tag.not_found404
slug.reserved409Attempted to use a reserved slug
slug.conflict409Slug already in use
content.state_invalid422E.g. approving already-approved content
ratelimit.exceeded429With Retry-After header
internal.error500Unhandled — 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-8 on every response.
  • X-Request-Id echoed from request or generated; also appears in meta.requestId.
  • Cache-Control: no-store on authenticated endpoints; public, max-age=… on read-only public endpoints where safe.
  • ETag on 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.