Skip to content
api-design logo

api-design

REST API design patterns including resource naming, status codes, pagination, filtering, error responses, versioning, and rate limiting for production APIs.

affaan-m/ecc11.9Kinstalls271.7Kstars

SKILL.md

Full skill instructions

API Design Patterns

Conventions and best practices for designing consistent, developer-friendly REST APIs.

When to Activate

  • Designing new API endpoints
  • Reviewing existing API contracts
  • Adding pagination, filtering, or sorting
  • Implementing error handling for APIs
  • Planning API versioning strategy
  • Building public or partner-facing APIs

Resource Design

URL Structure

# Resources are nouns, plural, lowercase, kebab-case
GET    /​api/​v1/​users
GET    /​api/​v1/​users/:id
POST   /​api/​v1/​users
PUT    /​api/​v1/​users/:id
PATCH  /​api/​v1/​users/:id
DELETE /​api/​v1/​users/:id

# Sub-resources for relationships
GET    /​api/​v1/​users/:id/​orders
POST   /​api/​v1/​users/:id/​orders

# Actions that don't map to CRUD (use verbs sparingly)
POST   /​api/​v1/​orders/:id/​cancel
POST   /​api/​v1/​auth/​login
POST   /​api/​v1/​auth/​refresh

Naming Rules

# GOOD
/​api/​v1/​team-members          # kebab-case for multi-word resources
/​api/​v1/​orders?status=active  # query params for filtering
/​api/​v1/​users/​123/​orders      # nested resources for ownership

# BAD
/​api/​v1/​getUsers              # verb in URL
/​api/​v1/​user                  # singular (use plural)
/​api/​v1/​team_members          # snake_case in URLs
/​api/​v1/​users/​123/​getOrders   # verb in nested resource

HTTP Methods and Status Codes

Method Semantics

MethodIdempotentSafeUse For
GETYesYesRetrieve resources
POSTNoNoCreate resources, trigger actions
PUTYesNoFull replacement of a resource
PATCHNo*NoPartial update of a resource
DELETEYesNoRemove a resource

*PATCH can be made idempotent with proper implementation

Status Code Reference

# Success
200 OK                    — GET, PUT, PATCH (with response body)
201 Created               — POST (include Location header)
204 No Content            — DELETE, PUT (no response body)

# Client Errors
400 Bad Request           — Validation failure, malformed JSON
401 Unauthorized          — Missing or invalid authentication
403 Forbidden             — Authenticated but not authorized
404 Not Found             — Resource doesn't exist
409 Conflict              — Duplicate entry, state conflict
422 Unprocessable Entity  — Semantically invalid (valid JSON, bad data)
429 Too Many Requests     — Rate limit exceeded

# Server Errors
500 Internal Server Error — Unexpected failure (never expose details)
502 Bad Gateway           — Upstream service failed
503 Service Unavailable   — Temporary overload, include Retry-After

Common Mistakes

# BAD: 200 for everything
{ "status": 200, "success": false, "error": "Not found" }

# GOOD: Use HTTP status codes semantically
HTTP/​1.1 404 Not Found
{ "error": { "code": "not_found", "message": "User not found" } }

# BAD: 500 for validation errors
# GOOD: 400 or 422 with field-level details

# BAD: 200 for created resources
# GOOD: 201 with Location header
HTTP/​1.1 201 Created
Location: /​api/​v1/​users/​abc-123

Response Format

Success Response

{
  "data": {
    "id": "abc-123",
    "email": "[email protected]",
    "name": "Alice",
    "created_at": "2025-01-15T10:30:00Z"
  }
}

Collection Response (with Pagination)

{
  "data": [
    { "id": "abc-123", "name": "Alice" },
    { "id": "def-456", "name": "Bob" }
  ],
  "meta": {
    "total": 142,
    "page": 1,
    "per_page": 20,
    "total_pages": 8
  },
  "links": {
    "self": "/​api/​v1/​users?page=1&per_page=20",
    "next": "/​api/​v1/​users?page=2&per_page=20",
    "last": "/​api/​v1/​users?page=8&per_page=20"
  }
}

Error Response

