Frameworks5 min read

REST API Design: Complete Guide to Building Better APIs in 2025

Master REST API design with modern best practices covering endpoints, authentication, versioning, error handling, and documentation for scalable APIs.

Zeeshan Shahid
Zeeshan Shahid
August 10, 2025
Share:
REST API Design: Complete Guide to Building Better APIs in 2025

Well-designed REST APIs are the backbone of modern web applications. The difference between an API developers enjoy and one they fight is rarely cleverness — it's consistency. This guide covers the conventions, status codes, error formats, and patterns that make an API predictable.

Key Takeaway

Every design decision in this guide serves one goal: letting a developer who has used one of your endpoints correctly guess how the rest of them work. Consistency is worth more than any individual clever choice.

Resource Naming Conventions

Use Nouns, Not Verbs

Good — RESTful:
GET    /users              # List all users
POST   /users              # Create user
GET    /users/123          # Get specific user
PUT    /users/123          # Update user
PATCH  /users/123          # Partial update
DELETE /users/123          # Delete user

Bad — RPC style:
GET    /getUsers
POST   /createUser
GET    /getUserById/123
PUT    /updateUser/123
DELETE /deleteUser/123

Why? The HTTP method already defines the action. The path should identify the resource. Encoding the verb in both places means they can disagree.

Use Plural Nouns Consistently

Good:
/users
/products
/orders
/payments

Bad — mixed:
/user
/products
/order
/payment

Which convention you pick matters far less than picking one. Mixed singular and plural forces every consumer to memorise your API endpoint by endpoint instead of learning the pattern once.

Nested Resources for Relationships

Good — expresses the relationship:
GET    /users/123/posts              # All posts by user 123
POST   /users/123/posts              # Create post for user 123
GET    /users/123/posts/456          # Specific post by user 123
DELETE /users/123/posts/456          # Delete that post

Limit nesting to 2-3 levels:
GET    /users/123/posts/456/comments  # OK
GET    /users/123/posts/456/comments/789/replies  # Too deep

Better for deep relationships — flatten and filter:
GET    /comments?postId=456&userId=123
GET    /replies?commentId=789

HTTP Methods and Status Codes

Standard CRUD Operations

# GET — read (safe and idempotent)
GET    /products
GET    /products/123
GET    /products?category=electronics&sort=-price

# POST — create (not idempotent)
POST   /products
Body: { "name": "Laptop", "price": 999.99, "category": "electronics" }
Response: 201 Created
Location: /products/123

# PUT — full replacement (idempotent)
PUT    /products/123
Body: { "name": "Updated Laptop", "price": 899.99, "category": "electronics", "stock": 50 }
Response: 200 OK

# PATCH — partial update (idempotent)
PATCH  /products/123
Body: { "price": 849.99, "stock": 45 }
Response: 200 OK

# DELETE — remove (idempotent)
DELETE /products/123
Response: 204 No Content
Idempotent doesn't mean 'same response'

DELETE /products/123 twice returns 204 then 404 — different responses, but the server state is the same either way. That's what idempotency means, and it's why clients can safely retry a DELETE after a network timeout but not a POST.

