# HTTP API Design

> Design and implement lightweight, ergonomic JSON HTTP APIs for machine-to-machine communication. Use this skill whenever the user is designing API endpoints, writing OpenAPI specs, building REST or HTTP API routes, defining request/response schemas, implementing error handling for APIs, or discussing API contracts.

- Skill: `diegosouzapw/http-api-design` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add diegosouzapw/http-api-design`
- Raw SKILL.md: https://api.skillmd.com/api/skills/diegosouzapw/http-api-design/raw
- Safety review: pending (external: skill-scanner PASS, skillspector CAUTION)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: diegosouzapw (https://skillmd.com/u/diegosouzapw)
- Updated: 2026-09-08
- Page: https://skillmd.com/skills/diegosouzapw/http-api-design

---


# Lightweight HTTP JSON API Design

This skill guides the design and implementation of pragmatic, ergonomic JSON HTTP APIs optimized for machine-to-machine communication. The philosophy is "pragmatic REST" — adopt the parts of REST that genuinely help (resources, statelessness, HTTP semantics) and skip what doesn't earn its keep (HATEOAS, rigid hypermedia specs, heavyweight envelope standards).

The goal: an API that feels obvious to consume, is safe to retry, and can be fully understood from its OpenAPI spec without reading prose documentation.

## Design Principles

These principles are ordered by impact. When two principles conflict, the one listed first wins.

1. **Consistency above all.** An API should look like one person designed it. Every endpoint, field name, error shape, and query parameter should follow the same conventions. Inconsistency is the #1 source of integration bugs.

2. **Optimize for the machine consumer.** This is M2M-first. Every response should be trivially parseable. Stable, typed error codes matter more than friendly messages. Prefer explicit over clever.

3. **Make retries safe by default.** Network failures between services are routine. Idempotency isn't a nice-to-have — it's infrastructure. Design every mutation so clients can safely retry without fear of side effects.

4. **Keep the surface area small.** Start with the minimum viable API. It's easy to add fields and endpoints later; removing them is a breaking change. When unsure, leave it out.

5. **Let HTTP do its job.** Use status codes, methods, headers, and content types as intended. Don't reinvent what the protocol already provides (caching, conditional requests, content negotiation).

## Resource Design

Model your API around domain nouns, not operations. HTTP methods carry the action semantics.

```
GET    /offers                  → List offers
POST   /offers                  → Create an offer
GET    /offers/{offer_id}       → Get a specific offer
PATCH  /offers/{offer_id}       → Partial update
DELETE /offers/{offer_id}       → Remove an offer
```

### URL conventions

- Plural nouns for collections: `/offers`, `/merchants`, `/transactions`
- Identifiers for single resources: `/offers/{offer_id}`
- Lowercase with hyphens for multi-word resources: `/card-linked-offers`
- Shallow nesting — one level maximum: `/merchants/{merchant_id}/offers`
- If a resource has a globally unique ID, also expose it at the top level: `/offers/{offer_id}`
- No verbs in paths. If an action doesn't map cleanly to CRUD, use a noun that represents the process: `POST /offers/{offer_id}/activations` rather than `POST /offers/{offer_id}/activate`

### When nesting vs. top-level

Nest when the child resource has no meaning without the parent and is always accessed in that context. Expose top-level when the resource has a globally unique identifier and consumers may access it independently. It's fine to support both routes to the same resource.

## JSON Conventions

Pick these once. Enforce them everywhere. Lint them in CI.

### Field naming: `snake_case`

```json
{
  "offer_id": "off_8xk2Qp",
  "merchant_name": "Acme Coffee",
  "created_at": "2026-02-11T14:30:00Z",
  "is_active": true
}
```

Why snake_case: it's the convention used by Stripe, GitHub, Slack, and most M2M-oriented APIs. It reads well, avoids ambiguity with acronyms (`api_key` vs `apiKey` vs `APIKey`), and serializes cleanly across languages.

### Core rules

- **Dates and times**: ISO 8601 always, UTC always: `2026-02-11T14:30:00Z`
- **Durations**: ISO 8601 durations (`PT30M`, `P7D`) or integer seconds — pick one
- **Booleans**: Use `is_` or `has_` prefixes: `is_active`, `has_rewards`
- **Enums**: Lowercase `snake_case` strings: `"status": "pending_review"` — never magic numbers
- **Null vs. absent**: Be deliberate. Null means "this field exists but has no value." Absent means "this field doesn't apply." Document which you use and be consistent.
- **Money**: Always a structured object with amount and currency, never a bare number:
  ```json
  { "amount": "19.99", "currency": "USD" }
  ```
  Use string for amount to avoid floating-point issues. Favor composition over inheritance when you need multiple money fields (e.g. `price` and `discounted_price` as separate objects).
- **IDs**: Use prefixed opaque strings (`off_8xk2Qp`, `mer_3kLm9x`). The prefix makes IDs self-documenting in logs and debugging. Avoid exposing auto-increment integers (they leak cardinality).
- **Pluralize arrays, singularize objects**: `"items": [...]` not `"item": [...]`
- **Top-level must be an object**: Never return a bare JSON array. Always wrap in an object to allow future extension.

## Response Envelope

Use a minimal, consistent envelope for all responses.

### Success — single resource

```json
{
  "data": {
    "offer_id": "off_8xk2Qp",
    "merchant_name": "Acme Coffee",
    "reward_amount": { "amount": "5.00", "currency": "USD" },
    "status": "active"
  }
}
```

### Success — collection

```json
{
  "data": [
    { "offer_id": "off_8xk2Qp", "merchant_name": "Acme Coffee" },
    { "offer_id": "off_9yL3Rq", "merchant_name": "Bean There" }
  ],
  "has_more": true,
  "next_cursor": "eyJpZCI6Im9mZl85eUwzUnEifQ"
}
```

### Why an envelope

Without it, you cannot add pagination metadata, deprecation warnings, or request diagnostics without a breaking change. The `data` key is a small price for forward compatibility. Don't over-engineer it — `data` plus pagination fields is sufficient. Avoid deeply nested envelope structures like `{ "response": { "data": { ... }, "meta": { ... } } }`.

## Error Handling — RFC 9457 Problem Details

Use RFC 9457 (`application/problem+json`) for all error responses. This is the one lightweight standard worth adopting — it eliminates the need to invent your own error format and is natively supported by many server frameworks.

```json
{
  "type": "https://api.example.com/errors/validation-failed",
  "title": "Validation Failed",
  "status": 400,
  "detail": "One or more fields failed validation.",
  "errors": [
    {
      "field": "email",
      "detail": "Must be a valid email address.",
      "pointer": "/data/email"
    },
    {
      "field": "reward_amount",
      "detail": "Must be greater than zero.",
      "pointer": "/data/reward_amount/amount"
    }
  ]
}
```

### Key decisions

- **`type`**: A stable URI that uniquely identifies the error category. Machine clients branch on this, not on `title` or `detail`. Make it a URL that resolves to documentation when possible.
- **`title`**: Short, human-readable summary of the problem type (not the specific occurrence).
- **`status`**: Advisory mirror of the HTTP status code. Always match the actual response status.
- **`detail`**: Human-readable explanation of this specific occurrence. Clients must not parse this programmatically — use extension fields instead.
- **Extension fields**: Add domain-specific fields freely. `errors` array for validation details. `retry_after` for rate limits. `balance` and `cost` for insufficient-funds errors. These are what machines should actually act on.

### Status code usage

Use the right HTTP status code. The most important ones for M2M:

| Code | Meaning | When |
|------|---------|------|
| 200 | OK | Successful GET, PATCH, DELETE |
| 201 | Created | Successful POST that creates a resource |
| 202 | Accepted | Async operation acknowledged, not yet complete |
| 204 | No Content | Successful DELETE with no body |
| 400 | Bad Request | Validation failure, malformed input |
| 401 | Unauthorized | Missing or invalid authentication |
| 403 | Forbidden | Authenticated but insufficient permissions |
| 404 | Not Found | Resource doesn't exist |
| 409 | Conflict | State conflict (e.g., duplicate, version mismatch) |
| 422 | Unprocessable Entity | Syntactically valid but semantically wrong |
| 429 | Too Many Requests | Rate limited — always include `Retry-After` |
| 500 | Internal Server Error | Server bug — include `request_id` for debugging |
| 503 | Service Unavailable | Temporary overload — include `Retry-After` |

Return 400 for structural input problems (missing fields, wrong types). Return 422 for business rule violations on well-formed input (insufficient balance, conflicting state). This distinction helps M2M clients separate "fix your request" from "fix the preconditions."

## Pagination

Use cursor-based pagination for all list endpoints. It's stable under concurrent writes and performs well at scale. Offset-based pagination breaks when rows are inserted or deleted between pages.

```
GET /offers?limit=25&cursor=eyJpZCI6Im9mZl85eUwzUnEifQ
```

Response:
```json
{
  "data": [...],
  "has_more": true,
  "next_cursor": "eyJpZCI6Im9mZl8xMGFCNHMifQ"
}
```

- `limit`: Maximum items to return (set a sensible default, e.g. 25, and a max, e.g. 100)
- `cursor`: Opaque string. Base64-encode the pagination state. Never expose raw IDs or offsets in cursors.
- `has_more`: Boolean — tells the client whether to keep paginating without decoding the cursor
- `next_cursor`: Only present when `has_more` is true

## Filtering and Sorting

Keep it simple. Use query parameters for filtering and sorting. Don't invent a query language unless you genuinely need one.

```
GET /offers?status=active&merchant_id=mer_3kLm9x&sort=created_at:desc
```

- Filter fields should match the resource's field names exactly
- Sort format: `field:direction` where direction is `asc` or `desc`
- For multiple filters on the same field, use comma separation: `?status=active,pending`
- For date ranges: `?created_after=2026-01-01T00:00:00Z&created_before=2026-02-01T00:00:00Z`

Avoid deeply expressive filter DSLs. If consumers need ad-hoc queries over your data, that's a different product (a query API or GraphQL layer), not a REST endpoint.

## Idempotency

Every mutation endpoint must be safe to retry.

- **GET, HEAD, OPTIONS**: Inherently safe and idempotent — no special handling needed.
- **PUT, DELETE**: Idempotent by definition when designed correctly (same input → same result).
- **POST**: Requires explicit idempotency support via `Idempotency-Key` header.

### Implementation

Clients send a unique key (recommend V4 UUID) with each POST request:

```
POST /offers
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json