{
  "error": {
    "code": "validation_error",
    "message": "Request validation failed",
    "details": [
      {
        "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"
      }
    ]
  }
}

Response Envelope Variants

// Option A: Envelope with data wrapper (recommended for public APIs)
interface ApiResponse<T> {
  data: T;
  meta?: PaginationMeta;
  links?: PaginationLinks;
}

interface ApiError {
  error: {
    code: string;
    message: string;
    details?: FieldError[];
  };
}

// Option B: Flat response (simpler, common for internal APIs)
// Success: just return the resource directly
// Error: return error object
// Distinguish by HTTP status code

Pagination

Offset-Based (Simple)

GET /​api/​v1/​users?page=2&per_page=20

# Implementation
SELECT * FROM users
ORDER BY created_at DESC
LIMIT 20 OFFSET 20;

Pros: Easy to implement, supports "jump to page N" Cons: Slow on large offsets (OFFSET 100000), inconsistent with concurrent inserts

Cursor-Based (Scalable)

GET /​api/​v1/​users?cursor=eyJpZCI6MTIzfQ&limit=20

# Implementation
SELECT * FROM users
WHERE id > :cursor_id
ORDER BY id ASC
LIMIT 21;  -- fetch one extra to determine has_next
{
  "data": [...],
  "meta": {
    "has_next": true,
    "next_cursor": "eyJpZCI6MTQzfQ"
  }
}

Pros: Consistent performance regardless of position, stable with concurrent inserts Cons: Cannot jump to arbitrary page, cursor is opaque

When to Use Which

Use CasePagination Type
Admin dashboards, small datasets (<10K)Offset
Infinite scroll, feeds, large datasetsCursor
Public APIsCursor (default) with offset (optional)
Search resultsOffset (users expect page numbers)

Filtering, Sorting, and Search

Filtering

# Simple equality
GET /​api/​v1/​orders?status=active&customer_id=abc-123

# Comparison operators (use bracket notation)
GET /​api/​v1/​products?price[gte]=10&price[lte]=100
GET /​api/​v1/​orders?created_at[after]=2025-01-01

# Multiple values (comma-separated)
GET /​api/​v1/​products?category=electronics,clothing

# Nested fields (dot notation)
GET /​api/​v1/​orders?customer.country=US

Sorting

# Single field (prefix - for descending)
GET /​api/​v1/​products?sort=-created_at

# Multiple fields (comma-separated)
GET /​api/​v1/​products?sort=-featured,price,-created_at

Full-Text Search

# Search query parameter
GET /​api/​v1/​products?q=wireless+headphones

# Field-specific search
GET /​api/​v1/​users?email=alice

Sparse Fieldsets

# Return only specified fields (reduces payload)
GET /​api/​v1/​users?fields=id,name,email
GET /​api/​v1/​orders?fields=id,total,status&include=customer.name

Authentication and Authorization

Token-Based Auth

# Bearer token in Authorization header
GET /​api/​v1/​users
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

# API key (for server-to-server)
GET /​api/​v1/​data
X-API-Key: sk_live_...

Authorization Patterns

// Resource-level: check ownership
app.get("/​api/​v1/​orders/:id", async (req, res) => {
  const order = await Order.findById(req.params.id);
  if (!order) return res.status(404).json({ error: { code: "not_found" } });
  if (order.userId !== req.user.id) return res.status(403).json({ error: { code: "forbidden" } });
  return res.json({ data: order });
});

// Role-based: check permissions
app.delete("/​api/​v1/​users/:id", requireRole("admin"), async (req, res) => {
  await User.delete(req.params.id);
  return res.status(204).send();
});

Rate Limiting

Headers

HTTP/​1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1640000000

# When exceeded
HTTP/​1.1 429 Too Many Requests
Retry-After: 60
{
  "error": {
    "code": "rate_limit_exceeded",
    "message": "Rate limit exceeded. Try again in 60 seconds."
  }
}

Rate Limit Tiers

TierLimitWindowUse Case
Anonymous30/​minPer IPPublic endpoints
Authenticated100/​minPer userStandard API access
Premium1000/​minPer API keyPaid API plans
Internal10000/​minPer serviceService-to-service

