Web APIs
API Case Study
Dissect how Stripe's API is architected — versioning, error design, idempotency, and the decisions that made it the most copied API in the industry.
Stripe processes hundreds of billions of dollars a year through a handful of HTTP endpoints. Their engineering team has been refining the same API surface since 2010 — and in that time they have made decisions that the rest of the industry eventually copied. When you look at how GitHub, Twilio, or Shopify design their APIs today, you are looking at an API that learned from Stripe.
This case study is not a Stripe tutorial. It is an analysis. You will pull apart six specific design decisions — versioning strategy, error structure, idempotency keys, pagination, webhook design, and rate limiting — and understand the reasoning behind each one. Then you apply each pattern to the APIForge platform to see what it looks like in practice.
By the end, you will have a checklist of production API patterns that most developers only discover after shipping something broken to production first.
Pattern 1 — API Versioning That Does Not Break Clients
Most APIs version with a URL prefix: /v1/charges, /v2/charges. Stripe does something more sophisticated. Every account is pinned to a specific API version at the time they first integrated — their requests always hit the behaviour that was live on that exact date, even if Stripe ships twenty changes after that.
The version is a date string sent in a header: Stripe-Version: 2024-06-20. Stripe maintains compatibility shims for every version going back years. An integration built in 2018 still works today without a single code change on the client side.
/v1/ URL in their codebase needs updating before they can use any new feature. Stripe's date-pinned versioning lets clients opt into new behaviour when they are ready, not when you force them. The cost is that Stripe must maintain and test every historical version — but for a payments API where a broken integration means lost revenue, that cost is worth it.The APIForge Product team applies a simplified version of this: each API key is registered against a version at creation time. The version is also accepted as a header so developers can test against a newer version before upgrading their key.
# WHAT: APIForge version negotiation — header-based versioning
# Request with explicit version header (testing a newer version)
# Without the header, the server falls back to the version pinned to the API key
GET /api/projects HTTP/1.1
Host: api.apiforge.dev
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
APIForge-Version: 2024-09-01
# Server reads version priority:
# 1. APIForge-Version header (if present)
# 2. Version pinned to the API key at creation time
# 3. Fallback: current stable versionThe response echoes back APIForge-Version in the headers so the client knows exactly which version served the response — useful for debugging mismatches. The X-API-Version-Deprecated header is how the server signals that a version is ageing out before it removes it — clients see this in their logs weeks before a breaking change happens.
Notice the response shape: every object has an "object" field. Stripe adds this to every resource — "object": "customer", "object": "charge". It makes the JSON self-describing — you can identify what type of resource you are looking at without checking which endpoint returned it.
Pattern 2 — Error Objects That Tell You Exactly What to Do
Most APIs return errors like this: { "error": "invalid_request" }. That tells you something went wrong. It does not tell you what field caused it, why it failed, or what to do next. A developer hitting that response has to guess, read docs, or email support.
Stripe's error objects carry five distinct fields: a machine-readable type for programmatic handling, a human-readable message safe to display to users, a code for documentation lookup, a param that names the exact field that caused the error, and a doc_url linking directly to the relevant docs page.
| Error Field | Purpose | Example value |
|---|---|---|
| type | Machine-readable error category for programmatic branching | invalid_request_error |
| message | Human-readable description safe to surface to end users | "No such customer: cus_xyz" |
| code | Specific error identifier for documentation and support lookup | resource_missing |
| param | The exact request field that caused the error | customer |
| doc_url | Direct link to the relevant docs page for this specific error | "https://stripe.com/docs/error-codes/resource-missing" |
// WHAT: APIForge error response builder — Stripe-style structured errors
// File: src/utils/apiError.js
// Every error in the API goes through this builder for consistency
function buildError({ type, message, code, param = null, statusCode }) {
return {
statusCode,
body: {
error: {
type,
message,
code,
param,
doc_url: `https://docs.apiforge.dev/errors/${code}`,
request_id: generateRequestId(), // unique ID per request for support lookups
}
}
};
}
// Usage examples across the API:
// Missing required field
buildError({
type: 'invalid_request_error',
message: 'The project name is required and cannot be blank.',
code: 'parameter_missing',
param: 'name',
statusCode: 400,
});
// Resource not found
buildError({
type: 'invalid_request_error',
message: 'No such project: proj_xyz99',
code: 'resource_missing',
param: 'id',
statusCode: 404,
});
// Authentication failure
buildError({
type: 'authentication_error',
message: 'No valid API key provided.',
code: 'api_key_missing',
param: null,
statusCode: 401,
});The request_id is the most underrated field in this response. When a developer emails support saying "something broke at 14:32", your support team can search logs for that exact request ID and see every log line associated with it — the full stack trace, the exact payload, the database queries, all of it. Without request IDs, debugging production issues is archaeology.
The param field is the one developers most appreciate. Instead of reading through the whole error message to figure out which field is wrong, a client application can read error.param and highlight that specific field in the UI.
Pattern 3 — Idempotency Keys: Safely Retrying Failed Requests
Networks fail. A client sends a payment request, the network drops before the response arrives, and the client does not know if the charge went through. If it retries — it might charge the customer twice. If it does not retry — the payment might have failed and the customer gets nothing. This is the retry dilemma, and Stripe solved it with idempotency keys.
An idempotency key is a unique string the client generates and sends in a header with every mutating request. Stripe stores the key and the response. If the same key arrives again within 24 hours — regardless of whether the first attempt succeeded or failed — Stripe returns the stored response without processing the request a second time. The client can retry as many times as it wants: the operation runs exactly once.
// WHAT: APIForge idempotency key implementation
// File: src/middleware/idempotency.js
// Stores request results in Redis keyed by Idempotency-Key header
// Applies to POST and PATCH routes only — GET and DELETE are already idempotent
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
const TTL = 60 * 60 * 24; // 24 hours — same as Stripe
export async function idempotency(req, res, next) {
// Only applies to state-changing methods
if (!['POST', 'PATCH'].includes(req.method)) return next();
const key = req.headers['idempotency-key'];
if (!key) return next(); // Key is optional — no key means no protection
const cacheKey = `idempotency:${req.user.userId}:${key}`;
// Check if we have a stored response for this key
const cached = await redis.get(cacheKey);
if (cached) {
const { statusCode, body } = JSON.parse(cached);
res.setHeader('Idempotency-Replayed', 'true');
return res.status(statusCode).json(body);
}
// Intercept the response to store it before sending
const originalJson = res.json.bind(res);
res.json = async (body) => {
// Only cache successful responses and known errors (not 5xx)
if (res.statusCode < 500) {
await redis.setEx(
cacheKey,
TTL,
JSON.stringify({ statusCode: res.statusCode, body })
);
}
return originalJson(body);
};
next();
}The Idempotency-Replayed: true header on the retry response tells the client this is a stored result, not a fresh operation. A well-built client library reads this header and does not trigger any "success" side effects a second time — no confirmation emails, no analytics events, no toast notifications that fire twice.
The key is scoped to the user ID in Redis: idempotency:{userId}:{key}. Two different users could send the same key string without colliding. This is important — if you scope only by key, a malicious client could potentially replay another user's cached response.
Pattern 4 — Cursor Pagination Over Offset Pagination
Most developers learn pagination with ?page=2&limit=20 — offset pagination. It is simple to implement and simple to understand. Stripe does not use it. They use cursor-based pagination, and once you understand why, you will not go back to offsets for any high-volume API.
Offset pagination has a fatal flaw: if records are inserted or deleted between page requests, results shift. A user paginating through a list of 100 charges where new charges keep arriving will see the same charge on two different pages, or miss charges entirely. At 1,000 items per second — the scale Stripe operates at — offset pagination produces meaningless results.
LIMIT 20 OFFSET 40 in SQL. Simple to implement. Breaks when data is inserted or deleted mid-pagination. Gets slower as offset grows — a database scanning 1M rows to skip the first 999,980 is doing enormous work.WHERE id < 'last_seen_id' LIMIT 20 — always indexed, always fast. Stable under concurrent inserts and deletes. Cannot jump to a specific page, but that is rarely a real user need.# WHAT: APIForge cursor pagination — Stripe-style response envelope
# Client fetches the first page, then uses starting_after to get the next
# Page 1 — no cursor
GET /api/projects?limit=3 HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
# Page 2 — use last ID from page 1 as cursor
GET /api/projects?limit=3&starting_after=proj_003 HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...The response includes one more item than the requested limit internally — if limit=3 was sent, the database query runs LIMIT 4. If 4 rows come back, there is more data and has_more is set to true (the 4th row is stripped from the response). If only 3 or fewer rows come back, has_more is false. This avoids an extra COUNT(*) query.
has_more: false is a precise termination signal — the client knows exactly when to stop paginating without sending an extra empty-result request. Offset pagination requires that extra request to discover the list has ended.
WHERE id < $cursor ORDER BY id DESC LIMIT $limit + 1. That single query replaces offset pagination entirely.
Pattern 5 — Webhooks That Cannot Be Faked
Polling is the naive answer to "how does my server know when something changes?" — call the API every few seconds and check. Webhooks are the correct answer: the API calls you when something changes. But webhooks introduce a security problem. Anyone can POST to your webhook endpoint pretending to be Stripe. How does your server know a webhook is genuine?
Stripe signs every webhook payload with a shared secret using HMAC-SHA256. They include the signature and a timestamp in the Stripe-Signature header. Your server recomputes the HMAC using the same secret and compares. If they match — the payload is genuine. If they do not — it is rejected immediately. The timestamp prevents replay attacks: a valid signature on a payload from 10 minutes ago is rejected.
// WHAT: APIForge webhook signature verification — Stripe pattern
// File: src/routes/webhooks.js
// Every incoming webhook is verified before any handler runs
import crypto from 'crypto';
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET; // shared with webhook sender
const TOLERANCE_SECS = 300; // reject webhooks older than 5 minutes
function verifyWebhookSignature(payload, signatureHeader) {
// Stripe-Signature header format:
// t=1731580800,v1=abc123def456...
const parts = Object.fromEntries(
signatureHeader.split(',').map(p => p.split('='))
);
const timestamp = parseInt(parts['t'], 10);
const signature = parts['v1'];
// Reject stale webhooks (replay attack protection)
const now = Math.floor(Date.now() / 1000);
if (now - timestamp > TOLERANCE_SECS) {
throw new Error('Webhook timestamp too old — possible replay attack');
}
// Recompute HMAC-SHA256 using raw body + timestamp
const signedPayload = `${timestamp}.${payload}`;
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signedPayload)
.digest('hex');
if (expected !== signature) {
throw new Error('Webhook signature mismatch — payload may be tampered');
}
return true;
}
// Express route — use express.raw() NOT express.json() to get raw body for HMAC
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
try {
verifyWebhookSignature(req.body.toString(), req.headers['apiforge-signature']);
} catch (err) {
return res.status(400).json({ error: err.message });
}
const event = JSON.parse(req.body);
switch (event.type) {
case 'project.created': handleProjectCreated(event.data); break;
case 'member.deleted': handleMemberDeleted(event.data); break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
res.status(200).json({ received: true }); // Always respond 200 quickly
});The route uses express.raw() instead of express.json() — this is not optional. The HMAC is computed over the raw bytes of the request body. If Express parses the JSON first, the body object's string representation may differ slightly from the raw bytes, causing the signature check to fail on every valid request.
The 200 response is sent immediately after verification and dispatch — before the handler finishes processing. Stripe requires this. If your handler takes 10 seconds and returns a 200 after, Stripe assumes the webhook failed (timeout) and retries. Always acknowledge receipt immediately, then process asynchronously.
Try this: Test your webhook handler by sending a request with a deliberately wrong signature and confirm it returns 400. Then send one with a timestamp 6 minutes in the past and confirm the replay protection fires.Pattern 6 — Rate Limit Headers Clients Can Actually Use
A 429 response is not helpful on its own. It tells the client they have been blocked but not when they can try again, how many requests they have left before the next block, or how the limit is structured. A client that only sees 429 responses has to guess at backoff timing — and guessing wrong makes the situation worse.
The correct pattern is to include rate limit information in every response — not just the 429. A well-behaved client reads these headers on every successful response and backs off proactively before hitting the limit, rather than reactively after getting blocked.
// WHAT: APIForge rate limit headers — included on every response, not just 429
// File: src/middleware/rateLimitHeaders.js
// Clients use these headers to self-throttle before hitting the hard limit
// Headers sent on EVERY response:
// X-RateLimit-Limit: total requests allowed per window
// X-RateLimit-Remaining: requests left in current window
// X-RateLimit-Reset: unix timestamp when the window resets
// X-RateLimit-Window: window duration in seconds
// Header sent ONLY on 429 responses:
// Retry-After: seconds to wait before retrying (RFC 7231 standard)
// Example: client reads headers and self-throttles
async function apiFetch(url, options = {}) {
const response = await fetch(url, options);
const remaining = parseInt(response.headers.get('X-RateLimit-Remaining'));
const resetAt = parseInt(response.headers.get('X-RateLimit-Reset'));
const now = Math.floor(Date.now() / 1000);
if (remaining < 10) {
// Fewer than 10 requests left — slow down proactively
const waitMs = (resetAt - now) * 1000;
console.warn(`Rate limit low (${remaining} left). Pausing ${waitMs}ms.`);
await new Promise(resolve => setTimeout(resolve, waitMs));
}
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get('Retry-After') || '60');
console.error(`Rate limited. Retrying in ${retryAfter}s.`);
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
return apiFetch(url, options); // retry once after waiting
}
return response;
}The client function reads X-RateLimit-Remaining on every response and pauses before it runs out. This is cooperative rate limiting — the API and the client work together so hard blocks rarely happen. The alternative is a client that hammers the API until blocked, then waits and hammers again. That wastes quota and produces worse throughput than self-throttling.
Retry-After is an RFC 7231 standard header — any HTTP client or library that respects the standard will handle it automatically. It is the difference between a client that retries immediately and hammers you 50 more times, and a client that waits the right amount of time before one clean retry.
X-RateLimit-Remaining to your API's success responses today. It costs one Redis read per request — which you are already doing for rate limiting — and the value to well-behaved clients is significant.
Before and After: Generic API vs Production-Grade API
Apply all six patterns and the gap between a first-draft API and a production-grade one becomes visible across every dimension — developer experience, reliability, security, and debuggability.
Six Patterns — One Checklist
These six patterns are not unique to Stripe. GitHub, Twilio, Shopify, and HubSpot all implement variations of the same ideas. What Stripe did was implement them all consistently, document them thoroughly, and ship them early enough that the industry adopted their conventions as defaults.
| Pattern | Problem It Solves | Implementation Complexity |
|---|---|---|
| Date-pinned versioning | Breaking changes force client migrations on your schedule, not theirs | High — version shim layer needed |
| Structured error objects | Vague errors waste developer time and generate support tickets | Low — one shared error builder function |
| Idempotency keys | Network failures create duplicate records on retry | Medium — Redis middleware + response caching |
| Cursor pagination | Offset pagination skips or duplicates rows under concurrent writes | Low — SQL WHERE + LIMIT change only |
| Signed webhooks | Unauthenticated webhook endpoints can be triggered by anyone | Low — one HMAC verification function |
| Rate limit headers | Clients have no signal to self-throttle, causing unnecessary 429 storms | Low — headers on existing rate limiter responses |
Quiz
1. A user clicks "Pay" on the APIForge billing page, the first charge succeeds, and they click "Pay" again for a second separate purchase. What should the client do with the idempotency key?
2. The APIForge analytics endpoint returns 50,000 event records. A developer suggests using offset pagination with page and limit parameters. What is the key problem with this approach at scale?
3. The APIForge webhook route uses express.raw() instead of express.json() to parse the request body before verifying the HMAC signature. Why is this the correct choice?