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.
This article is over a year old but was reviewed on July 30, 2026 β the content is still accurate.
This article is part of the Building and testing REST APIs series:
Changelog

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)
| GOOD | BAD |
|---|---|
https://xxx/api/v1/ipsoLorem/331 | https://xxx/api/v1/Ipso_Lorem/331 |
https://xxx/api/v1/ipso-lorem/331 | |
https://xxx/api/v1/ipsoLorem/331 | https://xxx/api/v1/ipsoLorem/331/ |
https://xxx/api/v1/ipsoLorem?country=BE | https://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:
| GOOD | BAD |
|---|---|
/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/titlePUT /api/v1/articlesDELETE /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β
| Term | Description | Example | Ref |
|---|---|---|---|
page | When a collection resources is paged, use this parameter to request a specific page. Page numbers are 1-based. | ?page=3&pageSize=20 | Pagination |
pageSize | When a collection resources is paged, use this parameter to specify the page size. | ?page=3&pageSize=20 | Pagination |
q | The standard search parameter to do a full-text search. | ?q=RESTFull | Filtering |
select | Filter the resource properties to the ones specified. | ?select=(name,address) | Consult (Document) |
sort | Multi-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=-age | Collection |
embed | Request 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=mainAddress | Embedding resources |
lang | language to filter multi-language descriptions | ?lang=fr | Multi-language descriptions |
Verbsβ
HEADβ
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:
HEAD - Returned codeβ
If we get an HTTP return code 200, we can run a GET action to get records:
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.
| Code | Status | Reason |
|---|---|---|
200 | OK | The server has successfully processed the request. This means that the requested resource exists. |
401 | Unauthorized | The endpoint is secured, and the request didn't pass a valid access token. |
403 | Access denied | You're not allowed to access to the requested resource. |
404 | Not found | The 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:
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:
Return the employee #1:
GET is a strict read-only method, which should never modify the state of the resource (reference).
GET - Returned codeβ
| Code | Status | Reason |
|---|---|---|
200 | OK | The server has successfully processed the request. The requested resource exists and is returned to you. |
401 | Unauthorized | The endpoint is secured, and the request didn't pass a valid access token. |
403 | Access denied | You're not allowed to access to the requested resource. |
404 | Not found | The 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).
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.
| Code | Status | Reason |
|---|---|---|
201 | Created | The resource has been successfully created. |
303 | See Other | The resource already exists, and a link is provided to the resource. 303 is used to denote it's a normal check. |
401 | Unauthorized | The endpoint is secured, and the request didn't pass a valid access token. |
403 | Access denied | You're not allowed to call this method. |
409 | Conflict | The 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:
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β
| Code | Status | Reason |
|---|---|---|
200 | OK | The record was successfully updated and the updated version of the resource is returned. |
204 | No Content | The record was successfully updated and nothing is returned as HTTP response. |
400 | Bad request | The request is incorrect (f.i. you're trying to update a field first_name while its correct name is firstname). |
401 | Unauthorized | The endpoint is secured, and the request didn't pass a valid access token. |
403 | Access denied | You're not allowed to call this method |
404 | Not found | The 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β
| Code | Status | Reason |
|---|---|---|
200 | OK | The record was successfully updated and the updated version of the resource is returned. |
204 | No Content | The record was successfully updated and nothing is returned as HTTP response. |
400 | Bad request | The request is incorrect (f.i. you're trying to update a field first_name while its correct name is firstname). |
401 | Unauthorized | The endpoint is secured, and the request didn't pass a valid access token. |
403 | Access denied | You're not allowed to call this method |
404 | Not found | The 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:
DELETE - Returned codeβ
| Code | Status | Reason |
|---|---|---|
200 | OK | The record was successfully deleted and a message like The resource has been successfully deleted is returned. |
204 | No Content | The record was successfully deleted and nothing is returned as HTTP response. |
400 | Bad request | The syntax of the request is invalid. |
401 | Unauthorized | The endpoint is secured, and the request didn't pass a valid access token. |
403 | Access denied | You're not allowed to call this method. |
404 | Not found | The requested resource didn't exist (f.i. you tried to access /api/v1/employees/1 and that one didn't exist). |
409 | Conflict | The 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.,
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.
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β
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.employeesorcountries, ...errors: an array of error objectsmeta: 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β
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β
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"
}
| Key | Description |
|---|---|
next | MANDATORY (except for the last page) hyperlink to the next page |
prev | OPTIONAL hyperlink to the previous page |
pageSize | RECOMMENDED Maximum number of items per page. For the last page, its value should be independent of the number of actually returned items. |
page | MANDATORY (offset-based); N/A (cursor-based) Index of the current page of items, should be 1-based (the default and first page is 1) |
first | OPTIONAL Hyperlink to the first page |
last | OPTIONAL Hyperlink to the last page |
total | OPTIONAL 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.
| Status | Status Code | Description |
|---|---|---|
UP | 200 | The API is functioning as expected. |
DEGRADED | 200 | The API is partly unavailable, but service can be continued with reduced functionality. |
DOWN | 503 | The API is suffering unexpected failures. |
Some lecturesβ
This article is part of the Building and testing REST APIs series:
Loading commentsβ¦



