Web APIs: A Complete Guide to REST, GraphQL, and Modern API Design in 2026
Every modern application is built on APIs. Whether you are fetching data in a React frontend, integrating a payment provider, pulling metrics from a cloud service, or connecting microservices behind the scenes, you are using web APIs. They are the contracts that allow software systems to communicate, and understanding how to design, consume, and secure them is a core skill for every developer.
This guide covers everything you need to know about web APIs in 2026: from foundational REST principles to GraphQL queries, from authentication mechanisms like OAuth 2.0 and JWT to modern protocols like gRPC and WebSockets. Whether you are building your first API or refining an existing one, you will leave with practical knowledge you can apply immediately.
What Are Web APIs and Why Do They Matter?
An API (Application Programming Interface) is a set of rules that defines how two pieces of software communicate. A web API specifically uses HTTP as its transport protocol, making it accessible from any device or language that can make HTTP requests. When your browser loads a social media feed, it is calling a web API. When your CI/CD pipeline triggers a deployment, it is calling a web API. When your phone shows the weather forecast, a web API delivered that data.
Web APIs matter because they enable decoupling. Instead of building monolithic applications where every feature is tightly wired together, APIs allow teams to build independent services that communicate through well-defined interfaces. A frontend team can build against an API contract without waiting for the backend to be finished. A mobile team and a web team can share the same API. Third-party developers can extend your platform without touching your source code.
The API economy is enormous. Companies like Stripe, Twilio, and SendGrid built billion-dollar businesses on APIs alone. Salesforce generates over 50% of its revenue through its API. In 2026, Postman's State of APIs report found that over 75% of organizations consider APIs critical to their business strategy. Understanding API design is not optional; it is a career necessity.
REST APIs: The Foundation of Web Services
REST (Representational State Transfer) is an architectural style defined by Roy Fielding in his 2000 doctoral dissertation. It is not a protocol or a specification but a set of constraints that, when followed, produce scalable and maintainable web services. REST has been the dominant API paradigm for over two decades and remains the most widely used approach in 2026.
Core REST Principles
A truly RESTful API adheres to six architectural constraints:
- Client-Server Separation: The client and server are independent. The client does not need to know how the server stores data, and the server does not need to know about the client's UI. This separation allows each side to evolve independently.
- Statelessness: Every request from the client must contain all the information needed to process it. The server does not store session state between requests. This makes APIs easier to scale because any server in a cluster can handle any request.
- Cacheability: Responses must explicitly indicate whether they can be cached. Proper caching reduces server load and improves client performance. HTTP cache headers (Cache-Control, ETag, Last-Modified) are the mechanism for this.
- Uniform Interface: The API uses a consistent, standardized interface. Resources are identified by URIs. Representations (JSON, XML) are used to manipulate resources. Messages are self-descriptive. Hypermedia links drive application state (HATEOAS).
- Layered System: The client cannot tell whether it is connected directly to the server or to an intermediary like a load balancer, CDN, or API gateway. This enables infrastructure flexibility without changing the API.
- Code on Demand (Optional): Servers can extend client functionality by transferring executable code (like JavaScript). This is the only optional constraint.
HTTP Methods in REST
REST APIs use standard HTTP methods (verbs) to perform operations on resources. Each method has specific semantics that clients and servers agree on:
# GET — Retrieve a resource (safe, idempotent)
GET /api/v1/users/42
# Returns the user with ID 42
# GET — List resources with filtering
GET /api/v1/users?role=admin&status=active&page=1&limit=20
# Returns a filtered, paginated list of users
# POST — Create a new resource (not idempotent)
POST /api/v1/users
Content-Type: application/json
{
"name": "Alice Chen",
"email": "alice@example.com",
"role": "developer"
}
# Server creates the user and returns 201 Created
# PUT — Replace a resource entirely (idempotent)
PUT /api/v1/users/42
Content-Type: application/json
{
"name": "Alice Chen",
"email": "alice.chen@example.com",
"role": "senior-developer"
}
# Replaces ALL fields of user 42
# PATCH — Partially update a resource (not always idempotent)
PATCH /api/v1/users/42
Content-Type: application/json
{
"role": "senior-developer"
}
# Updates ONLY the role field, leaving everything else unchanged
# DELETE — Remove a resource (idempotent)
DELETE /api/v1/users/42
# Deletes user 42, returns 204 No Content
The distinction between PUT and PATCH is important and frequently misunderstood. PUT replaces the entire resource, meaning any field you omit will be removed or set to its default. PATCH updates only the fields you include. In practice, most APIs implement PATCH for updates because it is more bandwidth-efficient and less error-prone.
HTTP Status Codes
Status codes tell the client what happened with its request. Using them correctly is fundamental to good API design:
| Code | Name | When to Use |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH, or DELETE |
| 201 | Created | Successful POST that creates a resource |
| 204 | No Content | Successful DELETE with no response body |
| 301 | Moved Permanently | Resource URL has permanently changed |
| 304 | Not Modified | Cached version is still valid (ETag match) |
| 400 | Bad Request | Malformed request syntax or invalid input |
| 401 | Unauthorized | Authentication required or failed |
| 403 | Forbidden | Authenticated but not authorized for this resource |
| 404 | Not Found | Resource does not exist |
| 409 | Conflict | Resource state conflict (duplicate email, etc.) |
| 422 | Unprocessable Entity | Valid syntax but semantic errors (validation failed) |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Unexpected server failure |
A common mistake is returning 200 for everything and putting the real status in the response body. This breaks HTTP semantics, confuses intermediaries (caches, proxies, monitoring tools), and makes your API harder to consume. Use the correct status code, and use the response body for details.
REST Best Practices
Following established conventions makes your REST API predictable and easy to consume:
- Use nouns for resource URIs, not verbs. Write
/api/users, not/api/getUsers. The HTTP method already indicates the action. - Use plural nouns.
/api/users/42is clearer than/api/user/42. The collection is plural; the individual resource is accessed by ID within it. - Nest resources to show relationships.
/api/users/42/postsreturns posts belonging to user 42. But avoid nesting more than two levels deep. - Use kebab-case for multi-word URIs.
/api/user-profilesis preferred over/api/userProfilesor/api/user_profiles. - Return the created resource on POST. After creating a resource, return 201 with the full resource (including server-generated fields like ID and timestamps) in the response body.
- Support filtering through query parameters.
/api/users?role=admin&created_after=2026-01-01is clean and composable.
GraphQL: A Query Language for Your API
GraphQL was developed at Facebook in 2012 and open-sourced in 2015. It takes a fundamentally different approach from REST: instead of the server defining fixed endpoints that return fixed data shapes, the client specifies exactly what data it needs in a query language, and the server returns exactly that.
GraphQL Queries
A GraphQL query lets the client request specific fields from specific resources:
# A simple query — fetch a user with only the fields you need
query {
user(id: 42) {
name
email
role
}
}
# Response — exactly the shape you requested
{
"data": {
"user": {
"name": "Alice Chen",
"email": "alice@example.com",
"role": "SENIOR_DEVELOPER"
}
}
}
# Nested query — fetch related data in a single request
query {
user(id: 42) {
name
posts(first: 5, orderBy: CREATED_AT_DESC) {
title
publishedAt
comments {
author { name }
body
}
}
}
}
In REST, getting the same data would require multiple requests: one for the user, one for their posts, and N more for comments on each post. GraphQL collapses this into a single round trip. This is particularly valuable for mobile clients on slow networks where every round trip adds latency.
Mutations
Mutations are GraphQL's equivalent of POST, PUT, PATCH, and DELETE in REST:
# Create a new user
mutation {
createUser(input: {
name: "Bob Martinez"
email: "bob@example.com"
role: DEVELOPER
}) {
id
name
email
createdAt
}
}
# Update a user
mutation {
updateUser(id: 42, input: {
role: SENIOR_DEVELOPER
}) {
id
name
role
updatedAt
}
}
# Delete a user
mutation {
deleteUser(id: 42) {
success
message
}
}
Subscriptions
GraphQL subscriptions provide real-time data through WebSocket connections:
# Subscribe to new comments on a post
subscription {
commentAdded(postId: "post-123") {
id
body
author {
name
avatar
}
createdAt
}
}
Schema Design
The GraphQL schema is the contract between client and server. It defines types, queries, mutations, and subscriptions using the Schema Definition Language (SDL):
type User {
id: ID!
name: String!
email: String!
role: Role!
posts: [Post!]!
createdAt: DateTime!
updatedAt: DateTime!
}
type Post {
id: ID!
title: String!
body: String!
author: User!
comments: [Comment!]!
publishedAt: DateTime
tags: [String!]!
}
enum Role {
DEVELOPER
SENIOR_DEVELOPER
TEAM_LEAD
ADMIN
}
type Query {
user(id: ID!): User
users(role: Role, limit: Int = 20, offset: Int = 0): [User!]!
post(id: ID!): Post
posts(authorId: ID, tag: String, limit: Int = 20): [Post!]!
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): DeleteResult!
createPost(input: CreatePostInput!): Post!
}
input CreateUserInput {
name: String!
email: String!
role: Role!
}
input UpdateUserInput {
name: String
email: String
role: Role
}
REST vs GraphQL: When to Use Which
| Consideration | REST | GraphQL |
|---|---|---|
| Caching | Built-in HTTP caching | Requires custom caching (Apollo, Relay) |
| Over-fetching | Common problem | Clients request exactly what they need |
| Under-fetching | Requires multiple requests | Single request for related data |
| Learning curve | Low (uses HTTP conventions) | Moderate (new query language) |
| File uploads | Native multipart support | Requires extra spec (multipart request) |
| Error handling | HTTP status codes | Always 200, errors in response body |
| Versioning | URL or header versioning | Schema evolution (deprecate fields) |
| Best for | Public APIs, simple CRUD, microservices | Complex UIs, mobile apps, BFF pattern |
Choose REST when you are building a public API, need simple caching, have straightforward CRUD operations, or want maximum compatibility. REST is also the better choice for machine-to-machine APIs where the data shape is predictable.
Choose GraphQL when your frontend needs vary significantly across pages or platforms, when you have deeply nested relational data, or when you want to reduce the number of round trips. GraphQL shines in applications like dashboards, social feeds, and e-commerce product pages.
API Authentication and Authorization
Authentication (who are you?) and authorization (what are you allowed to do?) are the most critical aspects of API security. Getting them wrong exposes your users' data and your infrastructure to attackers.
API Keys
The simplest authentication mechanism. The client includes a unique key with every request:
# API key in a header (preferred)
GET /api/v1/data
X-API-Key: sk_live_a1b2c3d4e5f6g7h8i9j0
# API key as a query parameter (less secure — logged in URLs)
GET /api/v1/data?api_key=sk_live_a1b2c3d4e5f6g7h8i9j0
API keys are best for server-to-server communication where you need to identify the calling application. They are not suitable for user authentication because they do not represent a specific user and cannot easily be scoped or revoked per-user. Always transmit API keys over HTTPS and never embed them in client-side code.
OAuth 2.0
OAuth 2.0 is the industry standard for delegated authorization. It allows users to grant third-party applications limited access to their resources without sharing their password. The most common flow for web applications is the Authorization Code flow:
# Step 1: Redirect user to authorization server
GET https://auth.example.com/authorize?
response_type=code&
client_id=YOUR_CLIENT_ID&
redirect_uri=https://yourapp.com/callback&
scope=read:users write:posts&
state=random_csrf_token
# Step 2: User logs in and grants consent
# Authorization server redirects back to your app with a code:
# https://yourapp.com/callback?code=AUTH_CODE&state=random_csrf_token
# Step 3: Exchange the code for tokens (server-side)
POST https://auth.example.com/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=AUTH_CODE&
redirect_uri=https://yourapp.com/callback&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET
# Step 4: Use the access token for API calls
GET /api/v1/users/me
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
OAuth 2.0 also supports other flows: the Client Credentials flow for machine-to-machine APIs (no user involved), the Device Code flow for smart TVs and CLI tools, and PKCE (Proof Key for Code Exchange) for mobile and single-page applications where the client secret cannot be kept confidential.
JSON Web Tokens (JWT)
JWTs are a compact, self-contained way to represent claims between parties. They are commonly used as OAuth 2.0 access tokens and for session management. A JWT has three parts separated by dots: header, payload, and signature.
# JWT structure: header.payload.signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiI0MiIsIm5hbWUiOiJBbGljZSBDaGVuIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzM4MjAwMDAwLCJleHAiOjE3MzgyMDM2MDB9.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
# Decoded header:
{
"alg": "HS256",
"typ": "JWT"
}
# Decoded payload:
{
"sub": "42",
"name": "Alice Chen",
"role": "admin",
"iat": 1738200000,
"exp": 1738203600
}
# The signature verifies the token has not been tampered with
Key JWT best practices: always validate the signature server-side, check the exp (expiration) claim, use short-lived access tokens (15 minutes to 1 hour), pair them with longer-lived refresh tokens, and never store sensitive data in the payload (it is base64-encoded, not encrypted).
Basic Authentication
HTTP Basic Auth sends the username and password as a base64-encoded string in the Authorization header:
# Format: base64(username:password)
GET /api/v1/data
Authorization: Basic YWxpY2U6c2VjcmV0MTIz
# Decoded: alice:secret123
Basic Auth is simple but should only be used over HTTPS (credentials are not encrypted, just encoded) and is best suited for internal tools, scripts, and API testing. For production APIs serving external clients, prefer OAuth 2.0 or API keys.
API Design Best Practices
Good API design makes the difference between an API that developers love and one they dread. These practices are drawn from the APIs that developers consistently rate highest: Stripe, GitHub, Twilio, and Cloudflare.
Versioning
APIs evolve. Versioning lets you make breaking changes without breaking existing clients:
# URL path versioning (most common and explicit)
GET /api/v1/users
GET /api/v2/users
# Header versioning (cleaner URLs)
GET /api/users
Accept: application/vnd.myapi.v2+json
# Query parameter versioning
GET /api/users?version=2
URL path versioning is the most popular approach because it is explicit, easy to understand, and simple to route at the infrastructure level. The key rule: never break an existing version. Once v1 is published, v1 clients should keep working indefinitely (or at least with a generous deprecation timeline).
Pagination
Any endpoint that returns a list must support pagination. Returning thousands of records in a single response destroys performance for both the client and the server:
# Offset-based pagination (simple but has issues with large datasets)
GET /api/v1/posts?page=3&per_page=25
# Response includes pagination metadata
{
"data": [...],
"pagination": {
"page": 3,
"per_page": 25,
"total": 1247,
"total_pages": 50
}
}
# Cursor-based pagination (better for large, dynamic datasets)
GET /api/v1/posts?after=eyJpZCI6MTAwfQ&limit=25
# Response includes cursor for next page
{
"data": [...],
"pagination": {
"has_next": true,
"next_cursor": "eyJpZCI6MTI1fQ",
"has_previous": true,
"previous_cursor": "eyJpZCI6MTAxfQ"
}
}
Offset pagination is simple to implement but suffers from two problems: performance degrades on large offsets (the database still scans all skipped rows), and results shift when items are inserted or deleted between page requests. Cursor-based pagination solves both problems and is the approach used by GitHub, Twitter, and Slack's APIs.
Filtering and Sorting
# Filtering with query parameters
GET /api/v1/products?category=electronics&price_min=100&price_max=500&in_stock=true
# Sorting
GET /api/v1/products?sort=price&order=asc
GET /api/v1/products?sort=-created_at # Prefix with - for descending
# Field selection (sparse fieldsets)
GET /api/v1/users?fields=id,name,email
# Combining everything
GET /api/v1/products?category=electronics&price_min=100&sort=-rating&fields=id,name,price,rating&page=1&per_page=20
Error Handling
A consistent error response format is one of the most impactful things you can do for developer experience. Here is a pattern used by leading APIs:
# Consistent error response format
{
"error": {
"code": "VALIDATION_ERROR",
"message": "The request body contains invalid fields.",
"details": [
{
"field": "email",
"message": "Must be a valid email address.",
"code": "INVALID_FORMAT"
},
{
"field": "age",
"message": "Must be between 13 and 150.",
"code": "OUT_OF_RANGE"
}
],
"request_id": "req_a1b2c3d4",
"documentation_url": "https://api.example.com/docs/errors#VALIDATION_ERROR"
}
}
Every error response should include: a machine-readable error code (not just the HTTP status), a human-readable message, field-level details for validation errors, a request ID for support and debugging, and optionally a link to documentation explaining the error.
Rate Limiting
Rate limiting protects your API from abuse and ensures fair usage across all clients. The standard approach uses response headers to communicate limits:
# Response headers for rate limiting
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 742
X-RateLimit-Reset: 1738203600
Retry-After: 60
# When the limit is exceeded
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1738203600
Retry-After: 45
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "You have exceeded 1000 requests per hour. Please wait 45 seconds.",
"retry_after": 45
}
}
Common rate limiting strategies include fixed window (simple but allows bursts at window boundaries), sliding window (smoother but more complex), and token bucket (the most flexible, used by AWS and Cloudflare). Always return the Retry-After header so clients know when to retry.
HTTP Headers for APIs
HTTP headers carry critical metadata between clients and servers. Understanding the key headers for API communication is essential for both API consumers and designers.
Content-Type and Accept
# Content-Type tells the server what format the request body is in
POST /api/v1/users
Content-Type: application/json
{"name": "Alice", "email": "alice@example.com"}
# Accept tells the server what format you want the response in
GET /api/v1/users/42
Accept: application/json
# Content negotiation — server can return different formats
Accept: application/xml # Request XML response
Accept: text/csv # Request CSV export
Accept: application/json, application/xml;q=0.9 # Prefer JSON, accept XML
Authorization
# Bearer token (OAuth 2.0, JWT)
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
# Basic authentication
Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=
# API key (custom scheme)
Authorization: ApiKey sk_live_a1b2c3d4e5f6
# AWS Signature v4
Authorization: AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20260211/...
CORS (Cross-Origin Resource Sharing)
CORS headers allow APIs to be called from web browsers running on different domains. This is essential for any API consumed by frontend JavaScript:
# Preflight request (browser sends OPTIONS before the actual request)
OPTIONS /api/v1/users
Origin: https://myapp.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
# Server response to preflight
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-Request-ID
Access-Control-Max-Age: 86400
Access-Control-Allow-Credentials: true
Avoid setting Access-Control-Allow-Origin: * if your API uses cookies or authorization headers. The wildcard origin does not work with credentialed requests and creates security risks. Instead, validate the origin against an allowlist and reflect it in the response.
Other Important API Headers
# Idempotency key — ensures safe retries for non-idempotent operations
Idempotency-Key: unique-request-id-12345
# Request tracing — follows a request across microservices
X-Request-ID: req_a1b2c3d4
X-Correlation-ID: corr_e5f6g7h8
# Content encoding — compressed responses
Accept-Encoding: gzip, br
Content-Encoding: gzip
# Conditional requests — for efficient caching
If-None-Match: "etag-value-abc123"
If-Modified-Since: Wed, 11 Feb 2026 10:00:00 GMT
# ETag — response version identifier
ETag: "etag-value-abc123"
Last-Modified: Wed, 11 Feb 2026 09:30:00 GMT
API Testing and Debugging
Testing APIs thoroughly before and after deployment is non-negotiable. Bugs in APIs cascade to every client that depends on them.
Testing Tools
curl remains the most versatile command-line tool for API testing:
# Simple GET request
curl -s https://api.example.com/users/42 | jq .
# POST with JSON body
curl -X POST https://api.example.com/users \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{"name": "Alice", "email": "alice@example.com"}' \
| jq .
# See response headers
curl -i https://api.example.com/users/42
# Verbose mode — see the full request and response
curl -v https://api.example.com/users/42
# Measure response time
curl -o /dev/null -s -w "Total: %{time_total}s\nDNS: %{time_namelookup}s\nConnect: %{time_connect}s\nTTFB: %{time_starttransfer}s\n" https://api.example.com/users
HTTPie offers a more human-friendly syntax:
# GET request (default)
http https://api.example.com/users/42
# POST with JSON (default content type)
http POST https://api.example.com/users name="Alice" email="alice@example.com"
# Custom headers
http https://api.example.com/users Authorization:"Bearer TOKEN"
# Form data
http --form POST https://api.example.com/upload file@photo.jpg
Automated API Testing
For comprehensive API testing, use frameworks that support contract testing, integration testing, and load testing:
# JavaScript — using Jest and supertest
const request = require('supertest');
const app = require('../app');
describe('GET /api/v1/users/:id', () => {
it('returns a user by ID', async () => {
const response = await request(app)
.get('/api/v1/users/42')
.set('Authorization', `Bearer ${token}`)
.expect(200)
.expect('Content-Type', /json/);
expect(response.body).toHaveProperty('id', 42);
expect(response.body).toHaveProperty('name');
expect(response.body).toHaveProperty('email');
});
it('returns 404 for non-existent user', async () => {
const response = await request(app)
.get('/api/v1/users/99999')
.set('Authorization', `Bearer ${token}`)
.expect(404);
expect(response.body.error.code).toBe('NOT_FOUND');
});
it('returns 401 without authentication', async () => {
await request(app)
.get('/api/v1/users/42')
.expect(401);
});
});
# Python — using pytest and requests
import pytest
import requests
BASE_URL = "https://api.example.com/v1"
class TestUsersAPI:
def test_get_user(self, auth_headers):
response = requests.get(f"{BASE_URL}/users/42", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert "id" in data
assert "name" in data
assert "email" in data
def test_create_user(self, auth_headers):
payload = {"name": "Test User", "email": "test@example.com", "role": "developer"}
response = requests.post(f"{BASE_URL}/users", json=payload, headers=auth_headers)
assert response.status_code == 201
assert response.json()["name"] == "Test User"
def test_rate_limiting(self, auth_headers):
for _ in range(1001):
response = requests.get(f"{BASE_URL}/users", headers=auth_headers)
assert response.status_code == 429
assert "Retry-After" in response.headers
API Documentation: OpenAPI and Swagger
Great documentation is the difference between an API that gets adopted and one that gets abandoned. The OpenAPI Specification (formerly Swagger) is the industry standard for describing REST APIs.
OpenAPI 3.1 Example
openapi: 3.1.0
info:
title: User Management API
version: 1.0.0
description: API for managing users and their resources
contact:
email: api-support@example.com
servers:
- url: https://api.example.com/v1
description: Production
- url: https://staging-api.example.com/v1
description: Staging
paths:
/users:
get:
summary: List all users
operationId: listUsers
tags: [Users]
parameters:
- name: role
in: query
schema:
type: string
enum: [developer, admin, viewer]
- name: page
in: query
schema:
type: integer
default: 1
- name: per_page
in: query
schema:
type: integer
default: 20
maximum: 100
responses:
'200':
description: A paginated list of users
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/User'
pagination:
$ref: '#/components/schemas/Pagination'
/users/{id}:
get:
summary: Get a user by ID
operationId: getUser
tags: [Users]
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'200':
description: The user
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'404':
description: User not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
schemas:
User:
type: object
properties:
id:
type: integer
name:
type: string
email:
type: string
format: email
role:
type: string
enum: [developer, admin, viewer]
created_at:
type: string
format: date-time
required: [id, name, email, role]
Error:
type: object
properties:
error:
type: object
properties:
code:
type: string
message:
type: string
request_id:
type: string
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
security:
- bearerAuth: []
From an OpenAPI spec, you can automatically generate interactive documentation (Swagger UI, Redoc), client SDKs in dozens of languages (OpenAPI Generator), server stubs, and mock servers for frontend development. This makes the spec the single source of truth for your entire API ecosystem.
Documentation Best Practices
- Include runnable examples for every endpoint. Developers learn by trying, not by reading abstract descriptions.
- Document error responses as thoroughly as success responses. Developers spend more time handling errors than parsing successes.
- Show authentication setup with complete, copy-pasteable examples in multiple languages.
- Keep a changelog. Document every breaking and non-breaking change with dates and migration guides.
- Provide a quickstart guide that gets a developer from zero to a successful API call in under five minutes.
Modern API Protocols and Patterns
While REST and GraphQL dominate the API landscape, several other protocols and patterns are gaining traction for specific use cases in 2026.
gRPC
gRPC (Google Remote Procedure Call) uses Protocol Buffers for serialization and HTTP/2 for transport. It is significantly faster than REST for inter-service communication:
// user_service.proto — Protocol Buffer definition
syntax = "proto3";
package user;
service UserService {
rpc GetUser (GetUserRequest) returns (User);
rpc ListUsers (ListUsersRequest) returns (stream User);
rpc CreateUser (CreateUserRequest) returns (User);
rpc UpdateUser (UpdateUserRequest) returns (User);
rpc DeleteUser (DeleteUserRequest) returns (DeleteResponse);
}
message User {
int32 id = 1;
string name = 2;
string email = 3;
Role role = 4;
google.protobuf.Timestamp created_at = 5;
}
enum Role {
ROLE_UNSPECIFIED = 0;
DEVELOPER = 1;
SENIOR_DEVELOPER = 2;
ADMIN = 3;
}
message GetUserRequest {
int32 id = 1;
}
message ListUsersRequest {
Role role = 1;
int32 page_size = 2;
string page_token = 3;
}
gRPC advantages: binary serialization (5-10x smaller payloads than JSON), HTTP/2 multiplexing, bidirectional streaming, strong typing with code generation, and built-in deadline/timeout propagation. It is the standard for microservice communication at Google, Netflix, and Square. The tradeoff: no native browser support (requires grpc-web proxy) and less human-readable than JSON.
WebSockets
WebSockets provide full-duplex, persistent connections between client and server. They are essential for real-time applications:
// Client-side WebSocket connection
const ws = new WebSocket('wss://api.example.com/ws');
ws.onopen = () => {
console.log('Connected');
// Subscribe to events
ws.send(JSON.stringify({
type: 'subscribe',
channels: ['trades', 'orderbook']
}));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case 'trade':
handleTrade(data.payload);
break;
case 'orderbook_update':
handleOrderbook(data.payload);
break;
}
};
ws.onclose = (event) => {
console.log(`Disconnected: ${event.code} ${event.reason}`);
// Implement reconnection with exponential backoff
setTimeout(() => reconnect(), getBackoffDelay());
};
Use WebSockets for: chat applications, live dashboards, financial data feeds, collaborative editing, multiplayer games, and any scenario where the server needs to push data to the client immediately. Do not use WebSockets when simple polling or Server-Sent Events would suffice, as WebSocket connections consume server resources and complicate horizontal scaling.
Server-Sent Events (SSE)
SSE provides a simpler alternative to WebSockets when you only need server-to-client streaming:
// Server-side (Node.js/Express)
app.get('/api/v1/events', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
const sendEvent = (data) => {
res.write(`event: update\n`);
res.write(`data: ${JSON.stringify(data)}\n`);
res.write(`id: ${Date.now()}\n\n`);
};
// Send events as they occur
const interval = setInterval(() => {
sendEvent({ timestamp: new Date(), metric: Math.random() });
}, 1000);
req.on('close', () => clearInterval(interval));
});
// Client-side
const events = new EventSource('/api/v1/events');
events.addEventListener('update', (e) => {
const data = JSON.parse(e.data);
updateDashboard(data);
});
SSE advantages over WebSockets: works over regular HTTP (no protocol upgrade), automatic reconnection built into the browser API, works with HTTP/2 multiplexing, simpler server implementation, and passes through proxies and firewalls without issues. SSE is the better choice for notifications, live feeds, log streaming, and progress updates.
tRPC
tRPC has emerged as a popular choice for TypeScript-heavy full-stack applications. It provides end-to-end type safety without code generation or schemas:
// Server — define your API router
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
const appRouter = t.router({
getUser: t.procedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const user = await db.users.findById(input.id);
return user;
}),
createUser: t.procedure
.input(z.object({
name: z.string().min(1),
email: z.string().email(),
role: z.enum(['developer', 'admin']),
}))
.mutation(async ({ input }) => {
return await db.users.create(input);
}),
});
export type AppRouter = typeof appRouter;
// Client — full type safety, zero code generation
import { createTRPCClient } from '@trpc/client';
import type { AppRouter } from '../server';
const client = createTRPCClient<AppRouter>({
url: 'http://localhost:3000/trpc',
});
// TypeScript knows the exact return type
const user = await client.getUser.query({ id: 42 });
console.log(user.name); // Fully typed!
tRPC eliminates the API layer entirely for TypeScript monorepos. There is no OpenAPI spec to maintain, no code generation step, and no runtime overhead. The tradeoff is that it only works when both client and server are TypeScript, making it unsuitable for public APIs consumed by multiple languages.
API Security: The OWASP API Top 10
API security breaches have made headlines repeatedly in recent years. The OWASP API Security Top 10 identifies the most critical risks. Every API developer should understand and mitigate these threats.
1. Broken Object-Level Authorization (BOLA)
The number one API vulnerability. It occurs when an API does not verify that the authenticated user has permission to access the specific resource they are requesting:
# Attacker is user 42 but requests user 43's data
GET /api/v1/users/43/bank-accounts
Authorization: Bearer token_for_user_42
# Vulnerable API returns user 43's data without checking ownership
# Fix: Always verify resource ownership
async function getBankAccounts(req, res) {
const accounts = await db.bankAccounts.find({ userId: req.params.userId });
// CRITICAL: Verify the authenticated user owns this resource
if (req.params.userId !== req.auth.userId && !req.auth.isAdmin) {
return res.status(403).json({
error: { code: "FORBIDDEN", message: "You cannot access another user's accounts." }
});
}
return res.json({ data: accounts });
}
2. Broken Authentication
Weak authentication mechanisms allow attackers to impersonate legitimate users. Common flaws include: accepting weak passwords, not implementing rate limiting on login endpoints, exposing tokens in URLs, not validating JWT signatures, and using predictable API keys.
3. Broken Object Property-Level Authorization
The API exposes object properties that the user should not be able to read or write:
# User submits a profile update but includes an admin field
PATCH /api/v1/users/42
{
"name": "Alice Chen",
"role": "admin" // Should not be settable by the user!
}
# Fix: Explicitly whitelist allowed fields
const ALLOWED_UPDATE_FIELDS = ['name', 'email', 'avatar', 'bio'];
function sanitizeInput(body) {
return Object.fromEntries(
Object.entries(body).filter(([key]) => ALLOWED_UPDATE_FIELDS.includes(key))
);
}
4. Unrestricted Resource Consumption
APIs that do not limit request rates, payload sizes, or query complexity are vulnerable to denial-of-service attacks:
# GraphQL depth attack — nested queries that explode into millions of database calls
query {
user(id: 1) {
friends {
friends {
friends {
friends {
friends { name }
}
}
}
}
}
}
# Mitigations:
# - Rate limiting (requests per minute per client)
# - Query depth limiting (max 5 levels for GraphQL)
# - Query cost analysis (assign costs to fields)
# - Payload size limits (max 1MB request body)
# - Pagination enforcement (max 100 items per page)
# - Timeout limits (kill long-running queries)
5. Additional Security Best Practices
- Always use HTTPS. There is no excuse for unencrypted API traffic in 2026. Use TLS 1.3 where possible.
- Validate all input. Never trust client data. Validate types, lengths, ranges, and formats on every field. Use a schema validation library (Zod, Joi, Pydantic).
- Implement request signing for sensitive operations. AWS, Stripe, and webhook providers use HMAC signatures to verify request integrity.
- Log everything. Log every API request with timestamp, client ID, endpoint, status code, and response time. This is essential for detecting attacks and debugging issues.
- Use short-lived tokens. Access tokens should expire in 15 minutes to 1 hour. Use refresh tokens (stored securely) to issue new access tokens.
- Implement security headers:
Strict-Transport-Security,X-Content-Type-Options: nosniff,X-Frame-Options: DENY, and appropriate CORS headers. - Sanitize error messages. Never expose stack traces, SQL queries, or internal server details in error responses. These are gifts to attackers.
- Audit dependencies. Keep API framework and library versions up to date. Run
npm audit,pip audit, orcargo auditregularly.
Putting It All Together: API Design Checklist
Before shipping any API, run through this checklist. It distills the practices covered in this guide into actionable items:
- Resource Design: Resources use plural nouns. URIs are clean and hierarchical. Relationships are modeled through nesting (max 2 levels).
- HTTP Methods: Each endpoint uses the correct method (GET for reads, POST for creates, etc.). Idempotency is guaranteed where expected (PUT, DELETE).
- Status Codes: Responses use semantically correct HTTP status codes. Errors include machine-readable codes and human-readable messages.
- Authentication: All endpoints require authentication (except health checks and documentation). Tokens are short-lived. Sensitive endpoints have additional authorization checks.
- Pagination: All list endpoints support pagination. Default page sizes are reasonable (20-50). Maximum page sizes are enforced.
- Versioning: The API is versioned from day one. The versioning strategy is consistent across all endpoints.
- Rate Limiting: All endpoints are rate-limited. Limits are communicated via response headers. 429 responses include Retry-After.
- Validation: All input is validated. Error messages identify which fields failed and why.
- Documentation: An OpenAPI spec exists and is kept in sync with the implementation. Interactive documentation is available.
- Security: HTTPS only. CORS is configured correctly. Input is sanitized. No sensitive data in logs or error messages. BOLA checks on every endpoint.
- Testing: Integration tests cover happy paths, error paths, authentication, and edge cases. Load tests verify performance under expected traffic.
Conclusion
Building great APIs in 2026 means understanding the full spectrum of tools and patterns available to you. REST remains the foundation, battle-tested and universally supported. GraphQL solves real problems with data fetching in complex frontends. gRPC dominates high-performance service-to-service communication. WebSockets and SSE enable real-time features. tRPC eliminates the API boundary in TypeScript monorepos.
No matter which protocol or pattern you choose, the fundamentals remain the same: design clear, consistent interfaces; authenticate and authorize every request; validate all input; handle errors gracefully; document everything; and test thoroughly. The APIs you build are the contracts other developers rely on. Make them reliable, predictable, and a pleasure to use.
Start small. Pick one area from this guide, whether it is improving your error handling, adding rate limiting, or writing an OpenAPI spec for your existing API, and implement it this week. Good API design is not a one-time effort. It is a practice you refine continuously as your understanding deepens and your users' needs evolve.