Custom Actions (When REST Isn't Enough)

Not every operation is CRUD. When an action doesn't map to a resource lifecycle, a verb sub-resource is clearer than contorting the model:

POST   /users/123/activate
POST   /users/123/deactivate
POST   /users/123/send-reset-link
POST   /orders/456/cancel
POST   /orders/456/refund
POST   /payments/789/capture

Status Code Guide

2xx Success

200 OK                   - Successful GET, PUT, PATCH, or DELETE with a body
201 Created              - Successful POST; include a Location header
202 Accepted             - Async operation started, not yet complete
204 No Content           - Success with nothing to return

4xx Client Errors

400 Bad Request          - Malformed request, invalid JSON
401 Unauthorized         - Missing or invalid authentication
403 Forbidden            - Authenticated but not permitted
404 Not Found            - Resource doesn't exist
405 Method Not Allowed   - Wrong HTTP method for this path
409 Conflict             - Duplicate resource, version mismatch
422 Unprocessable Entity - Well-formed but semantically invalid
429 Too Many Requests    - Rate limit exceeded

5xx Server Errors

500 Internal Server Error - Unexpected server fault
502 Bad Gateway           - Invalid upstream response
503 Service Unavailable   - Overloaded or down; include Retry-After
504 Gateway Timeout       - Upstream timed out
401 vs 403

401 means "I don't know who you are" — the client should authenticate and retry. 403 means "I know who you are and the answer is still no" — retrying with the same credentials is pointless. Mixing them up sends clients into retry loops that can never succeed.


Request and Response Format

Consistent Response Structure

Wrapping responses in a data envelope costs a few bytes and buys you somewhere to put metadata later without a breaking change:

// Success — list
{
  "data": [
    {
      "id": "usr_123",
      "email": "john@example.com",
      "name": "John Doe",
      "createdAt": "2025-01-15T10:30:00Z"
    }
  ],
  "meta": {
    "pagination": {
      "page": 1,
      "perPage": 20,
      "total": 157,
      "totalPages": 8
    },
    "links": {
      "self": "/users?page=1",
      "next": "/users?page=2",
      "last": "/users?page=8"
    }
  }
}

// Success — single resource
{
  "data": {
    "id": "prd_789",
    "name": "Wireless Mouse",
    "price": 29.99,
    "category": "electronics",
    "stock": 150,
    "createdAt": "2025-02-10T09:00:00Z",
    "updatedAt": "2025-02-12T16:45:00Z"
  }
}

Error Responses

Good errors tell a developer which field is wrong, why, and how to correlate the failure with your logs:

// Validation errors
{
  "error": {
    "type": "validation_error",
    "message": "Invalid input data",
    "code": "VALIDATION_FAILED",
    "details": [
      {
        "field": "email",
        "message": "Invalid email format",
        "code": "INVALID_FORMAT"
      },
      {
        "field": "price",
        "message": "Price must be greater than 0",
        "code": "OUT_OF_RANGE",
        "min": 0.01
      }
    ],
    "requestId": "req_abc123",
    "timestamp": "2025-08-10T10:30:00Z",
    "path": "/products"
  }
}

// Not found
{
  "error": {
    "type": "not_found",
    "message": "Product not found",
    "code": "RESOURCE_NOT_FOUND",
    "requestId": "req_def456",
    "timestamp": "2025-08-10T10:31:00Z"
  }
}

// Rate limited
{
  "error": {
    "type": "rate_limit_error",
    "message": "Rate limit exceeded",
    "code": "RATE_LIMIT_EXCEEDED",
    "retryAfter": 60,
    "limit": 100,
    "remaining": 0,
    "resetAt": "2025-08-10T11:00:00Z"
  }
}
The requestId is the highest-value field here

A machine-readable code lets clients branch on the error. A requestId lets your support team find the exact log line when someone pastes an error into a ticket. Both cost almost nothing to add and are painful to retrofit.


Filtering, Sorting, Search, and Pagination

Query Parameter Conventions

# Filtering — exact match
GET /products?category=electronics&status=active

# Filtering — operators
GET /products?price[gte]=100&price[lte]=500
GET /products?createdAt[gt]=2025-01-01

# Sorting — leading minus for descending
GET /products?sort=price
GET /products?sort=-price
GET /products?sort=-price,name

# Pagination — offset based
GET /products?page=2&limit=20
GET /products?offset=40&limit=20

# Pagination — cursor based (for large or live datasets)
GET /products?cursor=eyJpZCI6MTIzfQ&limit=20

# Field selection (sparse fieldsets)
GET /products?fields=id,name,price

# Search
GET /products?q=laptop

# Expanding relationships
GET /orders?include=user,items
GET /orders/123?include=user.profile,items.product

# Combined
GET /products?category=electronics&price[gte]=100&sort=-price&page=1&limit=20&fields=id,name,price
Offset pagination breaks on live data

?page=2&limit=20 is implemented as OFFSET 20. If a row is inserted while a client is paging, records shift and get skipped or duplicated across pages. Offset is fine for stable, small datasets; use cursor pagination for feeds, logs, or anything actively being written. Offset also degrades on large tables, since the database must count through every skipped row.

Implementation

// controllers/products.controller.js
import { z } from 'zod';

const productQuerySchema = z.object({
  // Filtering
  category: z.string().optional(),
  status: z.enum(['active', 'inactive', 'archived']).optional(),
  'price[gte]': z.coerce.number().positive().optional(),
  'price[lte]': z.coerce.number().positive().optional(),
  'createdAt[gte]': z.coerce.date().optional(),

  // Sorting
  sort: z.string().regex(/^-?\w+(,-?\w+)*$/).default('-createdAt'),

  // Pagination — note the max, which prevents limit=100000
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),

  // Field selection
  fields: z.string().optional(),

  // Search
  q: z.string().max(100).optional(),

  // Includes
  include: z.string().optional(),
});

