API Design Guide
Best practices for designing developer-friendly, maintainable APIs.
REST API Principles
Resource Naming
Use nouns, not verbs:
✓ GET /users ✓ GET /users/123 ✓ GET /users/123/orders ✗ GET /getUsers ✗ GET /fetchUserById ✗ POST /createNewOrder
Use plural nouns:
✓ /users ✓ /orders ✓ /products ✗ /user ✗ /order
Hierarchical relationships:
/users/{userId}/orders # User's orders /users/{userId}/orders/{orderId} # Specific order
HTTP Methods
| Method | Purpose | Idempotent | Request Body |
|---|---|---|---|
| GET | Retrieve resource(s) | Yes | No |
| POST | Create resource | No | Yes |
| PUT | Replace resource entirely | Yes | Yes |
| PATCH | Update resource partially | No | Yes |
| DELETE | Remove resource | Yes | No |
Status Codes
Success (2xx):
200 OK- Request succeeded201 Created- Resource created (return Location header)204 No Content- Success with no response body
Client Error (4xx):
400 Bad Request- Malformed request401 Unauthorized- Authentication required403 Forbidden- No permission404 Not Found- Resource doesn't exist409 Conflict- State conflict422 Unprocessable Entity- Validation failed429 Too Many Requests- Rate limited
Server Error (5xx):
500 Internal Server Error- Unexpected error503 Service Unavailable- Temporary outage
Response Format
Successful response:
json{ "data": { "id": "123", "name": "John Doe", "email": "john@example.com" } }
Collection response:
json{ "data": [ { "id": "1", "name": "Item 1" }, { "id": "2", "name": "Item 2" } ], "meta": { "page": 1, "perPage": 20, "total": 100, "totalPages": 5 }, "links": { "self": "/items?page=1", "next": "/items?page=2", "prev": null } }
Error response:
json{ "error": { "code": "VALIDATION_ERROR", "message": "Validation failed", "details": [ { "field": "email", "code": "INVALID_FORMAT", "message": "Must be a valid email address" } ] } }
Pagination
Offset-based (simple, but slow on large datasets):
GET /users?page=2&perPage=20 GET /users?offset=40&limit=20
Cursor-based (efficient, recommended):
GET /users?cursor=eyJpZCI6MTIzfQ&limit=20
Response includes next cursor:
json{ "data": [...], "meta": { "nextCursor": "eyJpZCI6MTQzfQ", "hasMore": true } }
Filtering, Sorting, Fields
Filtering:
GET /users?status=active GET /users?created_after=2024-01-01 GET /users?role=admin,moderator
Sorting:
GET /users?sort=name GET /users?sort=-created_at # Descending GET /users?sort=status,-created_at # Multiple fields
Field selection:
GET /users?fields=id,name,email GET /users?include=orders,profile
Versioning
URL path (recommended):
/api/v1/users /api/v2/users
Header:
Accept: application/vnd.api+json;version=2
OpenAPI Specification
yamlopenapi: 3.0.3 info: title: My API version: 1.0.0 description: API for managing users servers: - url: https://api.example.com/v1 paths: /users: get: summary: List users tags: [Users] parameters: - name: page in: query schema: type: integer default: 1 responses: "200": description: List of users content: application/json: schema: type: object properties: data: type: array items: $ref: "#/components/schemas/User" components: schemas: User: type: object required: [id, email] properties: id: type: string email: type: string format: email name: type: string
Authentication
API Keys (simple, for server-to-server):
Authorization: Api-Key YOUR_API_KEY
Bearer Tokens (JWT, OAuth):
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Include in OpenAPI:
yamlcomponents: securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT security: - bearerAuth: []
Rate Limiting
Include headers in responses:
X-RateLimit-Limit: 1000 X-RateLimit-Remaining: 999 X-RateLimit-Reset: 1640995200
Return 429 Too Many Requests when exceeded.
Security Checklist
- HTTPS only
- Authentication on protected routes
- Input validation
- Output encoding
- Rate limiting
- CORS configuration
- No sensitive data in URLs
- Audit logging
gRPC and Protocol Buffers
Proto Definition
protobufsyntax = "proto3"; package user.v1; service UserService { rpc GetUser(GetUserRequest) returns (User); rpc ListUsers(ListUsersRequest) returns (ListUsersResponse); rpc CreateUser(CreateUserRequest) returns (User); rpc StreamUpdates(StreamRequest) returns (stream UserUpdate); } message User { string id = 1; string name = 2; string email = 3; google.protobuf.Timestamp created_at = 4; } message GetUserRequest { string id = 1; } message ListUsersRequest { int32 page_size = 1; string page_token = 2; } message ListUsersResponse { repeated User users = 1; string next_page_token = 2; }
When to Use gRPC vs REST
| Factor | gRPC | REST |
|---|---|---|
| Performance | Binary, fast | JSON, human-readable |
| Streaming | Bidirectional | SSE/WebSocket workaround |
| Type safety | Proto generates types | OpenAPI + codegen |
| Browser | Needs gRPC-Web proxy | Native |
| Tooling | Protoc, Buf | Swagger, Postman |
| Best for | Service-to-service, streaming | Public APIs, web clients |
tRPC for TypeScript
typescript// server/router.ts import { router, publicProcedure, protectedProcedure } from './trpc'; import { z } from 'zod'; export const appRouter = router({ user: router({ get: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ input }) => { return db.user.findUnique({ where: { id: input.id } }); }), create: protectedProcedure .input(z.object({ name: z.string().min(1), email: z.string().email(), })) .mutation(async ({ input, ctx }) => { return db.user.create({ data: { ...input, createdBy: ctx.userId } }); }), }), }); export type AppRouter = typeof appRouter; // client.ts - Full type inference, no codegen const user = trpc.user.get.useQuery({ id: '123' }); const createUser = trpc.user.create.useMutation();
tRPC is ideal for monorepo full-stack TypeScript apps where client and server share the same codebase.
Webhook Design Patterns
Webhook Payload
json{ "id": "evt_abc123", "type": "order.completed", "created_at": "2025-01-15T10:30:00Z", "data": { "order_id": "ord_456", "total": 99.99, "currency": "USD" } }
Signature Verification
typescript// Sign webhooks with HMAC-SHA256 import crypto from 'crypto'; function signWebhook(payload: string, secret: string): string { return crypto .createHmac('sha256', secret) .update(payload) .digest('hex'); } // Verify on receiving end function verifyWebhook(payload: string, signature: string, secret: string): boolean { const expected = signWebhook(payload, secret); return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expected), ); }
Retry Strategy
Attempt 1: Immediately Attempt 2: After 1 minute Attempt 3: After 5 minutes Attempt 4: After 30 minutes Attempt 5: After 2 hours Attempt 6: After 24 hours (final) Failed webhooks: log, alert, manual retry UI
Idempotency
typescript// Include idempotency key in webhook // Receivers should deduplicate based on event ID async function handleWebhook(event: WebhookEvent) { // Check if already processed const existing = await db.processedEvents.findUnique({ where: { eventId: event.id }, }); if (existing) return { status: 'already_processed' }; // Process and record await db.$transaction([ processEvent(event), db.processedEvents.create({ data: { eventId: event.id } }), ]); }
API Versioning Strategies
| Strategy | Example | Pros | Cons |
|---|---|---|---|
| URL path | /api/v1/users | Explicit, easy to route | URL pollution |
| Query parameter | /api/users?version=1 | Optional parameter | Easy to miss |
| Header | Accept: application/vnd.api.v1 | Clean URLs | Hidden, harder to test |
| Content negotiation | Accept: application/json;v=2 | Standards-based | Complex to implement |
Recommendation: URL path versioning for simplicity. Only bump major versions for breaking changes. Use additive, non-breaking changes within a version.

