Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save keskinonur/b7dcad4b13b7c84ad2f940f563b88957 to your computer and use it in GitHub Desktop.

Select an option

Save keskinonur/b7dcad4b13b7c84ad2f940f563b88957 to your computer and use it in GitHub Desktop.
How to (and How Not to) Design REST APIs - 2025 Edition

How to (and How Not to) Design REST APIs - 2025 Edition

Original by Jeff Sternal (Oct 30, 2023) Updated with modern security, engineering, and best practices

  • Update 2025-07-26: Added security practices, OpenAPI documentation, versioning strategies, rate limiting, CORS, authentication/authorization patterns, and modern engineering practices

In my career, I have consumed hundreds of REST APIs and produced dozens. Since I often see the same mistakes repeated in API design, I thought it might be nice to write down a set of best practices. This updated version incorporates modern security requirements, engineering practices, and lessons learned from the evolution of API design.

Core Design Rules

Rule #0: DON'T get pedantic

Most developers today understand "REST" as some sort of HTTP-based API with noun-based URLs. The term when coined meant something slightly different, but language changes. Don't worry about what is or isn't REST; focus on building pragmatic, useful APIs.

Rule #1: DO use plural nouns for collections

It's an arbitrary convention, but it's well-established and I have found violations tend to be a leading indicator of "this API will have rough edges".

# GOOD
GET /products              # get all the products
GET /products/{product_id} # get one product

# BAD
GET /product/{product_id}

Rule #2: DON'T add unnecessary path segments

A common mistake seems to be trying to build your relational model into your URL structure.

# GOOD
GET /v3/application/listings/{listing_id}

# BAD
PATCH /v3/application/shops/{shop_id}/listings/{listing_id}
GET /v3/application/shops/{shop_id}/listings/{listing_id}/properties

The {listing_id} is globally unique; there's no reason for {shop_id} to be part of the URL.

Rule #3: DON'T add .json or other extensions to the url

  • URLs are resource identifiers, not representations
  • HTTP already offers headers (Accept, Accept-Charset, Accept-Encoding, Accept-Language) to negotiate representations
  • JSON should be the default anyway

Rule #4: DON'T return arrays as top level responses

The top level response from an endpoint should always be an object, never an array.

# GOOD
GET /things returns:
{ 
  "data": [{ ...thing1...}, { ...thing2...}],
  "meta": {
    "totalCount": 42,
    "page": 1,
    "pageSize": 20
  }
}

# BAD
GET /things returns:
[{ ...thing1...}, { ...thing2...}]

Rule #5: DON'T return map structures

Return arrays of objects instead of map structures.

# BAD
GET /things returns:
{
    "KEY1": { "id": "KEY1", "foo": "bar" },
    "KEY2": { "id": "KEY2", "foo": "baz" }
}

# GOOD
GET /things returns:
{
    "data": [
        { "id": "KEY1", "foo": "bar" },
        { "id": "KEY2", "foo": "baz" }
    ]   
}

Rule #6: DO use strings for all identifiers

Always use strings for object identifiers, even if your internal representation is numeric.

# BAD
{ "id": 123 }

# GOOD
{ "id": "123" }

Rule #7: DO prefix your identifiers

Make different types of IDs self-describing:

  • Stripe's identifiers: cus_1MVpWEJVZPfyS2HyRgVDkwiZ
  • Consider UUIDs with prefixes: prod_550e8400-e29b-41d4-a716-446655440000
  • Or use URN format: urn:app:product:550e8400-e29b-41d4-a716-446655440000

Rule #8: DON'T use 404 to indicate "not found"

Use a different 400-level error code that clients can interpret as "I understand what you're asking for, but I don't have it". Options:

  • 410 GONE
  • 422 Unprocessable Entity (with clear error message)

Rule #9: BE consistent

Keep field names, formats, and structures consistent across your entire API.

Rule #10: DO use a structured error format