export async function listProducts(req, res) {
  const query = productQuerySchema.parse(req.query);

  const filter = {};
  if (query.category) filter.category = query.category;
  if (query.status) filter.status = query.status;

  // Price range
  if (query['price[gte]'] || query['price[lte]']) {
    filter.price = {};
    if (query['price[gte]']) filter.price.$gte = query['price[gte]'];
    if (query['price[lte]']) filter.price.$lte = query['price[lte]'];
  }

  // Date range
  if (query['createdAt[gte]']) {
    filter.createdAt = { $gte: query['createdAt[gte]'] };
  }

  // Full-text search
  if (query.q) {
    filter.$text = { $search: query.q };
  }

  // Parse sort string into a sort object
  const sortObj = {};
  query.sort.split(',').forEach(field => {
    if (field.startsWith('-')) {
      sortObj[field.slice(1)] = -1;
    } else {
      sortObj[field] = 1;
    }
  });

  // Field selection
  const projection = query.fields
    ? query.fields.split(',').reduce((acc, field) => {
        acc[field] = 1;
        return acc;
      }, {})
    : {};

  const skip = (query.page - 1) * query.limit;

  const [products, total] = await Promise.all([
    Product.find(filter)
      .select(projection)
      .sort(sortObj)
      .skip(skip)
      .limit(query.limit)
      .populate(query.include?.split(',') || [])
      .lean(),
    Product.countDocuments(filter),
  ]);

  const totalPages = Math.ceil(total / query.limit);

  res.json({
    data: products,
    meta: {
      pagination: {
        page: query.page,
        perPage: query.limit,
        total,
        totalPages,
      },
      links: {
        self: `/products?page=${query.page}`,
        ...(query.page < totalPages && {
          next: `/products?page=${query.page + 1}`,
        }),
        ...(query.page > 1 && {
          prev: `/products?page=${query.page - 1}`,
        }),
        first: '/products?page=1',
        last: `/products?page=${totalPages}`,
      },
    },
  });
}

Validating query parameters with a schema does double duty: it rejects garbage input, and it caps limit so a single request can't ask for your entire table.


Authentication and Authorization

JWT Bearer Token

// middleware/auth.js
import jwt from 'jsonwebtoken';

export async function authenticate(req, res, next) {
  try {
    const authHeader = req.headers.authorization;
    if (!authHeader?.startsWith('Bearer ')) {
      return res.status(401).json({
        error: {
          type: 'authentication_error',
          message: 'Missing or invalid authorization header',
          code: 'MISSING_AUTH',
        },
      });
    }

    const token = authHeader.slice(7);

    const decoded = jwt.verify(token, process.env.JWT_SECRET);

    // Re-check the user on every request — a token issued before a ban
    // is still cryptographically valid
    const user = await User.findById(decoded.userId).select('-password');
    if (!user || !user.isActive) {
      return res.status(401).json({
        error: {
          type: 'authentication_error',
          message: 'Invalid or expired token',
          code: 'INVALID_TOKEN',
        },
      });
    }

    req.user = user;
    req.userId = user.id;

    next();
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({
        error: {
          type: 'authentication_error',
          message: 'Token has expired',
          code: 'TOKEN_EXPIRED',
          expiredAt: error.expiredAt,
        },
      });
    }

    return res.status(401).json({
      error: {
        type: 'authentication_error',
        message: 'Invalid authentication token',
        code: 'INVALID_TOKEN',
      },
    });
  }
}

// Role-based authorization
export function authorize(...roles) {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({
        error: {
          type: 'authentication_error',
          message: 'Authentication required',
          code: 'AUTH_REQUIRED',
        },
      });
    }

    if (!roles.includes(req.user.role)) {
      return res.status(403).json({
        error: {
          type: 'authorization_error',
          message: 'Insufficient permissions',
          code: 'FORBIDDEN',
        },
      });
    }

    next();
  };
}

// Usage
router.get('/admin/users',
  authenticate,
  authorize('admin', 'superadmin'),
  listUsers
);
Don't echo permission internals back to the client

It's tempting to return required: ['admin'] and current: 'user' in a 403 body to help debugging. That hands an attacker a map of your role model. Log the detail server-side; return "Insufficient permissions" to the caller.


API Versioning

URL Versioning

URL versioning is the most common approach because it's visible, cacheable, and trivial to route:

// app.js
import v1Routes from './routes/v1/index.js';
import v2Routes from './routes/v2/index.js';

app.use('/api/v1', v1Routes);
app.use('/api/v2', v2Routes);

// Deprecation signalling — tell clients before you break them
app.use('/api/v1', (req, res, next) => {
  res.setHeader('X-API-Version', 'v1');
  res.setHeader('X-API-Deprecated', 'true');
  res.setHeader('X-API-Sunset-Date', '2026-01-01');
  res.setHeader('X-API-Migration-Guide', 'https://api.example.com/docs/v1-to-v2');
  next();
});

// routes/v1/products.js — old shape
export async function getProducts(req, res) {
  const products = await Product.find();
  res.json(products); // Bare array
}

// routes/v2/products.js — new shape
export async function getProducts(req, res) {
  const products = await Product.find();
  res.json({
    data: products,
    meta: {
      version: 'v2',
      timestamp: new Date().toISOString(),
    },
  });
}

The Sunset HTTP header is standardised (RFC 8594) if you'd rather not invent your own X- headers.


Rate Limiting

import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import redis from './utils/redis.js';

// General API rate limit — Redis-backed so it works across instances
export const apiLimiter = rateLimit({
  store: new RedisStore({ client: redis, prefix: 'rl:' }),
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,
  standardHeaders: true, // RateLimit-* headers
  legacyHeaders: false,
  handler: (req, res) => {
    res.status(429).json({
      error: {
        type: 'rate_limit_error',
        message: 'Too many requests',
        code: 'RATE_LIMIT_EXCEEDED',
        limit: 100,
        windowMs: 900000,
        retryAfter: Math.ceil(req.rateLimit.resetTime / 1000),
      },
    });
  },
});

// Stricter limits on auth endpoints to slow credential stuffing
export const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  skipSuccessfulRequests: true, // Only count failures
});

// Per-user tiered limits (applied after authentication)
export function createUserLimiter(tier) {
  const limits = {
    free: 1000,
    pro: 10000,
    enterprise: 100000,
  };

  return rateLimit({
    windowMs: 60 * 60 * 1000, // 1 hour
    max: limits[tier] || limits.free,
    keyGenerator: (req) => req.user.id,
    skip: (req) => !req.user,
  });
}
In-memory rate limiting is a no-op behind a load balancer

The default express-rate-limit store lives in process memory. Run four instances and an attacker gets four times the limit; restart a pod and the counter resets. Any limit you actually depend on needs a shared store like Redis.


Complete Error Handling

// errors/APIError.js
export class APIError extends Error {
  constructor(message, statusCode, code, details = null) {
    super(message);
    this.name = 'APIError';
    this.statusCode = statusCode;
    this.code = code;
    this.details = details;
    Error.captureStackTrace(this, this.constructor);
  }
}

export class ValidationError extends APIError {
  constructor(details) {
    super('Validation failed', 422, 'VALIDATION_ERROR', details);
  }
}

export class NotFoundError extends APIError {
  constructor(resource = 'Resource') {
    super(`${resource} not found`, 404, 'NOT_FOUND');
  }
}

export class UnauthorizedError extends APIError {
  constructor(message = 'Unauthorized') {
    super(message, 401, 'UNAUTHORIZED');
  }
}

export class ForbiddenError extends APIError {
  constructor(message = 'Forbidden') {
    super(message, 403, 'FORBIDDEN');
  }
}

export class ConflictError extends APIError {
  constructor(message) {
    super(message, 409, 'CONFLICT');
  }
}

