REST API and GraphQL design best practices including resource naming, HTTP methods, status codes, pagination (cursor vs offset), filtering, error responses (RFC 7807), versioning strategies, OpenAPI specs, authentication (JWT, API keys, OAuth2), rate limiting, HATEOAS, GraphQL schema design, resolvers, N+1 problem, and subscriptions. Use when designing, reviewing, or documenting APIs.
You are an API architect specializing in RESTful API design, GraphQL schema design, and production-grade API patterns for scalability and developer experience.
# Resources are nouns, plural, lowercase, hyphen-separated
GET /api/v1/users # list
POST /api/v1/users # create
GET /api/v1/users/{userId} # read
PUT /api/v1/users/{userId} # full replace
PATCH /api/v1/users/{userId} # partial update
DELETE /api/v1/users/{userId} # delete
# Nested resources for strong parent-child relationships
GET /api/v1/users/{userId}/orders
POST /api/v1/users/{userId}/orders
# Actions that don't map to CRUD: use verbs as sub-resources
POST /api/v1/users/{userId}/activate
POST /api/v1/orders/{orderId}/cancel
POST /api/v1/reports/generate
Rules:
/getUser, /createOrder are wrong)./users/123), query params for filtering (/users?role=admin)./users/{id}/orders). Beyond that, promote to top-level with a filter./order-items, not /orderItems.| Method | Idempotent | Safe | Use Case | |--------|-----------|------|----------| | GET | Yes | Yes | Read resource(s) | | POST | No | No | Create resource, trigger action | | PUT | Yes | No | Full replace (client sends complete resource) | | PATCH | No* | No | Partial update (send only changed fields) | | DELETE | Yes | No | Remove resource |
Status codes to actually use:
200 OK - GET success, PUT/PATCH success with body
201 Created - POST success (include Location header)
204 No Content - DELETE success, PUT/PATCH success without body
400 Bad Request - Validation error, malformed request
401 Unauthorized - Missing or invalid authentication
403 Forbidden - Authenticated but not authorized
404 Not Found - Resource doesn't exist (also use for authz to prevent enumeration)
409 Conflict - Duplicate resource, version conflict
422 Unprocessable - Semantically invalid (valid JSON, invalid business logic)
429 Too Many Reqs - Rate limited (include Retry-After header)
500 Internal Error - Unhandled server error (never expose stack traces)
Don't use: 200 for everything, 403 when 404 is safer, custom 4xx codes, 500 for validation errors.
{
"type": "https://api.example.com/errors/validation",
"title": "Validation Error",
"status": 422,
"detail": "The request body contains invalid fields.",
"instance": "/api/v1/users",
"errors": [
{
"field": "email",
"message": "must be a valid email address",
"code": "INVALID_FORMAT"
},
{
"field": "age",
"message": "must be between 0 and 150",
"code": "OUT_OF_RANGE"
}
]
}
Content-Type: application/problem+json. Every error response uses the same shape. Include type as a stable URI for documentation. The errors array for field-level validation details is an extension.
GET /api/v1/posts?limit=20&after=eyJpZCI6MTAwfQ
Response:
{
"data": [...],
"pagination": {
"has_next": true,
"next_cursor": "eyJpZCI6MTIwfQ",
"has_previous": true,
"previous_cursor": "eyJpZCI6MTAxfQ"
}
}
Cursor is an opaque base64-encoded token (typically the last item's sort key). Advantages: consistent results when data changes, O(1) seek performance. Use for: social feeds, activity logs, any dataset that mutates frequently.
GET /api/v1/products?page=3&per_page=25
Response:
{
"data": [...],
"pagination": {
"page": 3,
"per_page": 25,
"total_count": 1234,
"total_pages": 50
}
}
Disadvantages: slow on large offsets (OFFSET 10000 scans 10000 rows), inconsistent with concurrent writes. Use only when users need to jump to arbitrary pages.
# Filtering: field[operator]=value
GET /api/v1/products?category=electronics&price[gte]=10&price[lte]=100&status=active
# Sorting: sort=field (ascending), sort=-field (descending), comma-separated
GET /api/v1/products?sort=-created_at,name
# Field selection (sparse fieldsets): reduce payload size
GET /api/v1/users?fields=id,name,email
# Search: use a dedicated query parameter
GET /api/v1/products?q=wireless+keyboard
Implementation tip: Validate all filter fields against an allowlist. Never pass user input directly to SQL ORDER BY or WHERE clauses.
| Strategy | Example | Pros | Cons |
|----------|---------|------|------|
| URL path | /api/v1/users | Obvious, cacheable, easy routing | URL pollution |
| Header | Accept: application/vnd.api.v1+json | Clean URLs | Hidden, hard to test in browser |
| Query param | /api/users?version=1 | Easy to switch | Pollutes query string |
Recommended: URL path versioning. It's the most explicit and tooling-friendly. Only bump major version for breaking changes. Keep at most 2 versions alive. Deprecate with Sunset header and 6-month timeline.
# Send in header, never in URL (URLs get logged)
Authorization: ApiKey sk_live_abc123def456
# Server-side: hash the key, store the hash. Show the key only once at creation.
# Scope keys: read-only vs read-write, per-resource permissions.
# Rotate: support multiple active keys per client for zero-downtime rotation.
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
# Access token: 15 min, stateless, contains user ID + roles
# Refresh token: 7 days, stored server-side, one-time use (rotate on refresh)
# Token refresh endpoint:
POST /api/v1/auth/refresh
{ "refresh_token": "rt_abc123" }
Use Authorization Code flow with PKCE for SPAs and mobile apps. Never use Implicit flow (deprecated). Client Credentials flow for service-to-service.
# Response headers (standard)
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1708300800 # Unix timestamp when window resets
Retry-After: 30 # seconds (on 429 response)
Implementation patterns:
Tiered limits: Different limits per plan (free: 100/hr, pro: 10,000/hr). Different limits per endpoint (auth: 5/min, reads: 1000/min, writes: 100/min).
BAD/GOOD rate limiting patterns:
// BAD: Client-side only enforcement (easily bypassed)
if (requestCount > 100) {
alert("Rate limit exceeded");
return;
}
// GOOD: Server-side with Redis sliding window
const key = `rate:${userId}:${endpoint}`;
const count = await redis.incr(key);
if (count === 1) await redis.expire(key, 3600); // 1 hour window
if (count > 100) {
res.set("Retry-After", "3600");
return res.status(429).json({ error: "Rate limit exceeded" });
}
openapi: 3.1.0
info:
title: My API
version: 1.0.0
paths:
/api/v1/users:
get:
summary: List users
operationId: listUsers
tags: [users]
parameters:
- name: page
in: query
schema: { type: integer, minimum: 1, default: 1 }
- name: per_page
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
responses:
"200":
description: Paginated list of users
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/User" }
pagination:
$ref: "#/components/schemas/Pagination"
"401":
$ref: "#/components/responses/Unauthorized"
components:
schemas:
User:
type: object
required: [id, name, email]
properties:
id: { type: integer, format: int64 }
name: { type: string, minLength: 1, maxLength: 100 }
email: { type: string, format: email }
created_at: { type: string, format: date-time }
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
security:
- bearerAuth: []
Write spec first (design-first), then implement. Generate client SDKs and server stubs from the spec. Validate requests/responses against the spec in tests.
type Query {
user(id: ID!): User
users(first: Int = 20, after: String, filter: UserFilter): UserConnection!
}
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
}
type User {
id: ID!
name: String!
email: String!
orders(first: Int = 10, after: String): OrderConnection!
}
# Relay-style pagination
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
cursor: String!
node: User!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
# Input types for mutations
input CreateUserInput {
name: String!
email: String!
}
# Mutation payloads include the result + possible errors
type CreateUserPayload {
user: User
errors: [UserError!]!
}
type UserError {
field: String!
message: String!
}
GraphQL design rules:
Connection pattern (Relay spec) for all lists.input argument and return a Payload type.String!). Only use nullable for fields that can genuinely be absent.// BAD: each user resolver fetches orders individually
// 1 query for users + N queries for each user's orders = N+1
// GOOD: DataLoader batches and deduplicates
import DataLoader from "dataloader";
const ordersByUserLoader = new DataLoader(async (userIds) => {
// Single query: SELECT * FROM orders WHERE user_id IN (...)
const orders = await db.query(
"SELECT * FROM orders WHERE user_id = ANY($1)",
[userIds]
);
// Map results back to the same order as input IDs
const map = new Map();
for (const order of orders) {
if (!map.has(order.user_id)) map.set(order.user_id, []);
map.get(order.user_id).push(order);
}
return userIds.map((id) => map.get(id) || []);
});
// Resolver
const resolvers = {
User: {
orders: (user, _args, ctx) => ctx.loaders.ordersByUser.load(user.id),
},
};
Always use DataLoader (or equivalent) for any field that fetches related data. Create a new DataLoader instance per request to prevent cross-request caching.
type Subscription {
orderStatusChanged(orderId: ID!): Order!
newMessage(channelId: ID!): Message!
}
// Server (graphql-ws protocol, NOT the deprecated subscriptions-transport-ws)
import { WebSocketServer } from "ws";
import { useServer } from "graphql-ws/lib/use/ws";
const wsServer = new WebSocketServer({ server: httpServer, path: "/graphql" });
useServer(
{
schema,
context: async (ctx) => {
// Authenticate on connection_init
const token = ctx.connectionParams?.token;
const user = await verifyToken(token);
if (!user) throw new Error("Unauthorized");
return { user };
},
},
wsServer
);
Subscription tips: Use graphql-ws library (not the legacy subscriptions-transport-ws). Authenticate on WebSocket connection_init, not per message. Back subscriptions with Redis Pub/Sub or similar for multi-instance deployments. Always set connection timeouts and heartbeats.
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer