What Makes an API Good
A good API is predictable, consistent, and hard to misuse. Consumers should be able to guess the next endpoint from the last one. The core levers are resource naming, correct use of HTTP semantics, sensible errors, stable versioning, and clear auth. This page focuses on HTTP/REST with a comparison of GraphQL and gRPC.
REST & the Richardson Maturity Model
REST treats everything as resources manipulated through a uniform interface. Richardson's maturity model grades how RESTful an API is:
- Level 0 — one endpoint, RPC over HTTP (SOAP-style).
- Level 1 — multiple resources (
/orders,/users) but verbs ignored. - Level 2 — proper HTTP verbs and status codes. This is where most good APIs live.
- Level 3 — HATEOAS: responses carry links to related actions.
Resource Naming
Use plural nouns, lowercase, hyphenated, and hierarchical. The URL identifies a thing; the HTTP method says what to do to it. Verbs in the path (/getUser) are an anti-pattern.
GET /users list users
POST /users create a user
GET /users/42 get user 42
PATCH /users/42 partial update
DELETE /users/42 delete
GET /users/42/orders orders belonging to user 42
GET /orders?status=paid&sort=-created_at filter + sort
HTTP Methods, Idempotency & Status Codes
| Method | Safe | Idempotent | Use |
|---|---|---|---|
| GET | yes | yes | Read |
| POST | no | no | Create / actions |
| PUT | no | yes | Replace whole resource |
| PATCH | no | not required | Partial update |
| DELETE | no | yes | Remove |
Idempotent means repeating the call has the same effect as calling once — critical because clients retry on timeouts. Status codes carry meaning:
| Code | Meaning |
|---|---|
| 200 / 201 / 204 | OK / Created / No Content |
| 301 / 304 | Moved / Not Modified (cache hit) |
| 400 / 401 / 403 | Bad request / Unauthenticated / Forbidden |
| 404 / 409 / 422 | Not found / Conflict / Unprocessable |
| 429 | Too Many Requests (rate limited) |
| 500 / 502 / 503 | Server error / Bad gateway / Unavailable |
4xx vs 5xx
4xx = the client did something wrong (don't retry blindly). 5xx = the server failed (safe to retry with backoff). Never return 200 with an error body — it breaks caching, monitoring, and client logic.
Versioning
| Strategy | Example | Trade-off |
|---|---|---|
| URI path | /v2/users | Obvious, cacheable; not purely RESTful |
| Header | API-Version: 2 | Clean URLs; harder to test in browser |
| Media type | Accept: application/vnd.api.v2+json | Most RESTful; least discoverable |
URI versioning is the pragmatic default. Whatever you pick, version only on breaking changes; additive changes (new optional fields) should never bump the version.
Pagination, Filtering & Sorting
| Style | Query | Notes |
|---|---|---|
| Offset | ?limit=20&offset=40 | Simple; slow & skips rows on deep/changing data |
| Cursor / keyset | ?limit=20&after=eyJpZCI6... | Stable, fast at scale; can't jump to page N |
Use cursor pagination for large or real-time datasets — offset pagination degrades and duplicates/skips rows as data shifts underneath. Support ?sort=-created_at,name (leading - = descending) and field filters like ?status=active.
Idempotency Keys
POST is not idempotent, yet clients retry. Let the client send a unique Idempotency-Key header; the server stores the first result keyed by it and replays that result for any retry — so a double-charge becomes impossible. This is how Stripe handles payment retries.
Rate Limiting
Return 429 Too Many Requests when a client exceeds its quota, and tell it when to come back with Retry-After. Advertise the quota so well-behaved clients self-throttle:
HTTP/1.1 429 Too Many Requests
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 30
Retry-After: 30
Error Format — RFC 9457 (problem+json)
Standardize errors so clients parse them uniformly. RFC 9457 (Problem Details for HTTP APIs, obsoleting 7807) defines the shape:
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://api.example.com/errors/validation",
"title": "Validation failed",
"status": 422,
"detail": "email must be a valid address",
"instance": "/users",
"errors": [{ "field": "email", "code": "invalid_format" }]
}
Authentication
| Scheme | Best for |
|---|---|
| API keys | Server-to-server, simple identification (not fine-grained authz) |
| OAuth 2.0 | Third-party delegated access, scopes, refresh tokens |
| JWT (bearer) | Stateless sessions; short-lived, signed, verified per request |
Always over TLS. Keep JWTs short-lived and pair with refresh tokens; never put secrets in the query string.
Caching Headers
Cache-Control tells clients/CDNs how long a response is fresh. ETag is a content fingerprint; the client sends it back as If-None-Match, and an unchanged resource returns 304 Not Modified with no body — saving bandwidth. ETags also enable optimistic concurrency (send If-Match on writes to avoid lost updates).
REST vs GraphQL vs gRPC
| REST | GraphQL | gRPC | |
|---|---|---|---|
| Transport | HTTP/1.1 JSON | HTTP + JSON | HTTP/2 + Protobuf |
| Fetching | Over/under-fetch | Client picks fields | Fixed methods |
| Perf | Good | Good | Fastest, streaming |
| Best for | Public APIs, CRUD | Rich clients, many entities | Internal microservices |
| Caching | HTTP native | Hard (all POST) | App-level |
gRPC defines services in a .proto file, generating typed client/server stubs and using compact binary Protobuf over HTTP/2 with bidirectional streaming — ideal east-west between services but awkward from browsers (needs gRPC-Web).
Webhooks & Gateways
Webhooks invert the flow: instead of the client polling, your server POSTs an event to the client's URL. Sign the payload (HMAC) so receivers can verify authenticity, include an event ID for dedup, and retry with backoff on non-2xx. An API gateway fronts everything, handling auth, rate limiting, routing, and observability in one place.
A Well-Designed Endpoint
POST /v1/orders
Authorization: Bearer <jwt>
Idempotency-Key: 9c8b-...-a1
Content-Type: application/json
{ "items": [{ "sku": "ABC", "qty": 2 }], "currency": "USD" }
--- response ---
HTTP/1.1 201 Created
Location: /v1/orders/ord_7f3
ETag: "a1b2c3"
Cache-Control: private, max-age=0
{
"id": "ord_7f3",
"status": "pending",
"total": 4200,
"_links": { "self": "/v1/orders/ord_7f3",
"cancel": "/v1/orders/ord_7f3/cancel" }
}
Bottom line
Nouns for resources, verbs from HTTP, precise status codes, idempotency keys on writes, cursor pagination, problem+json errors, and versions only on breaking changes. Consistency beats cleverness.