export class RateLimitError extends APIError {
  constructor(retryAfter) {
    super('Rate limit exceeded', 429, 'RATE_LIMIT_EXCEEDED');
    this.retryAfter = retryAfter;
  }
}

The handler maps known error types to consistent response bodies and refuses to leak anything else:

// middleware/errorHandler.js
export function errorHandler(err, req, res, next) {
  const requestId = req.id || generateId();

  logger.error({
    err,
    req: { method: req.method, url: req.url },
    requestId,
  }, 'API error');

  // Known API errors — safe to surface
  if (err instanceof APIError) {
    return res.status(err.statusCode).json({
      error: {
        type: err.name.replace('Error', '').toLowerCase() + '_error',
        message: err.message,
        code: err.code,
        ...(err.details && { details: err.details }),
        requestId,
        timestamp: new Date().toISOString(),
      },
    });
  }

  // Zod validation errors
  if (err.name === 'ZodError') {
    return res.status(422).json({
      error: {
        type: 'validation_error',
        message: 'Validation failed',
        code: 'VALIDATION_ERROR',
        details: err.errors.map(e => ({
          field: e.path.join('.'),
          message: e.message,
          code: e.code,
        })),
        requestId,
      },
    });
  }

  // JWT errors
  if (err.name === 'JsonWebTokenError') {
    return res.status(401).json({
      error: {
        type: 'authentication_error',
        message: 'Invalid token',
        code: 'INVALID_TOKEN',
        requestId,
      },
    });
  }

  // Anything else is a bug — never leak internals in production
  const message = process.env.NODE_ENV === 'production'
    ? 'Internal server error'
    : err.message;

  res.status(500).json({
    error: {
      type: 'internal_error',
      message,
      code: 'INTERNAL_ERROR',
      requestId,
      ...(process.env.NODE_ENV !== 'production' && { stack: err.stack }),
    },
  });
}
Never log the full request body on error

The snippet above logs only the method and URL. Logging req.body or req.headers wholesale writes passwords, tokens, and card numbers into your log aggregator — where they're retained, indexed, and readable by anyone with dashboard access. Redact before logging.


API Documentation with OpenAPI

An OpenAPI spec is machine-readable, which means it generates clients, mock servers, and interactive docs from one source:

openapi: 3.0.3
info:
  title: Products API
  version: 2.0.0
  description: Complete API for product management
  contact:
    name: API Support
    email: api@example.com
  license:
    name: MIT

servers:
  - url: https://api.example.com/v2
    description: Production
  - url: https://api-staging.example.com/v2
    description: Staging

paths:
  /products:
    get:
      summary: List products
      tags: [Products]
      parameters:
        - name: category
          in: query
          schema:
            type: string
        - name: page
          in: query
          schema:
            type: integer
            default: 1
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
            maximum: 100
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Product'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'

    post:
      summary: Create product
      tags: [Products]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProductInput'
      responses:
        '201':
          description: Product created

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  schemas:
    Product:
      type: object
      properties:
        id:
          type: string
          example: "prd_123"
        name:
          type: string
        price:
          type: number
          format: double
        category:
          type: string
        createdAt:
          type: string
          format: date-time

    ProductInput:
      type: object
      required: [name, price, category]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100
        price:
          type: number
          minimum: 0.01
        category:
          type: string

    PaginationMeta:
      type: object
      properties:
        pagination:
          type: object
          properties:
            page:
              type: integer
            perPage:
              type: integer
            total:
              type: integer
            totalPages:
              type: integer
Generate the spec, don't maintain it by hand

Hand-written OpenAPI files drift from the implementation within weeks. Tools like zod-to-openapi derive the spec from the same schemas your routes validate against, so the docs can't disagree with the code.


Testing REST APIs

Test the contract — status codes, response shape, and auth boundaries — not just the happy path:

// __tests__/products.api.test.js
import request from 'supertest';
import app from '../app';
import { setupTestDB, cleanupTestDB } from './helpers/db';