{
  "error": {
    "code": "RESOURCE_NOT_FOUND",
    "message": "Product with ID 'prod_123' not found",
    "type": "NotFoundError",
    "timestamp": "2025-07-26T10:15:30Z",
    "traceId": "550e8400-e29b-41d4-a716-446655440000",
    "details": {
      "resourceType": "product",
      "resourceId": "prod_123"
    },
    "help": "https://api.example.com/docs/errors/RESOURCE_NOT_FOUND"
  }
}

Rule #11: DO provide idempotence mechanisms

Support idempotency for non-GET requests:

POST /orders
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json

{
  "product": "frisbee",
  "quantity": 2
}

Rule #12: DO use ISO8601 strings for timestamps

Use ISO8601 format with UTC timezone:

  • Good: "2025-07-26T10:15:30Z"
  • Bad: 1722001230000

Modern Security Practices

Rule #13: DO implement proper authentication and authorization

Authentication Options:

  1. API Keys (for server-to-server):

    X-API-Key: sk_live_4eC39HqLyjWDarjtT1zdp7dc
    
  2. Bearer Tokens (JWT or OAuth 2.0):

    Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
    
  3. OAuth 2.0 with PKCE (for public clients):

    # Authorization request
    GET /oauth/authorize?
      client_id=CLIENT_ID&
      redirect_uri=REDIRECT_URI&
      response_type=code&
      scope=read:products&
      state=RANDOM_STATE&
      code_challenge=CODE_CHALLENGE&
      code_challenge_method=S256
    

Rule #14: DO implement rate limiting

Include rate limit headers in responses:

X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 999
X-RateLimit-Reset: 1722004800
X-RateLimit-Retry-After: 3600

Consider different limits for:

  • Authenticated vs anonymous requests
  • Different subscription tiers
  • Different endpoints (writes vs reads)

Rule #15: DO validate all inputs

{
  "email": "user@example.com",  // Validate email format
  "age": 25,                    // Validate range: 0-150
  "role": "admin",              // Validate enum: ["user", "admin", "moderator"]
  "url": "https://example.com"  // Validate URL format and protocol
}

Rule #16: DO implement CORS properly

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With
Access-Control-Max-Age: 86400
Access-Control-Allow-Credentials: true

Never use:

Access-Control-Allow-Origin: *  // With credentials

Rule #17: DO use HTTPS everywhere

  • Enforce TLS 1.3 minimum
  • Implement HSTS headers
  • Use certificate pinning for mobile apps
  • Redirect all HTTP to HTTPS

API Versioning

Rule #18: DO version your API

URL Path Versioning (recommended for clarity):

https://api.example.com/v1/products
https://api.example.com/v2/products

Header Versioning (alternative):

GET /products
API-Version: 2025-07-26

Sunset Headers for deprecation:

Sunset: Sat, 31 Dec 2025 23:59:59 GMT
Deprecation: true
Link: <https://api.example.com/v2/products>; rel="successor-version"

Documentation and Developer Experience

Rule #19: DO provide OpenAPI/Swagger documentation

openapi: 3.1.0
info:
  title: Example API
  version: 1.0.0
  description: |
    Production API for Example Service.
    
    ## Authentication
    Use Bearer token in Authorization header.
    
    ## Rate Limiting
    1000 requests per hour for authenticated users.
servers:
  - url: https://api.example.com/v1
    description: Production server
paths:
  /products:
    get:
      summary: List all products
      operationId: listProducts
      parameters:
        - name: page
          in: query
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: pageSize
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductList'

Rule #20: DO provide SDK examples

Include examples in multiple languages:

// TypeScript/JavaScript
import { ExampleAPI } from '@example/api-client';

const api = new ExampleAPI({
  apiKey: process.env.EXAMPLE_API_KEY,
  timeout: 30000,
  retryConfig: {
    retries: 3,
    retryDelay: 1000
  }
});

try {
  const products = await api.products.list({
    page: 1,
    pageSize: 20
  });
  console.log(products.data);
} catch (error) {
  console.error('API Error:', error);
}
// PHP
use Example\ApiClient;
use Example\ApiException;

$client = new ApiClient([
    'apiKey' => $_ENV['EXAMPLE_API_KEY'],
    'timeout' => 30,
    'retryAttempts' => 3
]);

