
api-design
Designs and reviews REST API endpoints with best practices for URLs, status codes, pagination, and more.
Lift-13ppscreen tier · directional
92
Trustsecurity · quality · license · upkeep
Scan Saferisk 0/100
LicenseMITserved via MCP
Skill-matched tasks (generated, public + held-out) · glm-5.3-flash@cf
-13pp95% CI [-38, 0]unverified · screen tier
trigger rate 100% · 24 trials
A/B outputs — median trial per arm
Same task, same model: without the skill (left) vs with it (right).
api-design--m1
baseline · 1.00
# Task Management REST API
## Resource
`/tasks` — collection; `/tasks/{id}` — individual task.
## Endpoints
| Action | Method & Path | Success | Errors |
|---|---|---|---|
| Create a task | `POST /tasks` | `201 Created` (return the created task, with `Location: /tasks/{id}` header) | `400 Bad Request` (invalid body, e.g. missing title or malformed due date) |
| List all tasks | `GET /tasks` | `200 OK` (JSON array of tasks) | — |
| Get one task | `GET /tasks/{id}` | `200 OK` | `404 Not Found` (id doesn't exist) |
| Update a task | `PUT /tasks/{id}` (full replace) or `PATCH /tasks/{id}` (partial update) | `200 OK` (return updated task) | `400 Bad Request` (invalid fields), `404 Not Found` |
| Delete a task | `DELETE /tasks/{id}` | `204 No Content` (empty body) | `404 Not Found` |
## Task representation (JSON)
```json
{
"id": "a1b2c3",
"title": "Buy groceries",
"completed": false,
"dueDate": "2025-07-01"
}
```
## Notes
- Use `PATCH` if clients often toggle just `completed`; use `PUT` for full-object updates (or support both).
- Return `405 Method Not Allowed` for unsupported methods on a path, and `400` with a descriptive error body for validation failures.
- Optional extras: filtering/sorting via query params on `GET /tasks` (e.g. `?completed=false&dueBefore=2025-07-01`), and `400` for a non-UUID/invalid `{id}` format if you validate ids.with skill · 1.00
# Task Management REST API
## Endpoints
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/v1/tasks` | Create a task |
| GET | `/api/v1/tasks` | List all tasks |
| GET | `/api/v1/tasks/:id` | Get one task |
| PATCH | `/api/v1/tasks/:id` | Partially update a task |
| PUT | `/api/v1/tasks/:id` | Fully replace a task (optional) |
| DELETE | `/api/v1/tasks/:id` | Delete a task |
## Status Codes
**POST /api/v1/tasks**
- `201 Created` — task created; return the task body and a `Location: /api/v1/tasks/:id` header
- `400 Bad Request` — malformed JSON
- `422 Unprocessable Entity` — valid JSON but invalid data (e.g., missing title, bad due date format)
**GET /api/v1/tasks**
- `200 OK` — array of tasks
- Optional filtering: `GET /api/v1/tasks?completed=false&sort=due_date`
**GET /api/v1/tasks/:id**
- `200 OK` — task found
- `404 Not Found` — no task with that id
**PATCH /api/v1/tasks/:id**
- `200 OK` — updated, return the updated task
- `400` / `422` — validation errors
- `404 Not Found` — task doesn't exist
**DELETE /api/v1/tasks/:id**
- `204 No Content` — deleted, empty body
- `404 Not Found` — task doesn't exist
## Example Payloads
Create request:
```json
POST /api/v1/tasks
{
"title": "Buy groceries",
"completed": false,
"due_date": "2025-06-01"
}
```
Task response:
```json
{
"data": {
"id": "abc-123",
"title": "Buy groceries",
"completed": false,
"due_date": "2025-06-01"
}
}
```
Error response:
```json
HTTP/1.1 422 Unprocessable Entity
{
"error": {
"code": "validation_error",
"message": "Request validation failed",
"details": [
{ "field": "title", "message": "Title is required", "code": "missing_field" }
]
}
}
```
## Notes
- Use **PATCH** for partial updates (e.g., just flipping `completed`); use **PUT** only if clients always send the full resource.
- Plural noun resource (`tasks`), kebab-case, versioned with `/v1/`.
- `completed` defaults to `false` on create if omitted; `due_date` should be an ISO 8601 date.api-design--m2
baseline · 1.00
# Orders API Pagination & Filtering Design
## Query Parameters
| Parameter | Type | Default | Constraints |
|---|---|---|---|
| `status` | string | *(none = all)* | One of `pending`, `paid`, `shipped` |
| `created_after` | ISO 8601 timestamp | *(none)* | Inclusive lower bound |
| `created_before` | ISO 8601 timestamp | *(none)* | Inclusive upper bound |
| `limit` | integer | `25` | 1–100 (max page size: **100**) |
| `cursor` | string | *(none)* | Opaque cursor from previous response |
**Example request:**
```
GET /orders?status=paid&created_after=2024-01-01T00:00:00Z&created_before=2024-01-31T23:59:59Z&limit=50
```
## Pagination approach: cursor-based (recommended)
Use **keyset pagination** with an opaque cursor derived from the last item's sort key (`created_at` + `id` as a tiebreaker). This is stable under inserts/deletes and performs well at scale — unlike `page`/`offset`, which skips or duplicates rows when data changes between requests.
- Sort orders by `created_at DESC, id DESC` (newest first) — document this as fixed.
- The cursor is a base64-encoded, server-signed token (e.g., `{created_at, id}`); clients must treat it as opaque.
- Return `next_cursor` only when more results exist; `null`/omitted means end of results.
## Response Body
```json
{
"data": [
{
"id": "ord_01HVQ3XK9M",
"customer_id": "cus_8Zk2P",
"status": "paid",
"created_at": "2024-01-15T10:32:00Z"
}
],
"pagination": {
"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNC0wMS0xNVQxMDoxNDowMFoiLCJpZCI6Im9yZF8wMUhWUTNYSEFNIn0",
"has_more": true,
"limit": 50
}
}
```
## Validation & Error Handling
- Invalid `status` → `400` with a message listing allowed values.
- `limit` out of range → `400` (or silently clamp to 100; pick one and document it — explicit 400 is clearer).
- `created_after > created_before` → `400`.
- Unknown/expired cursor → `400` (clients should restart from page 1).
- Invalid date format → `400` with expected format (`ISO 8601 / RFC 3339`).
## Notes
- Keep `limit` rather than `page_size`/`per_page` — it's the most common convention (Stripe, GitHub, etc.).
- If you must support offset pagination for legacy clients, cap `offset` (e.g., 10,000) to protect the database.
- Consider `Link` headers (`rel="next"`) as an alternative to embedding the cursor in the body — either is fine; just be consistent.with skill · 1.00
# Designing `GET /orders` with Pagination and Filtering
## Query Parameters
| Parameter | Type | Default | Notes |
|---|---|---|---|
| `status` | string | (none = all) | One of `pending`, `paid`, `shipped`. Support comma-separated multiple values: `status=pending,paid`. Reject invalid values with `400`. |
| `created_at[after]` | ISO 8601 datetime | (none) | Inclusive lower bound |
| `created_at[before]` | ISO 8601 datetime | (none) | Inclusive upper bound |
| `customer_id` | string | (none) | Cheap to add now; clients will ask for it |
| `sort` | string | `-created_at` | e.g. `sort=-created_at,id` |
| `limit` | integer | 20 | **Default page size: 20**, **Max page size: 100**. Values >100 → clamp or return `400` (pick one and document it; clamping with a `meta` echo is friendlier). `limit=0` or negative → `400`. |
| `cursor` | string | (none) | Opaque pagination token (see below) |
Example:
```
GET /api/v1/orders?status=paid,shipped&created_at[after]=2025-01-01T00:00:00Z&created_at[before]=2025-01-31T23:59:59Z&limit=20
```
## Cursor vs. offset pagination
- **Cursor-based (recommended):** stable under concurrent inserts, constant performance on large datasets. Cursor encodes the last item's `created_at` + `id` (tiebreaker), so ordering must be deterministic: `ORDER BY created_at DESC, id DESC`.
- Offset (`page`/`per_page`) is fine only if the dataset is small (<10K) or clients need to jump to page N.
## Response Body
```json
{
"data": [
{
"id": "ord_abc123",
"customer_id": "cus_8812",
"status": "paid",
"created_at": "2025-01-15T10:30:00Z"
}
],
"meta": {
"has_next": true,
"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNS0wMS0xNVQxMDoxNTowMFoiLCJpZCI6Im9yZF94eTk5In0",
"limit": 20
},
"links": {
"self": "/api/v1/orders?status=paid&limit=20",
"next": "/api/v1/orders?status=paid&limit=20&cursor=eyJjcmVhdGVkX2F0Ijoi..."
}
}
```
Key points:
- **`data` / `meta` / `links` envelope** — `links.next` gives clients a copy-pasteable URL; omit `next` (or set `null`) on the last page.
- Fetch `limit + 1` rows to compute `has_next` without a count query. Avoid `meta.total` with cursor pagination — counting on every page is expensive; if clients need totals, offer them via a separate `HEAD`-style count or an offset mode.
- Timestamps in UTC ISO 8601 with `Z`.
## Validation errors
Return `400` with field-level details:
```json
{
"error": {
"code": "validation_error",
"message": "Request validation failed",
"details": [
{ "field": "status", "message": "Must be one of: pending, paid, shipped", "code": "invalid_value" },
{ "field": "limit", "message": "Must be between 1 and 100", "code": "out_of_range" }
]
}
}
```
## Implementation notes
- **Index:** composite index on `(status, created_at DESC, id DESC)` (or `(created_at, id)` if status filtering is rare) so the cursor query is an index scan.
- **Tiebreaker:** always include `id` in the sort/cursor — `created_at` alone can collide.
- **Document limits:** default 20, max 100, and state whether oversized `limit` is clamped or rejected.api-design--m3
baseline · 0.74
# Consistent JSON Error Format
Use a single error envelope across all endpoints, based on RFC 9457 (Problem Details) style:
```json
{
"error": {
"type": "invalid_request_error",
"code": "card_declined",
"message": "Your card was declined.",
"param": "payment_method",
"doc_url": "https://api.example.com/docs/errors#card_declined",
"request_id": "req_8f3k2m9x"
}
}
```
Key fields:
- **`type`** — broad category: `invalid_request_error`, `authentication_error`, `permission_error`, `not_found`, `rate_limit_error`, `idempotency_error`, `api_error` (our fault / 5xx).
- **`code`** — machine-readable, stable identifier for programmatic handling.
- **`message`** — human-readable, safe to display.
- **`param`** — which field caused the error (for validation).
- **`request_id`** — always include; it's what support will ask for.
Validation errors with multiple failures can add a `errors: [{code, param, message}]` array.
Rules:
- HTTP status codes stay semantically correct (400, 401, 402, 403, 404, 409, 422, 429, 500, 503).
- Never return `200` with an error body.
- Include `request_id` in response headers too (`X-Request-Id`).
- Publish the full error code catalog in docs and treat codes as immutable once shipped.
# Versioning Strategy
**URL-path versioning** is the pragmatic choice for a public API: `/v1/invoices`. It's explicit, cacheable, easy to route, and easy for customers to reason about.
Handling breaking changes:
1. **Never break within a version.** Once `/v1` is public, existing fields, semantics, and error codes are frozen.
2. **Additive changes are fine in-place:** new optional request fields, new response fields (clients should ignore unknown fields — document this), new endpoints, new enum values (with care — document that enums may grow).
3. **Breaking changes require a new version:** removing/renaming fields, changing field types or semantics, changing auth behavior, changing default pagination, removing endpoints.
4. **Deprecation process:**
- Announce via changelog, email, and status page.
- Mark deprecated resources in docs; return a `Deprecation` and `Sunset` header on responses using deprecated behavior.
- Give a long runway (12+ months is common for billing/financial APIs).
- Track usage per version so you know when it's safe to retire.
5. **Version lifecycle:** only ship a `/v2` when you actually have breaking changes — don't version for additive work. Keep old versions running until usage drops below a threshold, then sunset with notice.
Practical tips for a billing API specifically:
- Use **idempotency keys** on POST /v1/payments — required for payment retries.
- Treat amounts/currency formats as frozen early (e.g., integer minor units) — changing them later is a forced v2.
- Add a `X-API-Version` echo header so customers can verify what they're pinned to.with skill · 1.00
## Error Response Format
Use one consistent envelope across all endpoints:
```json
HTTP/1.1 422 Unprocessable Entity
{
"error": {
"code": "validation_error",
"message": "Request validation failed",
"details": [
{ "field": "amount", "message": "Must be a positive number", "code": "out_of_range" },
{ "field": "currency", "message": "Must be a valid ISO 4217 code", "code": "invalid_format" }
]
}
}
```
Key rules:
- **Machine-readable `code`** (snake_case): `not_found`, `validation_error`, `card_declined`, `invoice_already_paid`, `rate_limit_exceeded`, `idempotency_key_reused`. Clients branch on `code`, never on `message` strings.
- **`details` array for field-level errors** — critical for a billing API where a single request can fail validation in multiple places.
- **HTTP status codes carry the semantics** — never return 200 with an error body. Map:
- `400` malformed JSON / bad request, `401` missing/invalid key, `403` insufficient permissions, `404` unknown resource, `409` state conflicts (e.g., paying an already-paid invoice), `422` semantically invalid data, `429` rate limited (with `Retry-After`), `500` unexpected (never leak internals).
- **Billing-specific extras**: include a `request_id` at the top level for support/debugging, and use idempotency keys on POST /v1/payments — return a distinct `idempotency_error` code on key reuse with a different payload.
## Versioning Strategy
**URL path versioning** (`/v1/...`) is the right choice for a public billing API — explicit, easy to route, cacheable, and trivially testable with curl.
Handle changes like this:
**Non-breaking — no new version needed:**
- Adding new response fields (clients must ignore unknown fields — document this)
- Adding new optional query params or endpoints
- Adding new enum values (document that clients shouldn't exhaustively switch on enums like `invoice.status`)
**Breaking — new version required:**
- Removing/renaming fields, changing field types or formats (e.g., cents → decimal), changing auth, changing URL structure, removing enum values
**Deprecation lifecycle:**
1. Announce 6+ months ahead (email, changelog, status page)
2. Emit a `Deprecation` and `Sunset` header on v1 responses once announced:
```
Sunset: Sat, 01 Jan 2026 00:00:00 GMT
```
3. Keep at most 2 active versions (v1 + v2)
4. After sunset, return `410 Gone` with an error body pointing to migration docs
**Practical tip for billing specifically:** money field format changes are the most common breaking change. Decide now whether amounts are integer minor units (cents) or decimal strings, and lock it in for v1 — changing it later forces a v2.api-design--m4
baseline · 1.00
# API Redesign Recommendations
## 1. Resource-oriented paths (RESTful)
Your current endpoints are RPC-style. Move to resource nouns:
| Current | Proposed |
|---|---|
| `POST /createSubscription` | `POST /v1/subscriptions` |
| `GET /getSubscription?id=123` | `GET /v1/subscriptions/{id}` |
| `POST /cancelSubscription` | `DELETE /v1/subscriptions/{id}` (or `POST /v1/subscriptions/{id}/cancel` if cancel has side effects like proration) |
| `GET /getAllSubscriptions` | `GET /v1/subscriptions` |
| `PUT /updateSubscription` | `PATCH /v1/subscriptions/{id}` (partial updates; keep `PUT` only for full replacement) |
Add `v1` prefix now so future breaking changes ship as `v2`.
## 2. Status codes
- `201 Created` + `Location: /v1/subscriptions/{id}` on create
- `200 OK` on read/update; `204 No Content` on delete/cancel
- `400 Bad Request` — malformed input / validation errors
- `401 Unauthorized` — missing/invalid credentials
- `403 Forbidden` — authenticated but not allowed
- `404 Not Found` — unknown subscription ID
- `409 Conflict` — e.g., canceling an already-canceled subscription
- `422 Unprocessable Entity` — semantically invalid payload (optional; 400 is fine)
- `429 Too Many Requests` — rate limited
- `5xx` — server faults, never leak stack traces
## 3. Error format (JSON, not plain text)
Use a consistent envelope, ideally aligned with RFC 9457 (Problem Details):
```json
{
"error": {
"code": "subscription_already_canceled",
"message": "Subscription sub_123 was already canceled on 2024-05-01.",
"status": 409,
"request_id": "req_abc123",
"details": [
{ "field": "plan_id", "issue": "must be one of: basic, pro, enterprise" }
]
}
}
```
Machine-readable `code` + human `message` + `request_id` for support/debugging.
## 4. Pagination
Cursor-based (stable under writes, better than offset for large datasets):
```
GET /v1/subscriptions?limit=50&cursor=cur_xyz&status=active
```
Response:
```json
{
"data": [ ... ],
"pagination": {
"next_cursor": "cur_abc",
"has_more": true
}
}
```
- Default `limit=25`, max `limit=100`
- Support filtering (`status`, `customer_id`) and sorting (`created_at:desc`)
## 5. Rate limiting
- Token-bucket per API key (e.g., 100 req/min sustained, small burst allowance)
- Return headers on every response:
```
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 73
X-RateLimit-Reset: 1718172000
Retry-After: 12 (on 429 only)
```
- Return `429` with the standard error envelope when exceeded
- Consider stricter tiers for expensive endpoints (e.g., list/export)
## 6. Other quick wins
- **Idempotency**: accept an `Idempotency-Key` header on `POST /v1/subscriptions` so retries don't create duplicates
- **Versioning**: `v1` in path (or `Accept` header)
- **Consistent IDs**: prefixed opaque IDs like `sub_1a2b3c` instead of bare integers
- **Timestamps**: ISO 8601 UTC everywhere
- **OpenAPI spec**: publish it, generate docs and client SDKs from it
- **Webhooks**: notify callers of subscription lifecycle events (created, renewed, canceled) instead of forcing pollingwith skill · 0.50
## Redesigned API
Your current design uses verbs in URLs (`/createSubscription`), hides resource IDs in query params or request bodies, and returns untyped plain-text errors. Here's a concrete REST redesign.
### Endpoint paths
```
POST /api/v1/subscriptions # create
GET /api/v1/subscriptions/{id} # retrieve one
PATCH /api/v1/subscriptions/{id} # partial update (plan, add-ons)
DELETE /api/v1/subscriptions/{id} # hard delete (rare — admin only)
POST /api/v1/subscriptions/{id}/cancel # state change (soft cancel)
POST /api/v1/subscriptions/{id}/reactivate # undo cancel within grace period
GET /api/v1/subscriptions # list, with filters
GET /api/v1/customers/{customer_id}/subscriptions # subscriptions for a customer
```
Key changes:
- **Nouns, plural, kebab-case** — `/subscriptions`, not `/subscription` or `/createSubscription`.
- **ID in the path** — `/subscriptions/123`, not `?id=123`. This makes every resource addressable and cacheable.
- **`PATCH` instead of `PUT`** for updates — clients send only changed fields; `PUT` requires the full resource and silently wipes fields clients omit.
- **`cancel` as a sub-resource action** — cancellation is a state transition, not CRUD, so a verb action under the resource is the right pattern. It also makes the target explicit (your current `POST /cancelSubscription` doesn't say *which* subscription).
### Status codes per endpoint
| Endpoint | Success | Errors |
|---|---|---|
| `POST /subscriptions` | `201 Created` + `Location: /api/v1/subscriptions/123` | `400` malformed JSON, `422` validation, `409` duplicate active subscription |
| `GET /subscriptions/{id}` | `200 OK` | `404` not found, `403` not yours |
| `PATCH /subscriptions/{id}` | `200 OK` (return updated resource) | `404`, `422`, `409` already cancelled |
| `POST /subscriptions/{id}/cancel` | `200 OK` (return cancelled resource) | `404`, `409` already cancelled |
| `POST /subscriptions/{id}/reactivate` | `200 OK` | `404`, `409` outside grace period |
| `DELETE /subscriptions/{id}` | `204 No Content` | `404`, `403` |
| `GET /subscriptions` | `200 OK` | `400` invalid filter/sort param |
Rules to enforce: never return `200` with an error in the body; use `422` for semantically invalid data (valid JSON, bad values) and `400` for malformed requests; `401` for missing/invalid auth, `403` for authenticated-but-not-authorized.
### Error format (replace plain text)
Structured JSON, machine-readable `code`, stable across endpoints:
```json
HTTP/1.1 422 Unprocessable Entity
{
"error": {
"code": "validation_error",
"message": "Request validation failed",
"details": [
{ "field": "plan_id", "message": "Unknown plan", "code": "invalid_reference" },
{ "field": "billing_cycle", "message": "Must be one of: monthly, annual", "code": "invalid_value" }
]
}
}
```
Standard error codes to define: `validation_error`, `not_found`, `forbidden`, `unauthorized`, `conflict`, `already_cancelled`, `rate_limit_exceeded`, `internal_error`. Log the internal cause server-side; never leak stack traces in the response.
### Pagination for `GET /subscriptions`
Use **cursor-based** pagination — subscription lists grow unboundedly and offset pagination degrades and skips rows under concurrent inserts:
```
GET /api/v1/subscriptions?limit=20&cursor=eyJpZCI6MTIzfQ&status=active&sort=-created_at
```
```jsonapi-design--m5
baseline · 1.00
# Library Management REST API
## Endpoints
| Method | Endpoint | Description | Success Status | Error Statuses |
|--------|----------|-------------|----------------|----------------|
| POST | `/books` | Create a book | `201 Created` | `400 Bad Request` (invalid body), `409 Conflict` (duplicate ISBN) |
| GET | `/books` | List books (filters: `author`, `available`) | `200 OK` | — |
| GET | `/books/{id}` | Get one book | `200 OK` | `404 Not Found` |
| PUT | `/books/{id}` | Replace a book | `200 OK` | `400 Bad Request`, `404 Not Found` |
| PATCH | `/books/{id}` | Partially update (e.g., toggle `available`) | `200 OK` | `400 Bad Request`, `404 Not Found` |
| DELETE | `/books/{id}` | Delete a book | `204 No Content` | `404 Not Found` |
## Book Resource
```json
{
"id": 1,
"title": "The Hobbit",
"author": "J.R.R. Tolkien",
"isbn": "978-0547928227",
"published_year": 1937,
"available": true
}
```
## Example: List Books
**Request**
```
GET /books?author=Tolkien&available=true
```
**Response — 200 OK**
```json
{
"books": [
{
"id": 1,
"title": "The Hobbit",
"author": "J.R.R. Tolkien",
"isbn": "978-0547928227",
"published_year": 1937,
"available": true
},
{
"id": 2,
"title": "The Fellowship of the Ring",
"author": "J.R.R. Tolkien",
"isbn": "978-0547928210",
"published_year": 1954,
"available": true
}
],
"total": 2
}
```
Notes: filters are optional and combinable; no matches returns `200 OK` with an empty list.with skill · 0.70
# Library Books REST API
Base path: `/api/v1/books`
## Endpoints
| Method | Path | Description | Status Codes |
|--------|------|-------------|--------------|
| GET | `/api/v1/books` | List books. Filters: `?author=...&available=true` | 200, 400 (invalid filter value) |
| GET | `/api/v1/books/:id` | Get one book | 200, 404 |
| POST | `/api/v1/books` | Create a book | 201 (with `Location` header), 400/422 (validation), 409 (duplicate ISBN) |
| PUT | `/api/v1/books/:id` | Full update | 200, 400/422, 404 |
| PATCH | `/api/v1/books/:id` | Partial update (e.g., toggle `available`) | 200, 400/422, 404 |
| DELETE | `/api/v1/books/:id` | Delete a book | 204, 404 |
## Example: List books filtered by author and availability
**Request**
```
GET /api/v1/books?author=Tolkien&available=true
```
**Response**
```
HTTP/1.1 200 OK
```
```json
{
"data": [
{
"id": 12,
"title": "The Hobbit",
"author": "J.R.R. Tolkien",
"isbn": "978-0547928227",
"published_year": 1937,
"available": true
}
],
"meta": {
"total": 1,
"page": 1,
"per_page": 20
}
}
```
Notes:
- Filters combine with AND semantics; unknown query params return 400.
- Errors use a consistent shape: `{ "error": { "code": "not_found", "message": "Book not found" } }`.Limitations
- Agent noise: single-model results vary run to run; the confidence interval reflects task-level variance.
- Screen tier (1 trial/arm) is a directional gate, not a verified claim.
- Results are for the SkillzBench Reference Agent on glm-5.3-flash@cf and do not necessarily transfer to other agents or models.
- Task coverage: Skill-matched tasks (generated, public + held-out) only; a skill can shine elsewhere.