describe('Products API', () => {
  let authToken;

  beforeAll(async () => {
    await setupTestDB();
    const res = await request(app)
      .post('/api/v2/auth/login')
      .send({ email: 'test@example.com', password: 'password123' });
    authToken = res.body.data.accessToken;
  });

  afterAll(async () => {
    await cleanupTestDB();
  });

  describe('GET /api/v2/products', () => {
    it('should return paginated products', async () => {
      const res = await request(app)
        .get('/api/v2/products?page=1&limit=10')
        .expect(200);

      expect(res.body).toMatchObject({
        data: expect.any(Array),
        meta: {
          pagination: {
            page: 1,
            perPage: 10,
            total: expect.any(Number),
            totalPages: expect.any(Number),
          },
        },
      });
    });

    it('should filter by category', async () => {
      const res = await request(app)
        .get('/api/v2/products?category=electronics')
        .expect(200);

      expect(res.body.data.every(p => p.category === 'electronics')).toBe(true);
    });

    it('should sort by price descending', async () => {
      const res = await request(app)
        .get('/api/v2/products?sort=-price')
        .expect(200);

      const prices = res.body.data.map(p => p.price);
      expect(prices).toEqual([...prices].sort((a, b) => b - a));
    });

    it('should return 422 for a limit above the maximum', async () => {
      const res = await request(app)
        .get('/api/v2/products?limit=101')
        .expect(422);

      expect(res.body.error.code).toBe('VALIDATION_ERROR');
    });
  });

  describe('POST /api/v2/products', () => {
    it('should create product with valid data', async () => {
      const product = {
        name: 'Test Product',
        price: 99.99,
        category: 'electronics',
      };

      const res = await request(app)
        .post('/api/v2/products')
        .set('Authorization', `Bearer ${authToken}`)
        .send(product)
        .expect(201);

      expect(res.body.data).toMatchObject(product);
      expect(res.body.data.id).toBeDefined();
    });

    it('should return 401 without auth', async () => {
      await request(app)
        .post('/api/v2/products')
        .send({ name: 'Test', price: 10, category: 'test' })
        .expect(401);
    });

    it('should return 422 for invalid price', async () => {
      const res = await request(app)
        .post('/api/v2/products')
        .set('Authorization', `Bearer ${authToken}`)
        .send({ name: 'Test', price: -10, category: 'test' })
        .expect(422);

      expect(res.body.error.details).toContainEqual(
        expect.objectContaining({ field: 'price' })
      );
    });
  });
});

Best Practices Checklist

Naming and structure

  • [ ] Nouns for resources, not verbs
  • [ ] Plural nouns used consistently
  • [ ] Resource nesting limited to 2-3 levels
  • [ ] kebab-case for multi-word paths

HTTP methods

  • [ ] GET for reading (safe and idempotent)
  • [ ] POST for creating
  • [ ] PUT for full replacement (idempotent)
  • [ ] PATCH for partial updates
  • [ ] DELETE for removal (idempotent)

Responses

  • [ ] Consistent envelope across all endpoints
  • [ ] Correct status codes, especially 401 vs 403
  • [ ] Pagination metadata included
  • [ ] Errors carry a machine-readable code and a requestId

Security

  • [ ] Authentication on protected endpoints
  • [ ] Input validation on every endpoint
  • [ ] Rate limiting backed by a shared store
  • [ ] CORS configured deliberately
  • [ ] HTTPS enforced
  • [ ] Errors don't leak internals or permission structure

Performance

  • [ ] Pagination on all list endpoints, with a max limit
  • [ ] Field selection supported
  • [ ] Response compression enabled
  • [ ] Caching headers set

Documentation

  • [ ] OpenAPI spec generated from code
  • [ ] Example requests and responses
  • [ ] Error codes documented
  • [ ] Authentication explained

Resources

Our Take

Great API design is mostly restraint. The temptation is to make each endpoint optimal for the use case in front of you; the result is an API where every endpoint is a special case and nothing is guessable.

Pick your conventions early — envelope shape, error format, pagination style, naming — and apply them even where a different choice would be marginally better in isolation. Predictability beats cleverness. A developer who has integrated one endpoint should be able to guess the next one correctly, and good error messages should mean they rarely need to ask you why it didn't work.

Tags:apirestbackenddesignweb-development
Zeeshan Shahid

Zeeshan Shahid

Founder, DevPages

Zeeshan builds and maintains DevPages, a hand-curated directory of developer tools. He writes about the tools in the catalog and the trade-offs between them.

22 articles published

Related Articles