Skip to main content

API REST - How to write good APIs

Master REST API best practices for clean and scalable design. Get tips on correct URI notation, using plural nouns, and utilizing HTTP verbs for semantically correct endpoints.

Β· 24 min read
View raw
Christophe
Markdown, WSL and Docker lover ~ Insatiable curious.
Cet article existe aussi en franΓ§ais β€” lire la version franΓ§aise.

This article is over a year old but was reviewed on July 30, 2026 β€” the content is still accurate.

Changelog

Latest
Add belgif.be specifications

API REST - How to write good APIs

⚑
TL;DR

This comprehensive guide covers essential best practices for designing clean, scalable, and semantically correct REST APIs. Learn the do's and don'ts of URI construction, including using plural nouns and avoiding verbs. The article provides a detailed breakdown of how to properly use HTTP verbs like GET, POST, PUT, PATCH, and DELETE, and what HTTP status codes to return in different scenarios. It also delves into structuring JSON responses for data, errors, and pagination, following established standards for robust API design.

When developing APIs from scratch, you can do it in the mode I get behind the PC and start programming, or I learn about the standards first and then program in compliance with these standards.

And if we lose sight of these norms, we quickly come back to writing endpoints like /articles/insert that are semantically incorrect (and even stupid). In fact, we'll never GET here.

The purpose of the article below is to list best practices by way of examples.

Main concepts​

Uniform Resource Identifier​

URI notation​

Path segments and query parameters should use lowerCamelCase notation to enhance readability and to separate compounds names. (reference)

GOODBAD
https://xxx/api/v1/ipsoLorem/331https://xxx/api/v1/Ipso_Lorem/331
https://xxx/api/v1/ipso-lorem/331
https://xxx/api/v1/ipsoLorem/331https://xxx/api/v1/ipsoLorem/331/
https://xxx/api/v1/ipsoLorem?country=BEhttps://xxx/api/v1/Ipso_Lorem?Country=BE

URI shouldn't end with a / and neither with a file extension so don't https://xxx/api/v1/ipso-lorem/331.json but simply use HTTP headers like Content-Type: application/json;charset=UTF-8 to inform it's a JSON answer.

Prefer plural noun​

Always use the plural like GET /api/v1/employees/1 to return the first employee and not GET /api/v1/employee/1 (reference).

Using the plural make it easier to understand that employees is a collection, and we can use verbs like GET to get all, one or a range, POST to add a new employee, PUT to update an employee's information, ...

Also, using the plural form, the user will immediately understand he'll be able to add filters like /1 to get, f.i. the employee #1.

Relations between collections​

When building a URI think to the hierarchy and relationship between collections. If you need to return the list of tasks of John, you can use multiple notations like below illustrated:

GOODBAD
/api/v1/employees/john/tasks/api/v1/tasks/employees/john

About the hierarchy: does Tasks have employees or Employees have tasks? This hierarchy clearly defined (an employee can have multiple tasks), put collections in that order in the URI so /api/v1/employees/john will return the employee and by adding /tasks we'll obtain his list of tasks (then we can filter to a specific one /api/v1/employees/john/tasks/1 f.i.).

This makes URI more readable, intuitive and more scalable like in /api/v1/employees/john/projects.

Don't use verbs in URLs​

HTTP has verbs like GET, POST, DELETE, ... so don't use calls like:

  • /api/v1/articles/gettitle
  • /api/v1/articles/insert
  • /api/v1/articles/1/delete

But use the adequate HTTP verb:

  • GET /api/v1/articles/title
  • PUT /api/v1/articles
  • DELETE /api/v1/articles/1

Send the HTTP Accept header​

It's really recommended to inform the server about what you expect for: json, csv, plain-text, xml, ... To do this, use the Accept header like in the example here above. This is safer because the server can change its default format from JSON to CSV f.i. and if you expect JSON, your code will be broken.

Don't do this:

const employee = axios.create({
baseURL: "https://xxx/api/v1/employees/123",
});

But well:

const employee = axios.create({
baseURL: "https://xxx/api/v1/employees/123",
headers: {
Accept: "application/json",
},
});

Now, in the second example, you tell the web server that you want a JSON representation and nothing else.

Filtering​

Using ? followed by a key=value pair like ?country=BE. If you have several parameters, use & like in ?country=BE&lang=FR.

If you need to provide multiple values for the same parameter, the standard is to repeat the parameter like in /cars?color=black&color=blue (reference).

