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.

CodeMeans
200Here it is
201Created, with a Location header pointing at it
204Done, nothing to return
400Your request is malformed
401I do not know who you are
403I know who you are, and no
404No such thing
409Conflicts with current state
422Well formed, but the values fail validation
429Slow 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 called date.
  • 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": 3 does 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.

Related

Next step

Working on something this touches?

If this post lines up with a problem you have, I would rather hear about the problem than the post. Tell me what you are building.

  • Replies: Within two working days
  • Based in: the United States
  • Open to: full time, contract, remote or hybrid

Pages

Projects

Writing

Actions