api-design · git:20261106.206f5e9 · 2026-11-06 · sha256 5eed230ee79dbed0

api-design git:20261106.206f5e9A

Immutable. This exact content never changes and is served at /api/v1/blob/5eed230ee79dbed0.

---
name: api-design
description: REST conventions for iHub's HTTP API — URL naming, `_action` segments, status codes, RFC 9457 error bodies, cursor pagination, compatibility. Auto-invoked when adding or changing a route handler, an endpoint's request or response shape, or an error response.
user-invocable: false
---

# API Design

The canonical rules are in **`docs/api-guidelines.md`**; read it for the reasoning and the deviations from the
Zalando guidelines. This is the working summary. For input validation and path safety use the `api-security` skill.

## Before you write the handler

1. Write the OpenAPI comment first: request, success body, and every error `code` the endpoint can answer with.
2. Check the checklist at the end of `docs/api-guidelines.md`.

## Rules

**URLs.** Plural nouns, kebab-case, no verbs, at most two nesting levels. Query, path and body names are camelCase.
IDs are generated by the server and opaque.

**Actions.** Anything that is not create/read/update/delete is a path segment prefixed with `_`:
`POST /scheduled-tasks/{taskId}/_duplicate`, `POST /scheduled-tasks/_preview`, `GET /scheduled-tasks/_notifications`.
Prefer a resource (`POST …/runs`) when one models the operation. Never a bare `/run`, `/cancel`, `/rotate`.
(`scheduledTasks.js` is the reference.)

**Status codes.** `GET` 200 · `POST` create 201 + `Location` · `PUT`/`PATCH` 200 with the resource · `DELETE` 204 ·
async 202 + job resource. `400` for validation (never `422`), `401`, `403`, `404`, `409` (state conflict and
`ETAG_MISMATCH`), `413`/`415`, `429` + `Retry-After`, `502`/`503`/`504` for upstream. No redirects.

**Bodies.** The top level is an object. The resource itself is the body — no `{ success: true }`, no
`{ message: '… successfully' }`. Dates are RFC 3339 UTC strings. Durations carry the unit in the name (`timeoutMs`).
Omit members without a value; no `null` booleans; enums are strings and clients tolerate unknown values.

**Errors.** Throw, never answer:

```js
throw new ServiceError('NOT_FOUND', `Scheduled task ${taskId} not found`, { details: { taskId } });
```

Never `res.status(404).json({ error: … })`. The code comes from `shared/apiErrors.js` (`UPPER_SNAKE`); a new one also
goes into `apiErrors` of `shared/i18n/en.json` and `de.json`. Target body: `application/problem+json`
(`type`, `title`, `status`, `detail`, `code`, optional `details`, validation `errors[{pointer, code, message}]`).
OAuth endpoints (`/api/oauth/*`, `/.well-known/*`) keep RFC 6749.

**Lists.** `GET …?limit=50&cursor=…` → `{ "items": [...], "nextCursor": "…" | null }`. No `page`, `pageSize`,
`offset`, `after`. Unpaged collections still answer `{ "items": [...] }`.

**Compatibility.** Additive changes only. A breaking change to an endpoint only our client calls ships with the client in
the same release. External callers (API keys, integrations, browser extension, Nextcloud, Office) get `Deprecation` and
`Sunset` headers for at least one release. **Ask the user before adding a compatibility shim.**

**Headers.** `ETag`/`If-Match` for concurrent edits, `Idempotency-Key` for a `POST` that must not run twice,
`Retry-After` on 429/503, `Location` on 201/202.

## Touching an existing endpoint

Bring it in line when that is cheap and no outside caller breaks: replace a hand-written `{ error }` with a thrown
`ServiceError`, drop a `{ success }` envelope, move a bare verb to `_action` together with its client call.
Do not widen a task into a rewrite of the surrounding API.