Sorting​

One common practice is to use a key called sort, which can be used as many times as there are columns to filter. Descending sorting order can be indicated by prefixing the property name with the dash - sign (reference).

For instance ?sort=name&sort=-age will make an ascending sort on the lastname column and a descending order on the age.

Pagination query parameters​

When using pagination, page is used to determine the current page while pageSize the number of records to display.

So /api/v1/employees?page=1&pageSize=5 will display the first five employees and /api/v1/employees?page=2&pageSize=5 the following five.

Reserved query parameters name​

(reference)

TermDescriptionExampleRef
pageWhen a collection resources is paged, use this parameter to request a specific page. Page numbers are 1-based.?page=3&pageSize=20Pagination
pageSizeWhen a collection resources is paged, use this parameter to specify the page size.?page=3&pageSize=20Pagination
qThe standard search parameter to do a full-text search.?q=RESTFullFiltering
selectFilter the resource properties to the ones specified.?select=(name,address)Consult (Document)
sortMulti-value query param with list of properties to sort on. Default sorting direction is ascending. To indicate descending direction, the property may be prefixed with -.?sort=name&&sort=-ageCollection
embedRequest to embed sub-resource. For instance api/v1/countries returns countries and api/v1/countries?embed=political countries with their political infos. This will prevent additional API calls.?embed=mainAddressEmbedding resources
langlanguage to filter multi-language descriptions?lang=frMulti-language descriptions

Verbs​

HEAD has to be used to retrieve information's about the resource like f.i. the number of records in the resource (number of books, articles, ...) or to check if a given resource exists. HEAD should be used to only retrieve the HTTP response headers of a resource (reference).

For instance, imagine a resource called countries, we can verify if language=FR will return something:

>user@machine: ~/yourproject
HEAD - Returned code​

If we get an HTTP return code 200, we can run a GET action to get records:

>user@machine: ~/yourproject

HEAD can thus be used to get metadata about a resource.

How many countries have French as their official language?

In the returned headers of HEAD we will most probably get a Content-Range entry. In our fake example, we can determine that we've six countries where French is an official language.

CodeStatusReason
200OKThe server has successfully processed the request. This means that the requested resource exists.
401UnauthorizedThe endpoint is secured, and the request didn't pass a valid access token.
403Access deniedYou're not allowed to access to the requested resource.
404Not foundThe requested resource didn't exist

The code 204 - No Content isn't expected because even if HEAD didn't return a response, the server well returns HTTP headers and this is similar to a response. So, the standard code is to return 200; not 204.

Count number of items in a resource​

https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range

HEAD can be used against a resource to get the total number of records; f.i. to be able to use a navigation bar.

So, don't create an endpoint like https://xxx/api/v1/countries/count when the standard way is using HEAD.

Imagine a database table called countries having 984 records (i.e. the result of SELECT Count(*) FROM countries;).

This info can be retrieved from our API like this:

>user@machine: ~/yourproject

curl --head (similar to curl -i) is using the HEAD HTTP verb.

In order to extract this information in JavaScript, the parse-content-range-header script can perhaps be used.

GET​

You can translate `GET` as `READ` in `CRUD`

Return a resource (can be a collection or just one).

Return all employees:

>user@machine: ~/yourproject

Return the employee #1:

>user@machine: ~/yourproject

GET is a strict read-only method, which should never modify the state of the resource (reference).

GET - Returned code​
CodeStatusReason
200OKThe server has successfully processed the request. The requested resource exists and is returned to you.
401UnauthorizedThe endpoint is secured, and the request didn't pass a valid access token.
403Access deniedYou're not allowed to access to the requested resource.
404Not foundThe requested resource didn't exist.

The 204 - No content code should not be used with GET.

POST​

You can translate `POST` as `CREATE` in `CRUD`

Using POST you'll create a new resource. Calling POST five times for the same "new" employee will thus create five new employees. POST is not idempotent meaning calling it more than once will return every time a new resource (like the new employee ID).

>user@machine: ~/yourproject

Note

When calling POST you should provide all the fields required to create the resource. So, for a new employee, their firstname, lastname, birthdate, national register number, their hiring date, the department where they'll work and so on.

POST - Returned code​

The example here above will create a new employee, the server will return the HTTP status code 201 (CREATED) and, probably, also return a location-header with a link, like https://xxx/api/v1/employees/999 i.e. the link to the newly created employee.