Versioning

URL Path Versioning (Recommended)

/​api/​v1/​users
/​api/​v2/​users

Pros: Explicit, easy to route, cacheable Cons: URL changes between versions

Header Versioning

GET /​api/​users
Accept: application/​vnd.myapp.v2+json

Pros: Clean URLs Cons: Harder to test, easy to forget

Versioning Strategy

1. Start with /​api/​v1/ — don't version until you need to
2. Maintain at most 2 active versions (current + previous)
3. Deprecation timeline:
   - Announce deprecation (6 months notice for public APIs)
   - Add Sunset header: Sunset: Sat, 01 Jan 2026 00:00:00 GMT
   - Return 410 Gone after sunset date
4. Non-breaking changes don't need a new version:
   - Adding new fields to responses
   - Adding new optional query parameters
   - Adding new endpoints
5. Breaking changes require a new version:
   - Removing or renaming fields
   - Changing field types
   - Changing URL structure
   - Changing authentication method

Implementation Patterns

TypeScript (Next.js API Route)

import { z } from "zod";
import { NextRequest, NextResponse } from "next/​server";

const createUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1).max(100),
});

export async function POST(req: NextRequest) {
  const body = await req.json();
  const parsed = createUserSchema.safeParse(body);

  if (!parsed.success) {
    return NextResponse.json({
      error: {
        code: "validation_error",
        message: "Request validation failed",
        details: parsed.error.issues.map(i => ({
          field: i.path.join("."),
          message: i.message,
          code: i.code,
        })),
      },
    }, { status: 422 });
  }

  const user = await createUser(parsed.data);

  return NextResponse.json(
    { data: user },
    {
      status: 201,
      headers: { Location: `/​api/​v1/​users/​${user.id}` },
    },
  );
}

Python (Django REST Framework)

from rest_framework import serializers, viewsets, status
from rest_framework.response import Response

class CreateUserSerializer(serializers.Serializer):
    email = serializers.EmailField()
    name = serializers.CharField(max_length=100)

class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ["id", "email", "name", "created_at"]

class UserViewSet(viewsets.ModelViewSet):
    serializer_class = UserSerializer
    permission_classes = [IsAuthenticated]

    def get_serializer_class(self):
        if self.action == "create":
            return CreateUserSerializer
        return UserSerializer

    def create(self, request):
        serializer = CreateUserSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        user = UserService.create(**serializer.validated_data)
        return Response(
            {"data": UserSerializer(user).data},
            status=status.HTTP_201_CREATED,
            headers={"Location": f"/​api/​v1/​users/​{user.id}"},
        )

Go (net/​http)

func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
        return
    }

    if err := req.Validate(); err != nil {
        writeError(w, http.StatusUnprocessableEntity, "validation_error", err.Error())
        return
    }

    user, err := h.service.Create(r.Context(), req)
    if err != nil {
        switch {
        case errors.Is(err, domain.ErrEmailTaken):
            writeError(w, http.StatusConflict, "email_taken", "Email already registered")
        default:
            writeError(w, http.StatusInternalServerError, "internal_error", "Internal error")
        }
        return
    }

    w.Header().Set("Location", fmt.Sprintf("/​api/​v1/​users/%s", user.ID))
    writeJSON(w, http.StatusCreated, map[string]any{"data": user})
}

API Design Checklist

Before shipping a new endpoint:

  • Resource URL follows naming conventions (plural, kebab-case, no verbs)
  • Correct HTTP method used (GET for reads, POST for creates, etc.)
  • Appropriate status codes returned (not 200 for everything)
  • Input validated with schema (Zod, Pydantic, Bean Validation)
  • Error responses follow standard format with codes and messages
  • Pagination implemented for list endpoints (cursor or offset)
  • Authentication required (or explicitly marked as public)
  • Authorization checked (user can only access their own resources)
  • Rate limiting configured
  • Response does not leak internal details (stack traces, SQL errors)
  • Consistent naming with existing endpoints (camelCase vs snake_case)
  • Documented (OpenAPI/​Swagger spec updated)