APIs
The connective tissue of modern software — how they actually work, how to use them well, and how to build them well.
Who This Is For
Developers and technical builders who use APIs regularly but want a deeper model — not just how to call endpoints, but why they're designed the way they are, what the tradeoffs are, and how to avoid the mistakes that hurt later.
This is a 45-minute read covering consuming APIs, building APIs, and the newer territory of AI APIs and tool protocols like MCP.
What an API Actually Is
API stands for Application Programming Interface. That's technically accurate and entirely unhelpful for building intuition. Here's a better framing:
An API is a contract. One piece of software says: "If you send me a request in this specific format, I will send you back a response in this specific format." The contract defines what requests are valid, what responses will look like, and what errors mean. The internals — how the database is structured, what language the server is written in, where it's deployed — are hidden behind the contract.
This contract model is what makes APIs powerful. GitHub's API doesn't care whether you're calling it from Python or JavaScript or a bash script. Stripe's API doesn't care whether your checkout is built in React or server-rendered PHP. The contract is the stable interface; the implementations on both sides can change independently.
The Client-Server Model
Every API interaction involves at least two parties: a client making a request and a server returning a response. The client initiates, the server responds. The client doesn't need to know anything about the server's implementation. The server doesn't need to know anything about the client's implementation. They only need to agree on the contract.
Requests and Responses
Every HTTP API interaction involves:
Request: a method (GET, POST, PUT, DELETE), a URL identifying the resource, headers with metadata (authentication, content type), and optionally a body with data.
Response: a status code indicating what happened, headers with metadata, and optionally a body with data or error information.
# Request
GET /api/users/42 HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
Accept: application/json# Response HTTP/1.1 200 OK Content-Type: application/json { "id": 42, "name": "Alice Chen", "email": "[email protected]", "created_at": "2024-01-15T10:30:00Z" }
Everything you do with a REST API is a variation of this pattern.
HTTP Methods — The Verbs
HTTP methods describe the intended action. REST APIs map them to operations on resources.
GET — retrieve data. Should never modify anything. Safe to call multiple times (idempotent). Results can be cached.
POST — create something new or submit data. Not idempotent — calling it twice typically creates two records.
PUT — replace a resource entirely. Idempotent — calling it multiple times with the same data has the same effect as calling it once.
PATCH — partially update a resource. Only send the fields you want to change.
DELETE — remove a resource. Idempotent — deleting something that's already deleted is fine.
GET /users → list users GET /users/42 → get user 42 POST /users → create a new user PUT /users/42 → replace user 42 entirely PATCH /users/42 → update some fields on user 42 DELETE /users/42 → delete user 42
Idempotency Matters in Practice
Idempotent operations are safe to retry. If you send a PUT and the network drops before you get a response, you can retry without risk. If you retry a non-idempotent POST, you might create duplicate records.
URL Design — The Nouns
REST URLs identify resources. The verb is in the HTTP method. The URL is the thing you're acting on.
// Wrong GET /getUser/42 POST /createUser DELETE /deleteUser/42 // Right GET /users/42 POST /users DELETE /users/42
Collections and Hierarchy
Plural for collections, singular for specific items:
GET /products → collection of products GET /products/123 → specific product GET /products/123/reviews → reviews belonging to product 123 POST /products/123/reviews → create a review for product 123
Hierarchy represents relationships. Nested paths show containment. Don't nest more than two or three levels — deep nesting becomes unreadable and often signals a design problem.
Use query parameters for filtering, sorting, and searching:
GET /products?category=electronics&min_price=100&sort=price_asc GET /users?search=alice&page=2&per_page=25
Versioning in the URL:
GET /v1/users/42 GET /v2/users/42
API-Version: 2), but URL-based versioning is more practical — it's visible, copyable, and obvious in browser history and logs.Status Codes — What Happened
HTTP status codes are a standardized vocabulary for outcomes. Using them correctly makes your API predictable and enables monitoring tools, proxies, and clients to work properly.
2xx — Success
| Code | Meaning |
|---|---|
| 200 OK | General success for GET, PUT, PATCH. Request worked, here's the data. |
| 201 Created | A new resource was created (successful POST). Should include a Location header. |
| 204 No Content | Success, but no body. Typical for DELETE. |
| 202 Accepted | Request received, will be processed asynchronously. |
3xx — Redirection
| Code | Meaning |
|---|---|
| 301 | Moved Permanently — clients should update their bookmarks. |
| 304 | Not Modified — used with caching. Resource hasn't changed. |
4xx — Client Error
| Code | Meaning |
|---|---|
| 400 | Bad Request — malformed, missing required fields, wrong data types. |
| 401 | Unauthorized — authentication required or credentials invalid. (Really "unauthenticated.") |
| 403 | Forbidden — authenticated but not authorized. Knows who you are, but you can't do this. |
| 404 | Not Found — resource doesn't exist. |
| 405 | Method Not Allowed — wrong HTTP method on this endpoint. |
| 409 | Conflict — request conflicts with current state (e.g., uniqueness violation). |
| 422 | Unprocessable Entity — syntactically valid, semantically invalid. |
| 429 | Too Many Requests — rate limit exceeded. Should include Retry-After header. |
5xx — Server Error
| Code | Meaning |
|---|---|
| 500 | Internal Server Error — something unexpected went wrong. |
| 502 | Bad Gateway — invalid response from upstream service. |
| 503 | Service Unavailable — temporarily unable to handle requests. |
| 504 | Gateway Timeout — upstream service didn't respond in time. |
{"success": false, "error": "something failed"}). This breaks monitoring, logging, and client error handling. Don't return 500 for validation errors — a bad request is a 4xx. Don't return 200 for everything because it's simpler.Headers — The Metadata Layer
Headers carry metadata about the request and response. A few are essential to understand.
Request Headers
# Authentication Authorization: Bearer eyJhbGciOiJIUzI1NiJ9... # JWT or OAuth token Authorization: Basic dXNlcjpwYXNz # base64 user:password X-API-Key: sk-prod-1234567890abcdef # API key (custom header) # Content negotiation Content-Type: application/json # format of request body Accept: application/json # desired response format
Response Headers
# Rate limit headers — always check these X-RateLimit-Limit: 1000 # requests allowed per window X-RateLimit-Remaining: 847 # requests left in current window X-RateLimit-Reset: 1640995200 # Unix timestamp when window resets Retry-After: 60 # seconds to wait (in 429 responses)
CORS Headers
Cross-Origin Resource Sharing — browsers require these before allowing JavaScript on one domain to call an API on another:
Access-Control-Allow-Origin: https://myapp.com Access-Control-Allow-Methods: GET, POST, PUT, DELETE Access-Control-Allow-Headers: Content-Type, Authorization
API Keys — The Simple Approach
An API key is a long random string that identifies and authenticates a caller. Simple, widely used, easy to implement.
curl -H "X-API-Key: sk-prod-abc123xyz" https://api.example.com/data
Good for: server-to-server communication, simple integrations, identifying which application is making requests, rate limiting per client.
Limited because: they're all-or-nothing (one key = full access), they don't represent a specific user, and if leaked they're valid until rotated.
Bearer Tokens and JWT
A bearer token is any token that grants access when presented — "bearer" meaning whoever holds it. The most common format is JWT (JSON Web Token, pronounced "jot").
A JWT is three base64-encoded sections separated by dots: header (algorithm), payload (claims), and signature. The payload typically includes who the token is for, when it expires, and what permissions it grants. The signature proves it hasn't been tampered with.
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI0MiJ9.xZ3hpTbP...
The useful property: the server can validate the token by checking the signature without making a database call. The token is self-describing — fast and stateless.
The limitation: JWTs can't be truly "revoked" before expiration since there's no central registry. Short expiration times (15–60 minutes) mitigate this, combined with refresh tokens.
OAuth 2.0 — Delegated Access
OAuth 2.0 is the standard for "User A wants to authorize App B to access their data on Service C, without giving App B their password." The "Login with Google/GitHub" flow is OAuth.
Key concepts:
- Resource owner — the user whose data is being accessed
- Client — the application requesting access (your app)
- Authorization server — issues tokens (Google's, GitHub's auth servers)
- Resource server — where the protected data lives
- Scopes — specific permissions being requested (
read:emails,repo:write)
The User-Facing Flow
- Your app redirects the user to the authorization server
- User approves the requested scopes
- Authorization server redirects back with an authorization code
- Your app exchanges the code for an access token (and refresh token)
- Your app uses the access token to make API calls
Server-to-server: Client Credentials flow — your server sends its client ID and secret directly to get an access token, no user involved.
When to Use What
| Scenario | Method |
|---|---|
| Simple server-to-server, you control both sides | API key |
| User-facing app accessing external services on behalf of users | OAuth 2.0 with bearer tokens |
| User auth within your own system | JWT tokens from your auth server |
| Need short-lived credentials, granular permissions, or enterprise identity | JWT/OAuth from the start |
Consistent Structure
Pick conventions and apply them everywhere.
// Response envelope — consistent shape across all endpoints { "data": { ... }, "meta": { "page": 1, "per_page": 25, "total": 143 } } // Error envelope — same shape regardless of error type { "error": { "code": "VALIDATION_FAILED", "message": "email is required", "details": [ { "field": "email", "issue": "missing" } ], "request_id": "req_abc123" } }
request_id in errors is underrated. It lets clients report "I got this error" and you can find the exact request in your logs instantly.Use snake_case or camelCase for field names — pick one and be consistent. Use ISO 8601 for timestamps: "2024-01-15T10:30:00Z". Not Unix timestamps, not custom formats.
Pagination
Any endpoint that returns a collection needs pagination. Without it, performance degrades as data grows.
Offset Pagination (Simpler)
GET /products?page=3&per_page=25
// Response
{ "data": [...], "meta": { "page": 3, "per_page": 25, "total": 143 } }Clients can jump to any page. Downside: if items are inserted or deleted while paginating, pages shift.
Cursor Pagination (More Correct)
GET /products?after=cursor_abc123&limit=25
// Response
{ "data": [...], "next_cursor": "cursor_def456", "has_more": true }Immune to insertion/deletion problems because the cursor tracks position in the ordered data, not a page number. Better for feeds, event logs, anything that changes frequently.
Filtering and Sorting
GET /orders?status=pending&created_after=2024-01-01&user_id=42 GET /products?category=electronics&min_price=100&max_price=500 GET /events?sort=created_at&order=desc
Keep parameters consistent — created_after/created_before everywhere, not since/until in some places and after_date/before_date in others.
Error Handling
Errors are a first-class part of the API contract. Good error design is the difference between a developer who integrates in an afternoon and one who spends a week debugging.
Building: What Makes a Good Error
Machine-readable code + human-readable message + context:
{
"error": {
"code": "INSUFFICIENT_FUNDS",
"message": "The payment could not be processed due to insufficient funds.",
"details": {
"required": 5000,
"available": 3200,
"currency": "USD"
},
"request_id": "req_7f3a2b9d",
"docs_url": "https://docs.example.com/errors/INSUFFICIENT_FUNDS"
}
}{ "error": "Something went wrong" } — useless.{ "error": "Error code: 4028" } — opaque, undocumented.Never expose stack traces in production API responses.
Consuming: How to Handle Errors Well
Don't just check for 200 and treat everything else as "an error." Distinguish by type:
response = requests.post(url, json=data, headers=headers)
if response.status_code in (200, 201):
return response.json()
elif response.status_code == 400:
# Bad request — our code has a bug, fix it
raise ValueError(f"Bad request: {response.json()['error']['message']}")
elif response.status_code == 401:
# Auth failed — refresh token or re-authenticate
self.refresh_auth()
return self.retry_request(...)
elif response.status_code == 429:
# Rate limited — back off and retry
retry_after = int(response.headers.get('Retry-After', 60))
time.sleep(retry_after)
return self.retry_request(...)
elif response.status_code >= 500:
# Server error — retry with exponential backoff
raise APIServerError(f"Server error {response.status_code}")Retry Logic and Exponential Backoff
For transient errors (network issues, 5xx, 429), retry with exponential backoff.
import time, random
def api_call_with_retry(url, headers, max_retries=3):
for attempt in range(max_retries):
try:
response = requests.get(url, headers=headers, timeout=30)
if response.status_code == 429:
wait = int(response.headers.get('Retry-After', 2 ** attempt))
time.sleep(wait)
continue
if response.status_code >= 500 and attempt < max_retries - 1:
wait = (2 ** attempt) + random.uniform(0, 1) # jitter
time.sleep(wait)
continue
return response
except requests.exceptions.Timeout:
if attempt < max_retries - 1:
time.sleep(2 ** attempt)
else:
raise
raise Exception("Max retries exceeded")Rate Limiting and Quotas
Every public API has rate limits. Code that ignores them works in development, then breaks in production in ways that are hard to debug.
Types of Rate Limits
- Requests per time window — most common. Per second (burst protection), per minute/hour/day (usage quotas)
- Concurrent requests — maximum simultaneous in-flight requests
- Data volume — bytes per time period. Common for upload/download APIs
- Cost-based — each operation has a "cost" (tokens for LLMs, compute units). Limited by total cost, not request count
When Remaining is low, slow down proactively. Don't wait for a 429. When you do get a 429, respect the Retry-After header exactly. Don't retry in half the time. Don't retry in a tight loop.
Versioning and Breaking Changes
Version your API before you need to. It's much easier to establish versioning upfront than to add it when you need a breaking change.
What Is a Breaking Change?
Breaking (causes existing clients to stop working):
- Removing or renaming a field from a response
- Changing a field's type (string to integer)
- Changing a status code or removing an endpoint
- Changing the meaning of a parameter
Non-breaking (safe to deploy without a version bump):
- Adding new fields to responses
- Adding new endpoints or optional request parameters
- Adding new enum values (if clients handle unknown values)
Versioning Strategies
URL versioning (most common): /v1/users, /v2/users. Simple, explicit, visible in logs.
Header versioning: API-Version: 2024-10-01. Cleaner URLs, harder to test in a browser.
Date-based versioning (Stripe's approach): API version is a date. Each client's calls are processed against the semantics that existed on that date. New accounts use the latest version; existing integrations stay pinned.
Managing the Transition
- Run old and new versions in parallel
- Document what changed and provide a migration guide
- Set a deprecation timeline with enough lead time
- Use a
Sunsetheader to notify clients via the response - Hold to the sunset date — APIs that say "deprecated" and live forever train developers to ignore deprecation warnings
Webhooks — When the Server Calls You
All the patterns above are pull-based: your client calls the server. Webhooks invert this: the server calls you when something happens.
Instead of polling an API every minute to check if an order was paid, you register a URL. When an order is paid, they POST to your URL with the event details.
1. Register: POST /webhooks { "url": "https://yoursite.com/hooks/stripe", "events": ["payment.completed"] } 2. Event happens (payment completes) 3. Stripe POSTs: { "event": "payment.completed", "data": { "order_id": "ord_123" } } 4. You return 200 to acknowledge receipt 5. If non-200, Stripe retries with exponential backoff
Verifying Webhook Signatures
Anyone on the internet can POST to your webhook URL. You must verify that incoming webhooks are actually from the provider. The standard: the provider signs the payload with a secret key and includes the signature in a header.
Handling Delivery
Webhooks are delivered at-least-once, meaning the same event might be delivered more than once. Your handler must be idempotent — use the event's ID as an idempotency key. If you've already processed it, skip it.
GraphQL — Client-Specified Queries
GraphQL addresses REST's biggest pain points: over-fetching (getting more data than you need) and under-fetching (needing multiple requests).
In GraphQL, there's one endpoint. Clients send a query describing exactly what they want:
query {
user(id: "42") {
name
email
orders(status: "pending") {
id
total
items {
product { name }
quantity
}
}
}
}What would require 3 REST calls (get user, get orders, get order items) can be one GraphQL query.
When GraphQL makes sense: complex data requirements with many relationships, multiple client types with different data needs, rapidly evolving frontends.
When REST makes more sense: public APIs where simplicity matters, simple CRUD, when you need HTTP caching, small teams.
gRPC & Real-Time Protocols
gRPC — High-Performance Service-to-Service
gRPC uses Protocol Buffers (binary format) over HTTP/2. Significantly faster than REST+JSON for high-volume internal traffic. The schema is defined first in .proto files, and client libraries are generated in whatever languages you need.
service UserService {
rpc GetUser (GetUserRequest) returns (User);
rpc ListUsers (ListUsersRequest) returns (ListUsersResponse);
}
message User {
string id = 1;
string name = 2;
string email = 3;
}Use for: internal microservices, high-frequency calls, streaming scenarios. Don't use for: public APIs (not browser-native), when simplicity matters more than performance.
WebSockets and Server-Sent Events
WebSockets — full-duplex persistent connection. Both sides can send messages at any time. Good for chat, collaborative editing, live games.
Server-Sent Events (SSE) — one-way server-to-client. Simpler, built on HTTP, automatically reconnects. Good for live feeds, notifications, and streaming AI responses:
# Streaming SSE response from AI API
with httpx.stream("POST", url, json=data, headers=headers) as response:
for line in response.iter_lines():
if line.startswith("data: "):
chunk = json.loads(line[6:])
print(chunk["text"], end="", flush=True)MCP — The Protocol for AI Tools
Model Context Protocol (MCP) is an open standard for connecting AI models to tools and data sources. Where REST APIs connect software systems, MCP connects AI models to capabilities.
An MCP server exposes tools (functions the AI can call), resources (data the AI can read), and prompts (templates). An MCP client (like Claude Code) connects to these servers and the AI model can invoke them.
Client (Claude Code) → MCP Server (GitHub) → GitHub API Client (Claude Code) → MCP Server (Filesystem) → Local files Client (Claude Code) → MCP Server (Slack) → Slack API
Consuming APIs Well
Building an integration that works in a demo is easy. Building one that works reliably in production for months is harder.
Build a Client, Not Ad-Hoc Requests
Create a client class rather than making raw HTTP calls scattered throughout your code:
class StripeClient:
def __init__(self, api_key, base_url="https://api.stripe.com/v1"):
self.api_key = api_key
self.session = requests.Session()
self.session.headers.update({
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
})
def create_payment_intent(self, amount, currency, metadata=None):
response = self.session.post(
f"{self.base_url}/payment_intents",
json={"amount": amount, "currency": currency,
"metadata": metadata or {}},
timeout=30
)
response.raise_for_status()
return response.json()Benefits: authentication configured once, retry logic centralized, error handling consistent, testing easier (mock the client, not HTTP), logging/metrics in one place.
Always Set Timeouts
# Always specify connect timeout and read timeout separately response = requests.get(url, timeout=(5, 30)) # 5s connect, 30s read
Without a timeout, a slow server will hold your connection open indefinitely, eventually hanging your application.
Log Usefully
# Good: log operation, duration, status, request ID
logger.info("api.call", extra={
"method": "POST",
"url": "/v1/payment_intents",
"status": response.status_code,
"duration_ms": int(elapsed * 1000),
"request_id": response.headers.get("X-Request-Id")
})Authorization headers, request bodies that might contain passwords or tokens, or response bodies that might contain PII.Test Against Real APIs
Use the API's sandbox/test environment, not mocks, for integration testing. Mocks test your assumptions. The real sandbox tests that your assumptions are correct.
Building APIs — Design Principles
The choices you make early determine how easy it is to evolve later.
Design for the developer using it. The primary user is a developer, often working at 11pm against a deadline. Consistent naming, predictable errors, clear documentation with working examples, and a quick start in under five minutes.
Don't design from your database. Your internal data model and your API design are different things. Design resources from the client's perspective — what does a user of the API want to do?
Plan for breaking changes from day one. Version your API from the start, document what constitutes a breaking change, have a deprecation process, test backward compatibility with automated checks.
Document Thoroughly
Documentation is part of the product. The minimum:
- Every endpoint: what it does, all parameters, all response fields, all status codes
- Authentication: exactly how to authenticate, where to get credentials
- Error codes: what each means and how to handle it
- Rate limits: what they are and what to do when you hit them
- Changelog: what changed and when, breaking vs. non-breaking
APIs and AI
AI has changed API development in several specific ways, both as a consumer and as a builder.
What's Different About AI APIs
Latency is high and variable. An LLM API that generates a response might take 2–30 seconds. This changes UX design (streaming becomes important), timeout settings, and retry strategy. For a deeper look at token economics, AI latency patterns, and optimization techniques, see the Performance field guide.
Costs are response-dependent. LLM APIs charge by tokens — input plus output. Monitor token usage, set max_tokens to cap output, log token counts, build cost awareness into billing logic.
Outputs are non-deterministic. The same input can produce different outputs. Testing requires checking that outputs are within acceptable bounds, not exact equality.
Streaming changes the pattern. For user-facing applications, always use streaming — waiting for the full response creates a worse experience than progressive display.
# Streaming Anthropic API call
import anthropic
client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
with client.messages.stream(
model="claude-opus-4-5",
max_tokens=1024,
messages=[{"role": "user", "content": "Explain how APIs work"}]
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)AI-Assisted API Development
Specific uses where AI tools add real value:
- Writing client code — "Here's the API docs. Write a Python function that calls it, handles errors, and retries on 5xx."
- Understanding error responses — paste an unexpected error and ask what it means
- Generating test cases — happy path, validation errors, not found, rate limiting
- Writing OpenAPI specs — describe your API in natural language, get a spec draft
- Reviewing your design — ask for feedback on naming, missing error cases, security concerns
Building APIs for AI Agents
If you're building APIs that AI agents will consume:
- Structured errors matter even more — agents need machine-readable codes to take the right action
- Idempotency keys are essential — agents may retry when uncertain if they succeeded
- Consider what actions are reversible — an agent that calls DELETE without confirmation can't undo it
- Consider building an MCP server alongside or instead of a REST API for AI-native integrations
Quick Reference
HTTP Methods
| Method | Action | Idempotent? | Safe? |
|---|---|---|---|
| GET | Retrieve | Yes | Yes |
| POST | Create / Submit | No | No |
| PUT | Replace | Yes | No |
| PATCH | Partial update | Mostly | No |
| DELETE | Remove | Yes | No |
Status Codes
| Code | Meaning | Use when |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH |
| 201 | Created | Successful POST |
| 204 | No Content | Successful DELETE, no body |
| 400 | Bad Request | Validation failure, malformed request |
| 401 | Unauthorized | Missing or invalid credentials |
| 403 | Forbidden | Authenticated but not allowed |
| 404 | Not Found | Resource doesn't exist |
| 409 | Conflict | Uniqueness violation, state conflict |
| 422 | Unprocessable | Valid syntax, invalid semantics |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Server Error | Something unexpected went wrong |
| 503 | Service Unavailable | Temporarily down |
Auth Methods
| Method | Use for | Expiry | Revocable |
|---|---|---|---|
| API Key | Server-to-server, simple integrations | No (rotate manually) | Yes (delete key) |
| JWT Bearer | User sessions, stateless auth | Yes (short-lived) | Hard (need blacklist) |
| OAuth 2.0 | User-delegated access, third-party | Yes | Yes (at auth server) |
Design Checklist (Building)
Nouns in URLs, verbs in HTTP methods
Consistent field naming throughout
ISO 8601 for all timestamps
Structured error responses with machine-readable codes
Pagination on all collection endpoints
Version from day one
Request ID in every response
Rate limit headers exposed
Webhook signature verification
OpenAPI spec maintained alongside code
Integration Checklist (Consuming)
Timeouts on every call
Retry with exponential backoff and jitter for 5xx and 429
Never retry 4xx (except 429)
Read and respect rate limit headers
Verify webhook signatures before processing
Idempotency keys for non-idempotent operations
Log request IDs with every error
Use sandbox environment for integration testing
Client class, not scattered ad-hoc calls