CodeStatusReason
201CreatedThe resource has been successfully created.
303See OtherThe resource already exists, and a link is provided to the resource. 303 is used to denote it's a normal check.
401UnauthorizedThe endpoint is secured, and the request didn't pass a valid access token.
403Access deniedYou're not allowed to call this method.
409ConflictThe resource can't be created (f.i. already exists). 409 is used when we didn't expect such use case.

The 200 - OK code should not be used with POST.

POST - Special case​

POST can also be used when calling a controller like in curl -X POST -d '{"action":"sendBirthdayMail",...}' https://xxx/api/v1/employees/123/actions or curl -X POST https://xxx/api/v1/employees/123/sendBirthdayMail.

The RESTful API standards recommend using `POST` for actions.

So, we shouldn't do GET https://xxx/api/v1/employees/actions because actions is not a resource but an action.

PUT​

You can translate `PUT` as `UPDATE` in `CRUD`, when you'll update every field

Using PUT implies you intend to update each field of the record. If you plan to update just a few of them, take a look at the PATCH verb.

Using PUT you'll update an existing resource. Let's imagine we have just three fields in our employees table:

>user@machine: ~/yourproject

Consider using PUT only when you'll update every piece of information of the resource (except calculated fields like f.i. employee_id). If you wish to update just a few ones (partial content), consider using PATCH.

PUT is idempotent since running the same update won't have other effects on the server. You can update once, five or one thousand times the firstname of the employee #999, the result will always be the same.

PUT - Returned code​
CodeStatusReason
200OKThe record was successfully updated and the updated version of the resource is returned.
204No ContentThe record was successfully updated and nothing is returned as HTTP response.
400Bad requestThe request is incorrect (f.i. you're trying to update a field first_name while its correct name is firstname).
401UnauthorizedThe endpoint is secured, and the request didn't pass a valid access token.
403Access deniedYou're not allowed to call this method
404Not foundThe requested resource didn't exist (f.i. you tried to access /api/v1/employees/1 and that one didn't exist).

Caution

When getting PUT -d '{"firstname":"Christophe"}' https://xxx/api/v1/employees/123456 the API developer can decide on their own to CREATE the resource 123456 if it didn't exist and if all required fields have been provided in the payload.

So, when the resource didn't exist, the developer can return a 404 - Not found error or, if they decide to create the new resource, then the API could return 201 - Created (just like a POST) (reference).

PATCH​

You can translate `PATCH` as `UPDATE` in `CRUD`, when you DON'T update every field

If you plan to update all fields, you need to use PUT, not PATCH.

PATCH is to be used when the update is partial like updating one field: curl -X PATCH -d '{"firstname": "John"} https://xxx/api/v1/employees/999' will, only, update first name.

PATCH on an in-existing resource will return an error while, perhaps, PUT will create the resource. It's then safer to use PATCH and not PUT when updating partial content.

It's indeed impossible to create a new record with PATCH since the request just mentions little information, not all.

PATCH - Returned code​
CodeStatusReason
200OKThe record was successfully updated and the updated version of the resource is returned.
204No ContentThe record was successfully updated and nothing is returned as HTTP response.
400Bad requestThe request is incorrect (f.i. you're trying to update a field first_name while its correct name is firstname).
401UnauthorizedThe endpoint is secured, and the request didn't pass a valid access token.
403Access deniedYou're not allowed to call this method
404Not foundThe requested resource didn't exist (f.i. you tried to access /api/v1/employees/1 and that one didn't exist).

Note: like with PUT (see above), the developer can decide to create the resource. In that case 201 - Created can be returned.

DELETE​

You can translate `DELETE` as ... `DELETE` in `CRUD`

To remove employee #59:

>user@machine: ~/yourproject
DELETE - Returned code​
CodeStatusReason
200OKThe record was successfully deleted and a message like The resource has been successfully deleted is returned.
204No ContentThe record was successfully deleted and nothing is returned as HTTP response.
400Bad requestThe syntax of the request is invalid.
401UnauthorizedThe endpoint is secured, and the request didn't pass a valid access token.
403Access deniedYou're not allowed to call this method.
404Not foundThe requested resource didn't exist (f.i. you tried to access /api/v1/employees/1 and that one didn't exist).
409ConflictThe resource can't be deleted (f.i. you can't dismiss an employee while there are still payments to be made).