try {
    $products = $client->products->list([
        'page' => 1,
        'pageSize' => 20
    ]);
    
    foreach ($products->data as $product) {
        echo $product->name . PHP_EOL;
    }
} catch (ApiException $e) {
    error_log('API Error: ' . $e->getMessage());
}

Modern Engineering Practices

Rule #21: DO implement pagination properly

Cursor-based pagination (recommended for large datasets):

{
  "data": [...],
  "pagination": {
    "hasMore": true,
    "nextCursor": "eyJpZCI6MTAwfQ==",
    "prevCursor": "eyJpZCI6ODB9"
  }
}

Offset-based pagination (simpler, but has limitations):

{
  "data": [...],
  "pagination": {
    "page": 2,
    "pageSize": 20,
    "totalPages": 50,
    "totalCount": 1000
  }
}

Rule #22: DO support field filtering

Allow clients to request only needed fields:

GET /products?fields=id,name,price
GET /products?fields=id,name,category(id,name)

Rule #23: DO implement proper caching

Cache-Control: max-age=3600, private
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Last-Modified: Wed, 21 Oct 2025 07:28:00 GMT

Support conditional requests:

If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"
If-Modified-Since: Wed, 21 Oct 2025 07:28:00 GMT

Rule #24: DO support bulk operations

POST /products/bulk
{
  "operations": [
    {
      "method": "create",
      "data": { "name": "Product 1", "price": 29.99 }
    },
    {
      "method": "update",
      "id": "prod_123",
      "data": { "price": 34.99 }
    },
    {
      "method": "delete",
      "id": "prod_456"
    }
  ]
}

// Response
{
  "results": [
    { "status": "success", "id": "prod_789", "data": {...} },
    { "status": "success", "id": "prod_123", "data": {...} },
    { "status": "error", "id": "prod_456", "error": {...} }
  ]
}

Rule #25: DO implement webhooks for async events

POST https://customer.example.com/webhooks
{
  "id": "evt_1MqRtBEcg9tqpw",
  "type": "order.completed",
  "created": "2025-07-26T10:15:30Z",
  "data": {
    "object": {
      "id": "ord_123",
      "status": "completed"
    }
  }
}

// Include webhook signature for security
X-Webhook-Signature: sha256=d57c5c6f4b6e5a106b7b12d95a0c1c3f08f9e245c36596e2c49e0c5584e31234

Rule #26: DO provide health check endpoints

GET /health

{
  "status": "healthy",
  "version": "1.2.3",
  "timestamp": "2025-07-26T10:15:30Z",
  "checks": {
    "database": "healthy",
    "cache": "healthy",
    "queue": "healthy"
  }
}

Rule #27: DO log and monitor everything

Include correlation IDs in all requests:

X-Request-ID: 550e8400-e29b-41d4-a716-446655440000
X-Correlation-ID: 7e0a7c0b-f3f4-4d04-a4d4-3e5c6f6e7d8e

Performance Optimization

Rule #28: DO support compression

Accept-Encoding: gzip, deflate, br
Content-Encoding: gzip

Rule #29: DO implement connection pooling

For HTTP/2 and HTTP/3:

  • Use multiplexing
  • Implement proper connection reuse
  • Set appropriate timeouts

Rule #30: DO optimize for mobile clients

  • Minimize payload sizes
  • Support partial responses
  • Implement aggressive caching
  • Consider GraphQL for flexible queries

Testing and Quality Assurance

Rule #31: DO provide a sandbox environment

Production: https://api.example.com
Sandbox: https://sandbox-api.example.com

With test data and safe operations.

Rule #32: DO implement contract testing

Use tools like Pact or Spring Cloud Contract to ensure API compatibility.

Rule #33: DO monitor API performance

Track:

  • Response times (p50, p95, p99)
  • Error rates by endpoint
  • Rate limit violations
  • Authentication failures

Conclusion

Building great APIs requires attention to detail, consistency, and a focus on developer experience. By following these rules and staying current with security and engineering best practices, you'll create APIs that are secure, performant, and a joy to use.

Remember: The best API is one that developers can understand quickly, integrate easily, and rely on completely.

Additional Resources

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment