The test of an API is not whether it works. It is whether someone can build against it without asking you a question.
Here is what that takes, in roughly the order the decisions come up.
Name resources, not actions
The URL identifies a thing. The method says what you are doing to it.
GET /invoices list
POST /invoices create
GET /invoices/{id} read one
PATCH /invoices/{id} partial update
DELETE /invoices/{id} delete
Not /getInvoices, not /createInvoice, not /invoice/delete/{id}. Plural
nouns throughout, so a consumer never has to remember which endpoints you
decided were singular.
For things that genuinely are not CRUD, a sub-resource reads better than a verb:
POST /invoices/{id}/reminders send a reminder
POST /invoices/{id}/void void it
Use the status codes properly
This is where most of the confusion comes from, and it is free to get right.
| Code | Means |
|---|---|
| 200 | Here it is |
| 201 | Created, with a Location header pointing at it |
| 204 | Done, nothing to return |
| 400 | Your request is malformed |
| 401 | I do not know who you are |
| 403 | I know who you are, and no |
| 404 | No such thing |
| 409 | Conflicts with current state |
| 422 | Well formed, but the values fail validation |
| 429 | Slow down |
The two most abused are 200 and 401. Returning 200 with {"error": ...} in the
body forces every client to parse the body to find out whether it worked. And
sending 401 when you mean 403 makes clients retry authentication that was never
the problem.
Make errors machine-readable
A human-readable string is not enough. A client needs to branch on the error, and branching on a message means breaking the moment you reword it.
{
"error": {
"code": "invoice_already_paid",
"message": "This invoice was paid on 2026-08-14 and cannot be voided.",
"field": null
}
}
A stable code to branch on. A message for humans. A field when the error
belongs to one. Keep the shape identical across every endpoint.
For validation errors, return all of them at once. Returning the first failure makes the client fix one field, resubmit, and discover the next, which is a miserable experience to build against and a worse one to use.
Pagination, decided once
Pick a scheme and apply it everywhere. Mixed pagination across one API is genuinely worse than a mediocre scheme applied consistently.
Offset pagination is simple and fine for small, stable collections. It degrades badly: deep offsets get slow, and rows shift underneath a paging client when data is being written.
Cursor pagination is the better default:
{
"data": [ ... ],
"next_cursor": "eyJpZCI6MTQyMH0",
"has_more": true
}
The client passes next_cursor back. It stays fast at any depth and does not
skip or duplicate rows when the underlying data changes.
Dates, times, and money
Three conventions worth adopting without debate:
- Timestamps in UTC, ISO 8601, with the offset.
2026-09-02T14:30:00Z. Never a naive local time, never a Unix integer in a field calleddate. - Money in minor units as an integer, with a currency code.
{"amount": 1250, "currency": "USD"}is $12.50. Floats and money do not belong together. - Enums as lowercase strings, not integers.
"status": "paid"survives a reordering of your internal enum."status": 3does not.
Version before you need to
Put /v1 in the path from the first release. It costs nothing now and it is
the difference between shipping a breaking change and not being able to.
Then hold the line on what breaking means. Adding an optional field is not
breaking. Removing a field, renaming one, tightening validation, or changing a
status code all are, and they need /v2.
Let the documentation generate itself
With FastAPI you get OpenAPI for free, which means the docs cannot drift from the implementation if your models are honest. Declare the response model on every route, including the error responses:
@router.post(
"/invoices",
response_model=InvoiceOut,
status_code=201,
responses={409: {"model": ErrorBody}, 422: {"model": ErrorBody}},
)
def create_invoice(payload: InvoiceIn) -> InvoiceOut:
...
Then read the generated page as though you had never seen the code. If the Swagger page is confusing, the API is confusing, and no amount of hand-written documentation will rescue it.
That is the whole check, really. Open your own docs and try to use your own API. Most of the problems are visible in about five minutes.