{ "merchant_id": "mer_3kLm9x", "reward_type": "cashback" }
```

Server behavior:

1. **First request with this key**: Process normally, cache the response (status code + body) keyed by the idempotency key.
2. **Subsequent requests with same key and same parameters**: Return the cached response without re-executing.
3. **Same key but different parameters**: Return 422 error — prevents accidental misuse.
4. **Key expiry**: Automatically prune keys after 24 hours. A reused key after expiry starts a new request.

Cache the response regardless of whether it succeeded or failed (including 500s). This prevents double-execution when clients retry after ambiguous failures.

For requests that fail validation before execution begins (parameter errors, auth failures), don't store the idempotency result — let the client fix and retry with the same key.

## Rate Limiting

Return rate limit state on every response so clients can self-throttle:

```
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 847
X-RateLimit-Reset: 1739290200
```

On 429 responses, always include the `Retry-After` header (seconds until the client should retry). Also include it on 503 responses during load shedding.

Machines respect `Retry-After` if you provide it. If you don't, they'll hammer you with exponential backoff guesses that are usually more aggressive than necessary.

## Operational Headers

Include these on every response:

- **`X-Request-Id`**: Unique identifier for the request. Generate server-side if the client doesn't provide one. Invaluable for distributed tracing and support debugging.
- **`Content-Type: application/json`** (or `application/problem+json` for errors)

Accept these from clients:

- **`Idempotency-Key`**: For POST requests (see above)
- **`X-Request-Id`**: If the client sends one, use it (or derive from it) so logs correlate across services

## Authentication

For M2M server-to-server communication, API keys are the pragmatic default:

- Send via `Authorization: Bearer <api_key>` header (not query params, not custom headers)
- Prefix keys for identification: `sk_live_...`, `sk_test_...`
- Support key rotation: allow multiple active keys per client with independent expiry

Use OAuth 2.0 client credentials flow when you need scoped access or third-party delegation. Use JWTs when you need claims to propagate across service boundaries without a lookup.

Always require HTTPS. No exceptions.

## Async Operations

For operations that take longer than a few seconds, return 202 Accepted with a status resource:

```json
{
  "data": {
    "operation_id": "op_7mN4kP",
    "status": "processing",
    "status_url": "/operations/op_7mN4kP",
    "created_at": "2026-02-11T14:30:00Z"
  }
}
```

Clients poll `status_url` until `status` transitions to `completed` or `failed`. The completed response should include the result resource or a link to it.

## Versioning

Do not version in the URL. Instead, design for evolution:

- **Additive changes are not breaking**: New fields in responses, new optional query parameters, new endpoints, new enum values in documentation — none of these break existing clients.
- **Require tolerant readers**: Clients must ignore unknown fields. Document this as a contract requirement in your API spec. This is the single most important compatibility rule.
- **When breaking changes are truly unavoidable**: Use a versioned media type in the `Accept` header:
  ```
  Accept: application/json; version=2
  ```
  Or a custom header:
  ```
  API-Version: 2024-01-15
  ```
  Date-based versions (Stripe's approach) communicate when the contract was established and make deprecation timelines intuitive.

- **Deprecation process**: Add `Deprecation` and `Sunset` headers to responses for endpoints or fields being retired. Provide migration guides. Give consumers at least 6 months notice.

The reason to avoid URL versioning: it fragments your API surface, breaks caching semantics, complicates routing, and encourages lazy breaking changes instead of thoughtful evolution. An API that never breaks is better than one that versions easily.

## OpenAPI Specification

Treat your OpenAPI spec as a first-class artifact — not generated documentation, but the source of truth.

- Use OpenAPI 3.1 (full JSON Schema compatibility)
- Write the spec first, before implementation (API-First design)
- Version control it alongside your code
- Lint it in CI (use Spectral, Redocly, or similar)
- Include complete `examples` for every request and response
- Mark fields as `required` vs optional explicitly
- Use `$ref` for shared schemas (error responses, pagination, money objects)
- Publish it at a well-known path: `GET /openapi.json`

Consumers will generate clients from this spec. A good spec with accurate types, clear constraints, and realistic examples is worth more than pages of prose docs.

## Implementation Checklist

When implementing an API following this skill, verify:

- [ ] All URLs use plural nouns, lowercase-hyphenated, max one level of nesting
- [ ] All JSON fields use `snake_case`
- [ ] All dates are ISO 8601 UTC
- [ ] All responses are wrapped in a `{ "data": ... }` envelope
- [ ] All errors use RFC 9457 Problem Details format
- [ ] All errors include a `type` URI that machines can branch on
- [ ] All list endpoints use cursor-based pagination with `has_more` and `next_cursor`
- [ ] All POST endpoints accept `Idempotency-Key` header
- [ ] All responses include `X-Request-Id` header
- [ ] All responses include rate limit headers
- [ ] 429 and 503 responses include `Retry-After`
- [ ] No bare arrays in responses — always an object at top level
- [ ] IDs use prefixed opaque strings
- [ ] Money is always `{ amount, currency }` with string amount
- [ ] HTTPS required, no exceptions
- [ ] OpenAPI 3.1 spec exists, is linted, and is published at `/openapi.json`
- [ ] Clients are expected to ignore unknown fields (documented)

## What Not to Adopt

These are intentionally excluded because they add weight without proportional value for M2M JSON APIs:

- **HATEOAS / HAL / Siren / JSON-LD**: Adds envelope complexity and response bloat. Machine clients don't discover APIs dynamically — integration code is written against a spec, not navigated at runtime.
- **JSON:API**: Opinionated spec with rigid envelope structures, relationship graphs, and compound documents. Overkill for most M2M APIs.
- **OData**: Enterprise-oriented query language, more suited to generic CRUD over large ERP-style entity models.
- **GraphQL**: Different paradigm. Strong for client-driven queries but adds operational complexity (per-field authorization, caching, rate limiting) that's unnecessary when you control the API surface.
- **URL versioning**: Fragments the API surface and discourages evolutionary design. See Versioning section.

For further reading, see the references directory:
- `references/error-examples.md` — Expanded RFC 9457 error examples for common scenarios
- `references/openapi-template.md` — Starter OpenAPI 3.1 template following these conventions

