Quick take
Most API design advice obsesses over naming conventions and HTTP verb purity. The stuff that actually keeps an API alive across years of rewrites is boring: predictable error shapes, stable pagination, and never surprising the client.
The API that taught me
I’ve been building APIs for a while, but the one that forced me to care about design was the fintech startup’s financial data API. Financial news, sentiment scores, trending stories, all served to clients who ranged from solo quant traders to institutional data teams. These people don’t file bug reports. They just leave.
The first version of that API was functional. It returned data. It had endpoints. But within weeks of onboarding external consumers, I learned that “functional” and “well-designed” are completely different things. A functional API returns the right data. A well-designed API lets a stranger integrate in an afternoon without asking you a single question.
Conventions that matter vs conventions that don’t
The REST community has opinions about everything. Plural vs singular nouns, nested resources, HATEOAS, content negotiation. I’ve opinions too, and they come from watching real clients integrate with real APIs.
What actually matters:
Consistent response shapes. Every list endpoint should paginate the same way. Every error should have the same structure. Every create should return the created resource. I can’t overstate this. When a client library author can write one generic handler for all your endpoints, you have won.
HTTP status codes used correctly. Not creatively, correctly. 200 for success, 201 for creation, 400 for bad input, 404 for missing resources, 429 for rate limits. If you return 200 with an error body, you’re forcing every client to parse the response before knowing if the request worked. That’s a tax on every integration.
Predictable field naming. Pick snake_case or camelCase and never mix them. We went with snake_case for the fintech startup’s API because the majority of our consumers were Python shops. It doesn’t matter which you pick. It matters that you pick one.
What doesn’t matter nearly as much as people think:
Whether your endpoint says /stories or /story. Plural is the convention, fine. But nobody has ever failed an integration because of noun plurality.
Deeply nested resource paths. /users/123/portfolios/456/watchlists/789/items looks RESTful but is a nightmare to cache, a nightmare to document, and a nightmare for client library authors. Flatten it. Use query parameters.
HATEOAS. I know this is heresy. In theory, self-describing APIs with hypermedia links are beautiful. In practice, I’ve never seen a client actually follow links dynamically. They hardcode paths. Design for that reality.
Show me the code
Here is what a poorly designed endpoint looks like in practice. I’ve seen this pattern dozens of times.
# Bad: mixing concerns, inconsistent response shape,
# status code lies about outcome
@app.route('/api/getStories', methods=['POST'])
def get_stories():
ticker = request.json.get('ticker')
stories = db.query_stories(ticker)
if not stories:
return jsonify({
'success': False,
'message': 'No stories found'
}), 200 # 200 for an empty result? for an error? who knows
return jsonify({
'success': True,
'result': stories,
'total': len(stories)
}), 200
POST for a read operation. getStories as the path instead of a resource noun. success boolean instead of HTTP status codes. No pagination. The response shape changes based on the result. Every client has to write branching logic just to parse this.
Now compare that with the approach we settled on at the fintech startup.
# Good: proper verb, resource-oriented path,
# consistent response envelope, pagination from day one
@app.route('/api/v1/stories', methods=['GET'])
def list_stories():
ticker = request.args.get('ticker')
cursor = request.args.get('cursor')
limit = min(int(request.args.get('limit', 20)), 100)
stories, next_cursor = db.query_stories(
ticker=ticker, cursor=cursor, limit=limit
)
return jsonify({
'data': stories,
'pagination': {
'next_cursor': next_cursor,
'has_more': next_cursor is not None
}
}), 200
GET for reads. Resource noun in the path. Cursor-based pagination from day one. A consistent envelope that looks identical whether you’re listing stories, tickers, or users. An empty list returns an empty data array with has_more: false. No special cases.
Pagination isn’t optional
The fintech startup API serves financial news. News volume spikes. When a company announces earnings or a market event hits, you can go from 50 stories to 5,000 in an hour. If your list endpoint doesn’t paginate, you will find out the hard way.
We used cursor-based pagination from the start. Offset pagination is simpler to explain, but it breaks when data changes between requests. In a feed of financial news where stories are constantly being added, offset pagination skips and duplicates items. Cursors are opaque to the client, stable across inserts, and give you room to change the underlying query without breaking the contract.
Errors are part of the interface
When a quant trader’s script hits a validation error at 2am, they aren’t going to email support. They are going to read the error response and either fix it or switch providers.
Every error from our API included a machine-readable code, a human-readable message, and a request ID for tracing. Validation errors included the field name and the reason. Rate limit responses included Retry-After and the remaining quota. This isn’t generosity. This is self-preservation. Good error messages reduce support tickets.
Version from the start
We put /v1/ in the URL path. Not because it’s the most elegant approach, but because it’s the most visible. When something breaks, you can see the version in the logs, in the curl command, in the client configuration. Header-based versioning is cleaner in theory, but in practice it’s invisible exactly when you need it most.
The rule we followed: adding fields is safe, removing fields isn’t. Changing a type is a breaking change. Changing an error format is a breaking change. If you aren’t sure whether something is breaking, it’s breaking.
The contract that matters
API design isn’t about following REST rules for purity. It’s about reducing the cost of integration for people who aren’t you. Consistent shapes, honest status codes, pagination from day one, useful errors, and visible versioning. That’s the whole list. The fintech startup API survived multiple backend rewrites because we got those basics right early. The implementation changed. The contract held.