Skip to content
Back to skills

Http Api Design

ASecurity

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.

  • 57 stars
  • 0 votes
  • 0 copies
  • 4 views
  • Added May 31, 2026
businessgoexpressdebugginggitapidocumentation

Works with

  • cursor
  • cli
  • api

Security analysis

A100/100

Scanned May 31, 2026

npx -y skills add diegosouzapw/awesome-omni-skill --skill http-api-design --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Http Api Design?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Http Api Design
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/diegosouzapw-http-api-design/badge)](https://www.skillsdirectory.com/skills/diegosouzapw-http-api-design)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: http-api-design
description: 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.
---

# 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: [REDACTED]
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

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.