Awesome-omni-skill 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.
git clone https://github.com/diegosouzapw/awesome-omni-skill
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/development/http-api-design" ~/.claude/skills/diegosouzapw-awesome-omni-skill-http-api-design && rm -rf "$T"
skills/development/http-api-design/SKILL.mdLightweight 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.
-
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.
-
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.
-
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.
-
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.
-
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:
rather thanPOST /offers/{offer_id}/activationsPOST /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
snake_case{ "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
) or integer seconds — pick oneP7D - Booleans: Use
oris_
prefixes:has_
,is_activehas_rewards - Enums: Lowercase
strings:snake_case
— never magic numbers"status": "pending_review" - 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:
Use string for amount to avoid floating-point issues. Favor composition over inheritance when you need multiple money fields (e.g.{ "amount": "19.99", "currency": "USD" }
andprice
as separate objects).discounted_price - IDs: Use prefixed opaque strings (
,off_8xk2Qp
). The prefix makes IDs self-documenting in logs and debugging. Avoid exposing auto-increment integers (they leak cardinality).mer_3kLm9x - Pluralize arrays, singularize objects:
not"items": [...]"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
{ "data": { "offer_id": "off_8xk2Qp", "merchant_name": "Acme Coffee", "reward_amount": { "amount": "5.00", "currency": "USD" }, "status": "active" } }
Success — collection
{ "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.
{ "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
: A stable URI that uniquely identifies the error category. Machine clients branch on this, not ontype
ortitle
. Make it a URL that resolves to documentation when possible.detail
: Short, human-readable summary of the problem type (not the specific occurrence).title
: Advisory mirror of the HTTP status code. Always match the actual response status.status
: Human-readable explanation of this specific occurrence. Clients must not parse this programmatically — use extension fields instead.detail- Extension fields: Add domain-specific fields freely.
array for validation details.errors
for rate limits.retry_after
andbalance
for insufficient-funds errors. These are what machines should actually act on.cost
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 |
| 500 | Internal Server Error | Server bug — include for debugging |
| 503 | Service Unavailable | Temporary overload — include |
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:
{ "data": [...], "has_more": true, "next_cursor": "eyJpZCI6Im9mZl8xMGFCNHMifQ" }
: Maximum items to return (set a sensible default, e.g. 25, and a max, e.g. 100)limit
: Opaque string. Base64-encode the pagination state. Never expose raw IDs or offsets in cursors.cursor
: Boolean — tells the client whether to keep paginating without decoding the cursorhas_more
: Only present whennext_cursor
is truehas_more
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:
where direction isfield:direction
orascdesc - 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
header.Idempotency-Key
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:
- First request with this key: Process normally, cache the response (status code + body) keyed by the idempotency key.
- Subsequent requests with same key and same parameters: Return the cached response without re-executing.
- Same key but different parameters: Return 422 error — prevents accidental misuse.
- 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:
: Unique identifier for the request. Generate server-side if the client doesn't provide one. Invaluable for distributed tracing and support debugging.X-Request-Id
(orContent-Type: application/json
for errors)application/problem+json
Accept these from clients:
: For POST requests (see above)Idempotency-Key
: If the client sends one, use it (or derive from it) so logs correlate across servicesX-Request-Id
Authentication
For M2M server-to-server communication, API keys are the pragmatic default:
- Send via
header (not query params, not custom headers)Authorization: Bearer <api_key> - 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:
{ "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
header:AcceptAccept: application/json; version=2Or a custom header:
API-Version: 2024-01-15Date-based versions (Stripe's approach) communicate when the contract was established and make deprecation timelines intuitive.
-
Deprecation process: Add
andDeprecation
headers to responses for endpoints or fields being retired. Provide migration guides. Give consumers at least 6 months notice.Sunset
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
for every request and responseexamples - Mark fields as
vs optional explicitlyrequired - Use
for shared schemas (error responses, pagination, money objects)$ref - 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
envelope{ "data": ... } - All errors use RFC 9457 Problem Details format
- All errors include a
URI that machines can branch ontype - All list endpoints use cursor-based pagination with
andhas_morenext_cursor - All POST endpoints accept
headerIdempotency-Key - All responses include
headerX-Request-Id - 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
with string amount{ amount, currency } - 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:
— Expanded RFC 9457 error examples for common scenariosreferences/error-examples.md
— Starter OpenAPI 3.1 template following these conventionsreferences/openapi-template.md