OPTIONS​

OPTIONS can be used to retrieve the list of actions we can do with a resource like f.i. curl -X OPTIONS https://xxx/api/v1/employees/1 and we can expect to get GET (we can get the employee 1 metadata), PATCH/PUT (we can update it), ... but we'll not retrieve POST since that employee already exists (reference).

Responses​

Status codes​

200 OK​

A server MUST respond to a successful request to fetch an individual resource or resource collection with a 200 OK response.

When DELETE was used, we can also return 204 No Content to inform the calling application that deletion was successful.

201 Created​

If the requested resource has been created successfully and the server changes the resource in any way (for example, by assigning an ID), the server MUST return a 201 Created response and the new added resource in body.

202 Accepted​

If a request to create/update or delete a resource has been accepted for processing, but the processing has not been completed by the time the server responds, the server MUST return a 202 Accepted status code.

204 No Content​

If the requested resource has been created successfully and the server does not change the resource in any way (for example, by assigning an ID or createdAt attribute), the server MUST return either a 201 Created status code and the resource in response or a 204 No Content status code with no response document.

If the deletion was done successfully, 204 No Content can be used too.

For an update, it's the same: we can send code 200 OK with the updated resource in the body or, code 204 No Content without body; just headers.

400 Bad Request​

You should return a 400 Bad Request response when the call was incorrect like an unknown value is used for a parameter (like &lang=it when Italian isn't supported).

401 Unauthorized​

The requested API is a secured one and a call to it has been made without a valid access token.

403 Forbidden​

A server MUST return 403 Forbidden in response to an unsupported request to, for instance, create a resource (with POST verb with a client-generated ID since the ID has to be generated by the server, not the client).

The error 403 Forbidden has to be used too when a valid token is used but when the called resources require a specific scope (like canManageCustomers) and the used token doesn't have that one.

404 Not Found​

A server MUST respond with 404 Not Found when processing a request to fetch a single resource that does not exist, except when the request warrants a 200 OK response with null as the primary data (as described above).

406 Not Acceptable​

The request is asking for f.i. application/xml but the API didn't support it. In this case, the code 406 - Not Acceptable has to be returned (reference).

The request is indeed valid (the structure is correct) but the asked return type is not supported. For instance, by updating application/xml to application/json then the request can be processed.

The error is about the response's format; not about the request.

409 Conflict​

A server MUST return 409 Conflict when processing a POST request to create a resource with a client-generated ID that already exists.

A server MUST return 409 Conflict when processing a POST request in which the resource object's type is not among the type(s) that constitute the collection represented by the endpoint.

415 Unsupported Media Type​

Like 406 Not Acceptable, the server can't process the request but here, it's because the server didn't know how to process the received information (reference).

As an example, if the request is a POST with a JSON payload like curl -X POST -d '{"firstname":"Christophe",...}' https://xxx/api/v1/employees, the server can return the code 415 - Unsupported Media Type when the server was expecting an XML payload; not a JSON one.

The response would contain the supported type so, f.i.,

>user@machine: ~/yourproject

In this example, the server didn't support application/x-www-form-urlencoded for that route but will accept application/json.

Media Types​

The requestor application should specify the media type(s) that is (are) acceptable using the Accept HTTP header.

A REST response or request with payload must include the Content-Type HTTP header to indicate the media type of the HTTP payload.

For example, the request has to define -H "Accept: application/json" in its header to let the server know which media type is needed and the server (the response) should provide the Content-Type too.

>user@machine: ~/yourproject

If returning JSON is not managed by the API, the server has to return the HTTP code 406 - Not Acceptable.

The returned answer​

All RESTful APIs must support JSON, and it's the default representation to use (i.e. when no Content-Type header is sent with the request).

Following guidelines SHOULD be respected when determining a name for a JSON property (reference):

  • use lowerCamelCase notation,
  • also for abbreviations: all letters after the first should be lowercase,
  • use American English,
  • do not use underscores (_), hyphens (-) or dots (.) in a property name, nor use a digit as first letter,
  • the name should refer to the business meaning of its value in relation to the object in which it is specified, rather than how it is defined and
  • omit parts of the name that are already clear from the context.

Properties used from other standards, like OpenID Connect and OAuth2, are allowed to deviate from this rule.

Handling null values​

When a property is set to null, that property shouldn't be sent. For instance

{
"name": "CompanyName",
"country": "Belgium",
"employerId": null
}

In this case, the employerId key can be omitted.

Don't return an array for the answer​

See also the next chapter.

In order to make the API flexible, make sure the answer is a JSON object { ... } and not an array [ ... ] because that last one won't allow adding extra properties in the future (reference).

Top-Level​

https://jsonapi.org/format/#document-top-level

A document MUST contain at least one of the following top-level members:

  • data: the document's primary data. This node has to be renamed to better describe the object, f.i. employees or countries, ...
  • errors: an array of error objects
  • meta: a metaobject that contains non-standard meta-information.

Never return both `data` and `errors`

If the query has generated an error, it is not expected to return any data. And vice versa.

The document's primary data is a representation of the resource or collection of resources targeted by a request.

data can be an array or not:

{
"data": [
{
"key1": "value1",
"key2": "value2"
},
{
"key1": "value1",
"key2": "value2"
}
]
}

or, if just one record, directly:

{
"data": {
"key1": "value1",
"key2": "value2"
// ...
}
}

Most of the time, it's the developer choice for an array

In fact, most of the time, an array is returned, as this allows the API to be modified in the future to return other results without generating BC (break changes) in the API.

Data​

https://jsonapi.org/format/#document-top-level

As described in Top-Level items, the returned information should be put as a top-level node called data:

Best is to always use the array notation so we can return one or more rows.

'data': [
[...]
]
Returning an error​

https://jsonapi.org/format/#document-top-level

https://jsonapi.org/format/#error-objects

As described in Top-Level items, errors should be put as a top-level node and use the plural form:

"errors": [
[...]
]

Prefer to use HTTP status code 400 Bad Request when the call was incorrect like an unknown value is used for a parameter (like &lang=it when Italian isn't supported). Use code 500 Internal Server Error when the server has returned an error.

errors has to be an array so we can return more than once and should contain at least one of these keys (not exhaustive):

  • status: the HTTP status code applicable to this problem, expressed as a string value. This SHOULD be provided.
  • code: an application-specific error code, expressed as a string value.
  • title: a short, human-readable summary of the problem that SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization.
  • parameter: a string indicating which URI query parameter caused the error.
"errors": [
{
"status": "400",
"code": "ERR_INVALID_VIEW",
"title": "Language code 'it' is not supported",
"parameter": "lang"
},
[...]
]

The response of a paginated page​

When using pagination, the JSON answer of GET https://xxx/api/v1/employees?page=2&pageSize=2 can be something like this (reference):

{
"self": "/api/v1/employees?page=2&pageSize=2",
"data": [
{
"href": "/api/v1/employees/3",
"id": 3,
"firstname": "John",
"lastname": "Doe"
},
{
"href": "/api/v1/employees/4",
"id": 4,
"firstname": "Jane",
"lastname": "Doe"
}
],
"pageSize": 2,
"page": 2,
"total": 7,
"first": "/api/v1/employees?pageSize=2",
"last": "/api/v1/employees?page=4&pageSize=2",
"prev": "/api/v1/employees?page=1&pageSize=2",
"next": "/api/v1/employees?page=3&pageSize=2"
}
KeyDescription
nextMANDATORY (except for the last page) hyperlink to the next page
prevOPTIONAL hyperlink to the previous page
pageSizeRECOMMENDED Maximum number of items per page. For the last page, its value should be independent of the number of actually returned items.
pageMANDATORY (offset-based); N/A (cursor-based) Index of the current page of items, should be 1-based (the default and first page is 1)
firstOPTIONAL Hyperlink to the first page
lastOPTIONAL Hyperlink to the last page
totalOPTIONAL Total number of items across all pages. If query parameters are used to filter the result set, this is the total of the collection result set, not of the entire collection.

Expected endpoints​

Each REST API should expose a GET /api/v1/health endpoint which returns the availability of the API's functionality.

StatusStatus CodeDescription
UP200The API is functioning as expected.
DEGRADED200The API is partly unavailable, but service can be continued with reduced functionality.
DOWN503The API is suffering unexpected failures.

Some lectures​


πŸ¦‹ Join the conversation on BlueskyRequires a free Bluesky account to like, repost or comment. This block talks to Bluesky's public API directly from your browser (no cookies, no sign-in) β€” that means your IP address reaches Bluesky's servers just by this section loading.

Loading comments…

Was this article helpful?
Did you try the steps in this article?