TL;DR β The One-Minute Version
Here's REST in plain English. You're building an APIApplication Programming Interface β a way for programs to talk to each other. When a mobile app fetches your profile from Instagram, it's calling Instagram's API. Think of it as a waiter taking your order to the kitchen and bringing back food. β a way for other programs to talk to your system. Instead of inventing a brand-new command for every action (getUser, fetchAllOrders, createNewProduct, removeItemFromCart), you organize everything around resources β the "things" in your system. Users, orders, products, carts. Each thing gets a URL. And the action you want to take? That's just the HTTP methodThe verb in an HTTP request β GET (read), POST (create), PUT (replace), PATCH (update partially), DELETE (remove). Think of them as the 5 things you can do to any resource. β GET to read, POST to create, PUT to replace, PATCH to tweak, DELETE to remove.
Don't take our word for it. Open a terminal right now and try this:
# This is a REAL API call to GitHub. Try it.
curl https://api.github.com/users/torvalds
# What you get back:
# {
# "login": "torvalds",
# "id": 1024025,
# "name": "Linus Torvalds",
# "company": "Linux Foundation",
# "public_repos": 7,
# "followers": 221000,
# ...
# }
# That's REST. The URL (/users/torvalds) is the NOUN.
# The HTTP method (GET) is the VERB.
# The response is JSON β structured data.
That's a real REST API running in production right now. GitHub serves billions of these requests every day. The URL /users/torvalds is the resource (the noun β "Linus Torvalds's profile"). The HTTP method GET is the action (the verb β "read it"). The response is JSONJavaScript Object Notation β a lightweight format for sending data between systems. It looks like: {"name": "Linus", "repos": 7}. Nearly every REST API uses JSON because it's human-readable and easy for code to parse. β structured data your code can parse.
Once you learn this pattern, you can use any REST API β Stripe, GitHub, Twilio, Twitter β without reading much documentation, because they all speak the same language. The URLUniform Resource Locator β the address of a resource on the internet. In REST, the URL tells you WHAT thing you're working with (/users/42 = user #42). The HTTP method tells you WHAT you're doing to it (GET = read it, DELETE = remove it). tells you what you're talking about, and the method tells you what you want to do.
Why You Need This β The API That Nobody Can Use
You join a startup. The product is growing fast. There's a backend with 47 API endpoints. Your first task: build a new feature in the mobile app that shows order history. You ask the backend team, "What's the endpoint for getting a user's orders?"
Silence. Then: "Check Slack, someone posted it once." You dig through Slack messages and piece together a few endpoints. They look like this:
# User endpoints β different naming styles
POST /getUserProfile β RPC-style, verb in URL
GET /api/v2/fetch-orders?userId=5&sortBy=date β mixed conventions
POST /deleteAccount β POST for deletion??
GET /api/getProductsByCategory?cat=electronics&page=2&limit=50
# Cart endpoints β even the team doesn't agree
POST /addToCart β no resource noun
POST /cart/remove-item β kebab-case here
GET /getCartItems?userId=7 β camelCase there
# Order endpoints β chaos
POST /submitOrder
GET /getOrderStatus?orderId=99
POST /order/cancel β completely different structure
A new developer joins and asks: "Where's the documentation?" There is none. "OK, how do I update a user?" Nobody knows if it's PUT or POST or PATCH. Three different people wrote these endpoints over 18 months, and each one followed their own convention. The API isn't wrong β it works. But nobody can use it without asking someone.
Mobile developer: "Why is 'delete account' a POST? I assumed POST means create. And /cart/remove-item is also POST? How do I know which POSTs create things and which ones destroy things?"
Partner integration team: "Is it getUserProfile or fetchUser or user.info? Every endpoint uses a different naming convention. I've been guessing for three days."
New hire (week 1): "There are 47 endpoints. I have to memorize each one? There's no pattern I can learn once and apply everywhere?"
QA engineer: "I put a CDNContent Delivery Network β servers spread around the world that cache responses close to users. A CDN automatically caches GET requests (because GET means 'just reading, safe to store'). But it won't cache POST requests (because POST means 'doing something'). If your API uses POST for everything, the CDN can't help you. in front of the API. It cached nothing because everything is POST. Our server costs are 10x what they should be."
A study at a large fintech company found their developers spent 30% of API integration time just figuring out endpoint names, parameter formats, and which HTTP method to use. That's almost a third of your engineering time burned on guessing games.
The real cost isn't the confusion β it's the compounding effect. Every new hire goes through the same discovery process. Every partner integration requires hand-holding. Every new feature requires reading source code instead of guessing the URL pattern. The API becomes a tax on everyone who touches it.
Slack's real story: Slack's API is RPC-style β chat.postMessage, channels.list, users.info. It works, but developers can't guess endpoint names. You must read the docs for every call. Compare this to GitHub's API, where once you learn GET /repos/{owner}/{repo}, you can guess that GET /repos/{owner}/{repo}/pulls exists β and you'd be right.
Look at the 47-endpoint API above. Can you spot at least 4 inconsistencies? Think about naming, HTTP methods, URL structure, and parameter style. Write down your list before scrolling down β you'll see every one of these problems dissected in the next two sections.
The First Attempt β "Just Name Each Action" (RPC-Style APIs)
The messy API in Section 2 isn't random chaos. It's actually following a very common approach called RPC-styleRemote Procedure Call β an API style where each endpoint is a function name (verb + noun), like calling a method on a remote server. Examples: /getUser, /createOrder, /deleteItem. The URL IS the function name.. The idea is dead simple: every endpoint is a function call. You name the URL after what it does, exactly like you'd name a function in your code.
This isn't some outdated idea that nobody uses. Slack's entire API works this way right now. Open their API docs and you'll see real endpoints like these:
# Slack's actual API endpoints (still in use today)
POST https://slack.com/api/chat.postMessage
POST https://slack.com/api/channels.list
POST https://slack.com/api/users.info
POST https://slack.com/api/files.upload
POST https://slack.com/api/reactions.add
POST https://slack.com/api/conversations.history
# Notice: EVERYTHING is POST.
# The URL IS the function name.
# The action is baked into the URL path.
And honestly? For small projects, this works fine. If you have 5 endpoints, naming them /getUser, /createUser, /deleteUser, /getOrders, /createOrder is easy enough. Everyone on the team knows what each one does. Slack has hundreds of millions of users and their RPC-style API handles it just fine.
The problem isn't that RPC is "wrong." Slack proves it works at massive scale. The problem is it doesn't scale as a convention. With 5 endpoints, you can memorize them. With 50, you need documentation for every single one. With 200, even the people who built the API can't remember all the names. There's no pattern to learn β every endpoint is a snowflake.
Here's the real kicker: can you guess what Slack's endpoint for editing a message is? Is it chat.editMessage? Or chat.update? Or messages.edit? (It's actually chat.update β you'd never guess that without reading the docs.) Now compare that to GitHub's REST API: if you know GET /repos/{owner}/{repo} fetches a repo, can you guess how to get its pull requests? GET /repos/{owner}/{repo}/pulls. And you'd be right. That's the difference between a convention you have to memorize vs. a convention you can predict.
Where It Breaks β Three Problems That Cost Real Money
RPC-style APIs don't fail in theory. They fail in practice, at scale, with real money on the line. Here are three specific problems β each one illustrated with a real company that hit it.
If every endpoint in your API uses POST (like Slack's does), what happens when you put a CDNContent Delivery Network β servers around the world that store copies of frequently-requested data close to users. CDNs only cache GET requests automatically, because GET means "just reading, safe to store a copy." POST means "doing something" so CDNs don't cache it. in front of it? What about browser caching? Think about it before reading the numbers below.
Problem 1: You Can't Cache Anything
HTTP cachingThe mechanism where browsers, CDNs, and proxy servers store copies of responses so they don't have to ask the server again. It only works on GET requests β because GET means "I'm just reading, the data won't change." POST means "I'm doing something," so caches never store POST responses. is one of the most powerful performance tools on the internet. Every browser, every CDN, every proxy server already knows how to cache. But they only cache GET requests β because GET means "I'm just reading, this is safe to store." POST means "I'm changing something, don't store this."
If your API uses POST for everything (like Slack and many RPC APIs do), you've thrown away all of that free caching infrastructure. Let's do the math on what that actually costs:
This isn't hypothetical. When you use GET /users/42, the CDN can cache that response. The next 1,000 people asking for user #42 get the answer from the CDN β your server never even sees the request. But POST /getUser? The CDN won't touch it. Every single request hits your server. At scale, this is the difference between a $5K/month server bill and a $50K/month one.
GitHub's API makes this concrete. Try this in your terminal:
# HEAD request β just the headers, no body
curl -I https://api.github.com/users/torvalds
# Response headers (the ones that matter):
# HTTP/2 200
# cache-control: public, max-age=60, s-maxage=60
# etag: "abc123..."
# last-modified: Mon, 15 Jan 2024 10:30:00 GMT
# x-ratelimit-limit: 60
# x-ratelimit-remaining: 57
# See those? cache-control, etag, last-modified.
# These tell CDNs and browsers: "Cache this for 60 seconds.
# After that, check if it changed using the ETag."
# This ONLY works because it's a GET request.
Problem 2: Nobody Agrees on Error Handling
Here's something that actually happened with the early Twitter API. You'd make a request that failed β maybe the tweet didn't exist, or you hit the rate limit. The API would respond with HTTP 200 OK (which means "everything is fine!") and then put the error inside the JSON body. Your code had to parse the body to find out the request actually failed.
This wasn't unique to Twitter. It's a common RPC-style pattern because RPC treats every endpoint as a function call, and function calls return results β including error results. The HTTP status code is just a transport detail, not a semantic signal.
// HTTP 200 OK β says "everything is fine!"
// But wait...
{
"ok": false,
"error": "channel_not_found",
"response_metadata": {
"scopes": ["chat:write"]
}
}
// The HTTP status says success.
// The body says failure.
// Your code has to parse the body to know what happened.
// Every error check looks like: if (!response.ok) ...
// HTTP 404 Not Found β tells you immediately
{
"message": "Not Found",
"documentation_url": "https://docs.github.com/..."
}
// HTTP 403 Forbidden β rate limited
{
"message": "API rate limit exceeded for 1.2.3.4",
"documentation_url": "https://docs.github.com/..."
}
// HTTP 422 Unprocessable Entity β bad input
{
"message": "Validation Failed",
"errors": [{"field": "title", "code": "missing"}]
}
// No ambiguity. The status code tells you what happened
// BEFORE you even parse the body.
// HTTP 402 Payment Required β card was declined
{
"error": {
"type": "card_error",
"code": "card_declined",
"decline_code": "insufficient_funds",
"message": "Your card has insufficient funds.",
"param": "source"
}
}
// The status code (402) tells middleware & monitoring
// "this is a payment problem, not a server bug."
// The body tells the developer exactly what went wrong.
// Both layers carry meaning.
Why does this matter? Because HTTP status codesThree-digit numbers that tell the client what happened. 2xx = success. 3xx = redirect. 4xx = client made a mistake (bad request, not found, unauthorized). 5xx = server had an error. Every piece of internet infrastructure β browsers, load balancers, monitoring tools β understands these codes natively. aren't just numbers for developers to read. They're signals that the entire internet understands. Your monitoring system can alert on 5xx spikes. Your load balancer can retry 503s but not 400s. Your browser shows a "page not found" error for 404s. When you return 200 for everything, all of that tooling goes blind.
Problem 3: Endpoint Explosion
Let's count. Your startup has 8 types of things (resources): users, products, orders, carts, reviews, payments, shipments, and coupons. In RPC-style, each thing needs its own set of endpoints:
The numbers look similar (47 vs 40), but the difference is cognitive, not mathematical. With RPC, you need to memorize 47 unique endpoint names. With REST, you learn 8 resource names and 5 verbs β and you can predict every combination. That's 13 things to learn instead of 47. And when someone adds a 9th resource (say, /notifications), you already know all its endpoints without being told.
/me/friends, /me/photos) but is actually a graph query languageInstead of standard REST resources, Facebook treats everything as a "node" with "edges." You can traverse relationships: /me/friends/photos gives you photos of your friends. It's more like a database query than a REST endpoint, which is why Facebook eventually created GraphQL as a separate query language.. You can write /me/friends?fields=name,email&limit=10 β essentially querying a graph database through URLs. It works, but it's not REST. This confusion about "what counts as REST" is exactly why understanding the actual principles matters.
The Breakthrough β Think in Resources, Not Actions
In the year 2000, a PhD student named Roy Fielding at UC Irvine was writing his doctoral dissertation. He'd been one of the principal authors of the HTTP specification β the protocol that makes the entire web work. His thesis, titled "Architectural Styles and the Design of Network-Based Software Architectures," had a chapter (Chapter 5) that would change how we build APIs forever. He called the idea REST β REpresentational State TransferThe full name is rarely used. What matters is the core idea: organize your API around resources (things, nouns) that clients interact with by transferring representations (usually JSON) of those resources' state. In plain English: "here's the data for user #42, as JSON.".
The key insight was deceptively simple: stop thinking in functions, start thinking in resources.
Take the messy API from Section 2. Try to reorganize those endpoints using only these resource nouns: /users, /orders, /products, /carts. Use GET, POST, PUT, PATCH, DELETE as your verbs. How many unique names do you need to invent?
Here's what Fielding figured out. Instead of inventing a new URL for every action:
# BEFORE: The URL is a verb (what to DO)
/getUser?id=5 β GET /users/5
/createOrder β POST /orders
/updateProduct?id=3 β PUT /products/3
/deleteAccount?id=7 β DELETE /accounts/7
/getUserOrders?id=5 β GET /users/5/orders
# The URL is always a NOUN (the thing).
# The HTTP method is always the VERB (the action).
# This combination is so predictable that a developer
# can GUESS the API without reading docs.
Look what happened. Ten unique endpoint names with three different naming styles collapsed into three resource nouns with the same five verbs. A developer who's never seen your API can guess the endpoints. "I bet there's a GET /orders and a GET /orders/99." And they'd be right. That's the power of a convention β you learn the pattern once and apply it everywhere.
And this is exactly what the best real-world APIs do. Here's Stripe β widely considered the gold standardStripe's API is so well-designed that other companies use it as a reference when building their own. It follows REST conventions strictly: resources as nouns, proper HTTP methods, proper status codes, cursor-based pagination, and idempotency keys for safe retries. of API design:
# Creating a charge (real Stripe API call)
curl -X POST https://api.stripe.com/v1/charges \
-u sk_test_xxx: \
-d amount=2000 \
-d currency=usd \
-d source=tok_visa
# Response: HTTP 201 Created
# {
# "id": "ch_1234",
# "object": "charge",
# "amount": 2000,
# "currency": "usd",
# "status": "succeeded"
# }
# Notice the design choices:
# - POST (creating a new charge) β not /createCharge
# - /v1/charges (noun, plural) β the resource
# - HTTP 201 (not 200) β "created", not just "ok"
# - Versioning via URL path (/v1/)
# - Auth via HTTP Basic (-u flag)
This is RESTREpresentational State Transfer β an architectural style for APIs defined by Roy Fielding in his 2000 PhD dissertation at UC Irvine. The key idea: organize your API around resources (nouns) and use standard HTTP methods (verbs) to manipulate them. The "representational state transfer" part means: the client sends and receives representations (usually JSON) of a resource's current state. β REpresentational State Transfer. Don't memorize that name. Just remember: the URL is always a noun (the thing), the HTTP method is always a verb (the action). That's the entire philosophy.
REST doesn't add any new technology. There's no library to install, no protocol to learn. It's purely a convention β an agreement about how to organize URLs and use HTTP methods in a predictable way. The same HTTP that loads web pages in your browser already speaks REST. You just need to use it properly.
How It Works β The Six Building Blocks
A client sends DELETE /users/42 twice in a row. Should the second request fail with an error, or succeed silently? What about POST /users sent twice β should it create one user or two? Think about what makes an operation safe to repeat vs dangerous to repeat.
REST is built on six core concepts. You already understand the main idea β resources as nouns, HTTP methods as verbs. Now let's go deeper into each building block so you can design APIs that are clean, consistent, and production-ready.
1. Resources & URLs β Naming the Things in Your System
A resourceAny "thing" in your system that has identity and can be addressed by a URL. Users, orders, products, comments, payments β all resources. Even abstract concepts like "search results" can be resources. is any "thing" your API deals with. Users, books, orders, payments, reviews. Each resource gets a URL β its address on the web. The URL tells the client what they're talking about.
Here are the rules that every good REST API follows for naming resources:
- Use nouns, never verbs β
/booksnot/getBooks. The HTTP method IS the verb. - Use plurals β
/usersnot/user. The collection is plural; a single item is/users/42. - Use lowercase with hyphens β
/order-itemsnot/orderItemsor/OrderItems. - Nest for relationships β
/users/7/ordersmeans "orders belonging to user 7". - Keep it shallow β max 2-3 levels deep.
/users/7/orders/99/itemsis okay. Going deeper is a smell.
2. HTTP Methods β The Five Things You Can Do
HTTP gives you five standard methods. Think of them as the five actions you can perform on any resource β like a universal remote control that works with every TV.
Two words worth knowing here. SafeA "safe" HTTP method doesn't modify anything on the server. GET is safe β it just reads. POST, PUT, PATCH, DELETE are not safe β they change state. Browsers and proxies use this to decide what's safe to retry or cache. means the method doesn't change anything β GET just reads, like looking through a shop window. IdempotentAn operation is idempotent if doing it once or doing it ten times gives the same result. PUT and DELETE are idempotent β deleting the same resource twice doesn't delete it "more." POST is NOT β posting the same order twice creates two orders. means doing it once or ten times gives the same result. DELETE is idempotent β deleting a book that's already deleted doesn't delete it "more." POST is NOT idempotent β posting the same order twice creates two orders. This matters hugely for retries and error handling, which we'll cover in Section 7.
{ "title": "New Title" }, the price, author, and every other field gets erased. PATCH means "here are just the changes" β you can send { "price": 29.99 } and only the price updates. Most real APIs use PATCH for updates.
3. Status Codes β How the Server Says "Here You Go" or "Nope"
Every HTTP response comes with a three-digit number that tells the client what happened. You don't need to memorize all 70+ status codes β just these categories and the 10-12 most common ones.
A quick rule of thumb: 2xx means everything's fine, 4xx means the client did something wrong (bad data, wrong URL, no permissions), and 5xx means the server broke. The most common mistake is returning 200 for everything and hiding the error in the response body β don't do that. Clients, monitoring tools, and load balancersA server that distributes incoming traffic across multiple backend servers. Load balancers use status codes to decide if a backend is healthy β too many 5xx responses means "stop sending traffic there." all rely on status codes to behave correctly.
4. Request & Response β The Conversation Format
Every API call is a conversation: the client asks, the server answers. Let's look at what a real exchange looks like.
POST /books HTTP/1.1
Host: api.bookstore.com
Content-Type: application/json
Authorization: Bearer eyJhbGciOi...
{
"title": "Designing Data-Intensive Applications",
"author": "Martin Kleppmann",
"price": 45.99,
"isbn": "978-1449373320"
}
Line 1: Method + URL + HTTP version. "I want to CREATE a new book."
Headers: Metadata β what format the body is in, who I am (auth token).
Body: The actual data β the book I want to create.
HTTP/1.1 201 Created
Content-Type: application/json
Location: /books/143
{
"id": 143,
"title": "Designing Data-Intensive Applications",
"author": "Martin Kleppmann",
"price": 45.99,
"isbn": "978-1449373320",
"created_at": "2026-03-18T10:30:00Z"
}
Status line: 201 Created β "I made the book for you."
Location header: "Here's where you can find it: /books/143"
Body: The created resource, now with an ID and timestamp the server added.
Notice how the response echoes back the full resource, not just "ok." This lets the client update its local state without making a second GET request. The Location header tells the client the URL of the newly created resource β a small courtesy that saves a round trip.
5. Headers β The Envelope, Not the Letter
If the URL and body are the "letter" you're sending, headers are the "envelope" β they carry information about the message without being the message itself. Think of headers like the labels on a package: who sent it, how fragile it is, when it expires.
6. Content Negotiation β "Send Me JSON, Please"
Content negotiation is a fancy term for a simple idea: the client tells the server what format it wants the data in, and the server tries to comply. It's like walking into a restaurant and saying "I'd like the menu in English, please" β the food is the same, but the presentation matches your preference.
The client sends an Accept header saying what format it can handle:
GET /books/42 HTTP/1.1
Accept: application/json
The server responds with the data in that format, and tells you what it sent via Content-Type:
HTTP/1.1 200 OK
Content-Type: application/json
{ "id": 42, "title": "DDIA", "price": 45.99 }
In practice, 99% of modern REST APIs use JSON exclusively. You'll occasionally see XML in legacy enterprise systems, or CSV for data export endpoints. But JSON won the format war years ago, so content negotiation is mostly a formality β your API will almost certainly speak JSON and nothing else.
Content-Type: application/json on your responses, even if you think it's obvious. Some HTTP clients behave differently when the content type is missing β they might try to parse the response as HTML or plain text instead of JSON.
Going Deeper β The Details That Separate Good APIs from Great Ones
You now know the fundamentals β resources, methods, status codes, headers. That's enough to build a working REST API. But a great API handles the hard stuff too: How do you change your API without breaking everyone? How do you return 10 million records? How do clients retry safely? Let's dig in.
Versioning β Changing Your API Without Breaking the World
Your API is live. Thousands of apps depend on it. Now you need to change the response format β maybe rename a field, or restructure the data. If you just change it, every client breaks. That's why APIs need versioningA strategy for evolving your API over time while maintaining backward compatibility. Old clients keep using v1, new clients use v2. You support both until everyone migrates. β a way to evolve without breaking existing users.
You have an API with 500 active clients. You need to rename user_name to username in the response. How would you roll this out safely? Think of at least 2 approaches.
The industry favorite is URL path versioning β /v1/books, /v2/books. It's the simplest to understand, the easiest to route, and the most visible. Stripe, GitHub, and Twilio all use it. For most teams, this is the right choice. Don't overthink it.
Pagination β Returning a Million Records Without Crashing
Your bookstore has 2 million books. A client calls GET /books. Do you return all 2 million in one response? Of course not β that would be a 500MB JSON blob that takes 30 seconds to download and crashes the client's browser. You need paginationBreaking a large collection into smaller chunks ("pages") that clients can request one at a time. Like reading a book one chapter at a time instead of the whole thing at once. β breaking the results into pages.
The simple approach: tell the server "give me page 3, with 20 items per page."
GET /books?page=3&limit=20
Response:
{
"data": [ ... 20 books ... ],
"pagination": {
"page": 3,
"limit": 20,
"total": 1847,
"total_pages": 93
}
}
Pros: Easy to implement. Clients can jump to any page. Great for UI with page numbers.
Cons: If someone inserts a new book while you're paginating, you might see duplicates or skip items. At high offsets (page=50000), the database has to scan and skip millions of rows β very slow.
The scalable approach: instead of a page number, use a "bookmark" β a pointer to where you left off.
GET /books?limit=20&after=eyJpZCI6MTAwfQ==
Response:
{
"data": [ ... 20 books ... ],
"pagination": {
"next_cursor": "eyJpZCI6MTIwfQ==",
"has_more": true
}
}
Pros: Consistent even when data changes. Fast at any depth β the database uses an index seek, not a scan. Perfect for infinite scroll.
Cons: Can't jump to "page 500." Clients must follow the chain: page 1 β page 2 β page 3. The cursor is opaque β clients shouldn't try to decode or construct it.
Filtering & Sorting β Finding the Needle in the Haystack
Pagination gives you pages. But clients rarely want all books β they want science fiction books under $20, sorted by rating. That's filteringNarrowing down results based on criteria. In REST, filters are typically query parameters: /books?genre=sci-fi&max_price=20. The server applies these as WHERE clauses in the database query. and sortingOrdering results by a specific field. In REST: /books?sort=price (ascending) or /books?sort=-price (descending, with a minus prefix). Can sort by multiple fields: ?sort=-rating,price..
# Filter by genre AND price, sort by rating (descending)
GET /books?genre=sci-fi&max_price=20&sort=-rating&limit=20
# Filter by author, sort by publication date (newest first)
GET /books?author=kleppmann&sort=-published_at
# Search with a query string
GET /books?q=distributed+systems&in_stock=true
The conventions: use query parameters for filters, prefix with - for descending sort, use q for full-text search. Keep filter names consistent with your response field names β if the JSON says "genre": "sci-fi", the filter should be ?genre=sci-fi, not ?book_genre=sci-fi.
Idempotency Keys β Making Retries Safe
Here's a scary scenario. A client sends POST /orders to place an order. The server creates the order and charges the credit card. But the response never arrives β the network drops it. The client doesn't know if the order went through, so it retries. Without protection, the customer gets charged twice.
How would you prevent duplicate orders when clients retry? The server already processed the first request β how can it recognize the second request as a duplicate? Think about it before reading on.
The solution is idempotency keysA unique ID that the client generates and sends with each request. If the server receives the same key twice, it returns the original response instead of processing the request again. Stripe popularized this pattern β every payment API uses it now.. The client generates a unique ID and sends it with the request. The server stores this ID alongside the result. If the same ID shows up again, the server returns the original response instead of processing the request a second time.
Stripe uses this for every payment. You send an Idempotency-Key header, and Stripe stores the result for 24 hours. If the same key appears again, Stripe returns the original result without charging the card again. This is such a critical pattern that it should be built into every API that processes money, creates resources, or triggers side effects.
Rate Limiting Headers β "You're Calling Too Much"
APIs need to protect themselves from clients that call too often β whether it's a bug in someone's code, a DDoS attack, or a script gone wild. Rate limitingRestricting how many requests a client can make in a given time window. E.g., "1000 requests per hour." When the limit is exceeded, the server returns 429 Too Many Requests. caps the number of requests per time window, and good APIs tell you exactly where you stand via headers.
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000 # Max requests per hour
X-RateLimit-Remaining: 847 # Requests left in this window
X-RateLimit-Reset: 1710761400 # When the window resets (Unix timestamp)
# When you hit the limit:
HTTP/1.1 429 Too Many Requests
Retry-After: 342 # Wait 342 seconds before retrying
Good clients check X-RateLimit-Remaining proactively and slow down before hitting the limit. The Retry-After header on a 429 response tells the client exactly when it's safe to try again β so it doesn't have to guess.
The Variations β REST vs RPC vs GraphQL
A mobile app needs to display a user's profile, their 3 most recent orders, and a list of recommended products β all on one screen. With REST, that's 3 separate API calls (GET /users/42, GET /users/42/orders?limit=3, GET /recommendations). What if the mobile app is on 3G with 800ms latency per round trip? That's 2.4 seconds just in network time. Can you think of a way to get all this data in a single request?
REST isn't the only way to build APIs. It's the most popular, but two other styles are widely used β and each one exists because REST can't solve every problem perfectly. Let's compare them honestly, without fanboyism.
Here's the practical truth: most companies use REST for public APIs and gRPC for internal service-to-service communication. GraphQL thrives when you have many different clients (mobile, web, third-party) that each need different slices of the same data. GitHub actually offers both β their REST API (v3) and GraphQL API (v4) coexist, and you pick whichever fits your use case.
REST's weakness #1: You always get the entire resource, even if you only need one field.
# You just want the user's name. REST returns EVERYTHING:
GET /users/42
{
"id": 42,
"name": "Alice", β you wanted this
"email": "alice@...", β didn't need this
"avatar_url": "...", β or this
"address": { ... }, β definitely not this (1KB of data)
"preferences": { ... } β or this
}
# GraphQL lets you ask for just what you need:
# { user(id: 42) { name } } β { "name": "Alice" }
For desktop apps on fast connections, over-fetching barely matters. For mobile apps on 3G? Sending 10KB when you need 50 bytes adds up fast across hundreds of API calls.
REST's weakness #2: To get a user and their 5 most recent orders, you need multiple calls.
# Step 1: Get the user
GET /users/42 β { "id": 42, "name": "Alice" }
# Step 2: Get their orders (separate call!)
GET /users/42/orders β [ { "id": 1, ... }, { "id": 2, ... } ]
# Step 3: Get items for each order (5 MORE calls!)
GET /orders/1/items β [ ... ]
GET /orders/2/items β [ ... ]
...
# That's 7 round trips for one screen of data!
# GraphQL does it in ONE:
# { user(id:42) { name, orders { id, items { name } } } }
This is the N+1 problemWhen fetching a list of N items requires N additional requests to get related data. 1 request for the list + N requests for details = N+1 total. GraphQL and RPC solve this with nested queries or batching.. REST APIs often address it with ?include=orders,items or ?expand=orders.items query parameters, but it's a bolt-on β not a native feature.
At Scale β How Stripe, GitHub, and Twilio Do It
Stripe handles payments β an operation where failures are catastrophic. A customer clicks "Pay" and the network hiccups. The client retries. How would you design the API so that retrying POST /v1/charges doesn't charge the customer twice? Stripe solved this elegantly β think about what you'd put in the request header.
Theory is nice, but how do the world's best APIs actually work? Let's look at three APIs that are consistently rated among the best β and what specific choices they made that you can steal for your own designs.
Stripe β The API Every API Aspires to Be
Stripe processes billions of dollars in payments. Their API is so well-designed that it's become the gold standard for how REST APIs should work. Here's what they do right:
- Idempotency keys on every POST β send
Idempotency-Key: abc-123and Stripe stores the result for 24 hours. Retries are always safe. This is critical when you're moving money. - Expand parameter β instead of making 3 API calls, use
?expand[]=customer&expand[]=invoiceto include related objects inline. Solves the N+1 problem elegantly. - Versioned via date β Stripe uses
Stripe-Version: 2024-06-20instead of/v1/. Each account is pinned to the version it was created with. You can upgrade at your own pace. - Consistent error format β every error has
type,code,message, andparam(the field that caused it). Machine-readable AND human-readable.
// Stripe error response β clear, actionable, consistent
{
"error": {
"type": "card_error",
"code": "card_declined",
"message": "Your card was declined.",
"param": "source",
"decline_code": "insufficient_funds"
}
}
GitHub β REST and GraphQL Living Together
GitHub is interesting because they offer both API styles. Their REST API (v3) has been around for years and is battle-tested. Their GraphQL API (v4) launched in 2017 to solve the over-fetching problem that mobile developers complained about.
- HATEOAS in practice β every response includes
urllinks to related resources. A repo object containsissues_url,pulls_url,commits_url. You can navigate the entire API by following links. - Pagination via Link headers β
Link: </repos?page=3>; rel="next", </repos?page=50>; rel="last". Clients follow the links instead of constructing URLs. - Conditional requests β
ETagandIf-None-Matchfor caching. If data hasn't changed, GitHub returns304 Not Modifiedwith zero body. Saves bandwidth and doesn't count against your rate limit.
Twilio β So Simple You Can Use It From curl
Twilio's design philosophy is radical simplicity. Sending an SMS is one API call. Making a phone call is one API call. They optimized for "time to first hello world" β a new developer should be able to send their first text message in under 5 minutes.
- Subresources for everything β
/Accounts/{sid}/Messages,/Accounts/{sid}/Calls. The URL hierarchy mirrors the business domain. - Human-readable SIDs β resource IDs start with a prefix that tells you what they are:
ACfor Account,SMfor SMS,CAfor Call. You can identify a resource type just by looking at its ID. - Webhook-first design β when an SMS is delivered or a call ends, Twilio POSTs to your webhook URL with the status update. You don't have to poll.
The Anti-Lesson β When REST Is the Wrong Choice
REST is wonderful for most APIs. But it's not the answer to everything. Forcing REST onto problems it wasn't designed for leads to ugly, inefficient APIs. Here are the situations where you should reach for something else.
The last point is worth emphasizing. Purists insist that everything must be a noun. But POST /accounts/42/actions/merge is genuinely worse than POST /accounts/42/merge. The best APIs are pragmatic β they follow REST conventions where they fit and deviate where they don't. Stripe uses POST /charges/{id}/capture for capturing a payment. That's a verb, and it's fine.
POST /transfers with a body containing source and destination. Not everything has to be pure REST."
Common Mistakes β What Engineers Get Wrong
These are the mistakes I see in code reviews, interviews, and production APIs. Each one seems minor, but at scale they compound into APIs that are painful to use.
What people do: GET /getUsers, POST /createOrder, DELETE /deleteBook/42
Why it's wrong: The HTTP method IS the verb. Adding a verb to the URL is redundant β like saying "I want to GET the getUsers." It also breaks the REST convention, making your API harder to learn.
The fix: GET /users, POST /orders, DELETE /books/42. Let the method do its job.
What people do: Return 200 OK with { "success": false, "error": "Not found" }
Why it's wrong: Monitoring tools count 4xx/5xx errors to trigger alerts. Load balancers use status codes to route traffic. Returning 200 for errors means your infrastructure thinks everything is fine when it's not. Your error rate dashboard shows 0% while users are getting errors.
The fix: Use the correct status code: 404 for not found, 400 for bad input, 401 for unauthorized, 500 for server errors. Save 200 for actual success.
What people do: Mix camelCase, snake_case, and kebab-case in the same API. URLs use /orderItems, JSON uses order_items, headers use X-Order-Items.
Why it's wrong: Inconsistency forces developers to memorize exceptions. "Was it userId or user_id?" Every moment spent on this is a moment NOT spent building features.
The fix: Pick one convention and stick with it. The most common: snake_case in JSON (Stripe, GitHub), kebab-case in URLs (/order-items). Whatever you choose, document it and enforce it.
What people do: /users/1, /users/2, /users/3 β sequential auto-increment IDs from the database.
Why it's wrong: Attackers can iterate through all users. Competitors can estimate your growth ("they have 50,000 users"). Internal structure leaks into the public API.
The fix: Use UUIDs (/users/f47ac10b-58cc) or opaque identifiers. Stripe uses prefixed IDs like cus_abc123 β you can't guess the next one, and the prefix tells you it's a customer.
What people do: Return 400 Bad Request with no body, or a generic { "error": "Invalid request" }.
Why it's wrong: The developer debugging this has no idea WHAT was invalid. Was it a missing field? A wrong type? A value out of range? They end up guessing or reading source code.
The fix: Return a structured error body with a machine-readable code, a human-readable message, and β critically β which field caused the error:
{
"error": {
"code": "validation_error",
"message": "Price must be a positive number",
"field": "price",
"value": -5.99
}
}
Interview Playbook β How REST Shows Up in Interviews
REST API design comes up in two contexts: dedicated API design rounds ("Design an API for X") and as part of system design rounds ("Here's the architecture β now design the API layer"). Here's how to handle both.
Beginner: Show You Know the Basics
- Name resources as nouns β "I'd have
/users,/posts, and/commentsas my three main resources." - Use correct HTTP methods β "Creating a post is
POST /posts. Reading one isGET /posts/{id}. Deleting isDELETE /posts/{id}." - Use correct status codes β "201 for creation, 404 when not found, 400 for validation errors."
- Return the resource β "After creating, I return the full resource with its new ID so the client doesn't need a second call."
Intermediate: Show You Think About Edge Cases
- Pagination β "For lists, I'd use cursor-based pagination with
?limit=20&after=cursor. Offset pagination has performance issues at depth." - Filtering β "Clients can filter with
?status=published&author=alice&sort=-created_at." - Error format β "Every error returns a JSON body with
code,message, andfield. Machine-readable and human-readable." - Versioning β "I'd version the API via URL path:
/v1/posts. It's the simplest and most common approach."
Advanced: Show You've Built Production APIs
- Idempotency β "For POST endpoints that create resources or trigger payments, I'd require an
Idempotency-Keyheader. Retries are safe." - Rate limiting β "I'd return
X-RateLimit-Remainingheaders and 429 withRetry-Afterwhen exceeded." - HATEOAS β "Each response includes links to related resources and next actions. The client doesn't need to hard-code URLs."
- Trade-offs β "For this particular use case, I'd consider GraphQL instead of REST because the clients have very different data needs β mobile wants minimal fields, web wants everything."
Hands-On Challenges β Design APIs Yourself
The best way to learn REST API design is to practice it. These five challenges progress from beginner to expert. For each one, write down your endpoints before opening the solution. In interviews, you won't have answers to peek at β build that muscle now.
Design a REST API for a blog platform. Users can create posts, comment on posts, and like posts. List all the resources, endpoints, and the HTTP methods for each.
Time target: 5 minutes
Start by listing the nouns: posts, comments, likes, users. Then think about relationships β comments belong to posts, likes belong to posts. Should likes be a sub-resource of posts, or their own top-level resource?
# Posts
GET /posts # List all posts (paginated)
GET /posts/{id} # Get single post
POST /posts # Create a post
PATCH /posts/{id} # Update a post
DELETE /posts/{id} # Delete a post
# Comments (nested under posts)
GET /posts/{id}/comments # List comments on a post
POST /posts/{id}/comments # Add a comment
DELETE /posts/{id}/comments/{cid} # Delete a comment
# Likes (sub-resource of posts)
POST /posts/{id}/likes # Like a post
DELETE /posts/{id}/likes # Unlike a post (your own)
GET /posts/{id}/likes # List who liked it
# Users
GET /users/{id} # Get user profile
GET /users/{id}/posts # Get a user's posts
Key decisions: Likes are a sub-resource because they only make sense in context of a post. Comments are nested because they belong to a specific post. Users are top-level because they exist independently.
Design the product search and cart API for an e-commerce site. Products have categories, prices, ratings, and stock status. The catalog has 2 million products. Include pagination, filtering, and sorting in your design.
Time target: 7 minutes
With 2M products, offset pagination will be slow for deep pages. Think about cursor-based pagination. For filtering, think about which fields users would commonly filter by. For the cart, should cart items be a sub-resource?
# Product catalog (cursor pagination for 2M products)
GET /products?category=electronics&min_price=10&max_price=500
&in_stock=true&sort=-rating&limit=20&after=eyJpZCI6MTAwfQ
# Response includes:
# { "data": [...], "pagination": { "next_cursor": "...", "has_more": true } }
GET /products/{id} # Single product with full details
GET /products/{id}/reviews # Reviews for a product
# Cart
GET /carts/me # Current user's cart
POST /carts/me/items # Add item: { "product_id": 42, "quantity": 2 }
PATCH /carts/me/items/{id} # Update quantity: { "quantity": 5 }
DELETE /carts/me/items/{id} # Remove item from cart
# Checkout
POST /orders # Convert cart to order: { "cart_id": "...", "payment_method": "..." }
Key decisions: Cursor pagination because 2M products makes offset slow. /carts/me uses the authenticated user's ID (no need to pass it). Checkout is a POST to /orders (creating a new order), not a verb endpoint.
Design a consistent error response format for a payment API. Cover these scenarios: invalid card number, expired card, insufficient funds, rate limit exceeded, internal server error, and authentication failure. Show the exact JSON response for each.
Time target: 5 minutes
Think about what a developer needs to programmatically handle each error. A machine-readable code is essential (you can't parse English messages). Also think about which HTTP status code matches each scenario β not everything is 400.
// 422 Unprocessable Entity β invalid card
{ "error": { "type": "card_error", "code": "invalid_card_number",
"message": "The card number is not a valid credit card number.",
"param": "card_number" } }
// 402 Payment Required β insufficient funds
{ "error": { "type": "card_error", "code": "insufficient_funds",
"message": "The card has insufficient funds.",
"decline_code": "insufficient_funds" } }
// 429 Too Many Requests β rate limited
{ "error": { "type": "rate_limit_error", "code": "rate_limit_exceeded",
"message": "Too many requests. Retry after 60 seconds." } }
// + Retry-After: 60 header
// 401 Unauthorized β bad API key
{ "error": { "type": "authentication_error", "code": "invalid_api_key",
"message": "The API key provided is invalid." } }
// 500 Internal Server Error β our fault
{ "error": { "type": "api_error", "code": "internal_error",
"message": "An unexpected error occurred. Please retry.",
"request_id": "req_abc123" } }
Pattern: Every error has the same shape: type (category), code (machine-readable), message (human-readable), and optional param/request_id. Clients switch on type and code, never parse the message string.
Your API has 200 active clients. You need to rename user_name to username in all user-related responses, and also change the date format from "March 18, 2026" to "2026-03-18T00:00:00Z" (ISO 8601). Design the migration strategy. How do you roll this out without breaking anyone?
Time target: 8 minutes
Think about whether you need a full version bump (v1 β v2) or if you can do this more gradually. Consider: can you return BOTH field names temporarily? How would you communicate the change? What's your timeline?
- Phase 1 β Dual-write (Week 1): Return BOTH
user_nameANDusernamein responses. Both have the same value. Old clients keep working; new clients can start using the new field. Same for dates β return both formats. - Phase 2 β Deprecation notice (Week 2): Add a
Deprecation: trueheader and aSunset: 2026-05-18header on responses that use the old field names. Email all 200 clients. Add a deprecation warning to docs. - Phase 3 β Monitor usage (Weeks 3-8): Track which clients still use the old field name (check request bodies for
user_name). Reach out to the holdouts directly. - Phase 4 β Remove old fields (Week 9): Stop returning
user_name. Old date format goes away. Monitor error rates for 24 hours.
Alternative: If the changes are too risky for gradual migration, bump to v2: /v2/users with new field names. Keep /v1/users alive for 6 months. This is cleaner but more work to maintain two versions.
Design the API for a payment system that supports: creating payments, confirming payments, capturing authorized payments, refunding, and handling 3D Secure authentication redirects. Include idempotencyThe property that performing an operation multiple times has the same effect as performing it once. Critical for payments β you never want to charge a customer twice because of a network retry., proper status codes, and webhook notifications. This is Stripe's actual problem.
Time target: 12 minutes
A payment goes through stages: created β requires_confirmation β requires_action (3DS) β processing β succeeded/failed. Think of the payment as a resource with a state machine. Actions like "confirm" and "capture" are sub-resources or action endpoints. Webhooks handle async status changes.
# Create a payment intent (requires Idempotency-Key)
POST /v1/payment-intents
Idempotency-Key: pi_unique_123
{ "amount": 2000, "currency": "usd", "payment_method": "pm_card_visa" }
β 201 Created { "id": "pi_abc", "status": "requires_confirmation" }
# Confirm the payment (client-side SDK typically does this)
POST /v1/payment-intents/pi_abc/confirm
β 200 { "status": "requires_action", "next_action": {
"type": "redirect_to_url",
"redirect_url": "https://bank.com/3ds?token=xyz" } }
# After 3DS, the status updates asynchronously via webhook:
# POST https://yourapp.com/webhooks/stripe
# { "type": "payment_intent.succeeded", "data": { "id": "pi_abc" } }
# Capture an authorized payment (for auth-then-capture flow)
POST /v1/payment-intents/pi_abc/capture
β 200 { "status": "succeeded", "amount_captured": 2000 }
# Refund (full or partial)
POST /v1/refunds
{ "payment_intent": "pi_abc", "amount": 500 }
β 201 { "id": "re_xyz", "status": "pending", "amount": 500 }
# List refunds for a payment
GET /v1/payment-intents/pi_abc/refunds
Key decisions: /confirm and /capture are action endpoints (verbs) β this is where pure REST breaks down and pragmatism wins. Webhooks handle async state transitions. Every mutating call accepts an idempotency key. Refunds are their own resource because they have lifecycle and metadata.
Quick Reference β REST API Cheat Sheet
Everything from this page on one screen. Pin this for your next API design session or interview prep.
GET β Read (safe, idempotent) POST β Create (NOT idempotent) PUT β Replace (idempotent) PATCH β Partial update DELETE β Remove (idempotent) Safe = doesn't change data Idempotent = same result if repeated
200 OK β Success 201 Created β POST success 204 No Content β DELETE success 400 Bad Request β Invalid input 401 Unauthorized β Bad/missing auth 403 Forbidden β No permission 404 Not Found β Bad URL / no data 409 Conflict β Duplicate/state err 422 Unprocessableβ Validation failed 429 Too Many Req β Rate limited 500 Server Error β Our fault
Nouns, not verbs: /users NOT /getUsers Plurals: /books NOT /book Lowercase + hyphens: /order-items NOT /orderItems Nesting for relationships: /users/42/orders Max 3 levels deep: /users/42/orders/99/items β OK /.../items/3/details β too deep
Offset (simple, slow at depth): ?page=3&limit=20 Cursor (fast, no jump-to-page): ?limit=20&after=eyJpZCI6MTAwfQ Response includes: "next_cursor": "..." "has_more": true Rule: cursor for feeds/search, offset for admin dashboards
URL path (most common): /v1/books β /v2/books Header: API-Version: 2 Query param: /books?version=2 Best practice: URL path (Stripe, GitHub, Twilio) Support old version for 6-12 months Deprecation headers + sunset date
{
"error": {
"type": "validation_error",
"code": "invalid_email",
"message": "Email is invalid",
"field": "email"
}
}
Always include:
- Machine-readable code
- Human-readable message
- Which field caused it
- Request ID for debuggingConnected Topics β Where to Go Next
REST API design connects to almost every other system design topic. Here's how what you learned feeds into the rest of your learning journey.