1. API documentation as part of the contract
If tests are a way to prove to a computer that the API works as intended, documentation is a way to prove it to a human. And not only to an API user, but also to future you two weeks from now, when you forget why POST /done returns 204 rather than 200. Documentation is not a three-volume novel; it is a short, strict “agreement”: what endpoints exist, which headers are required, which responses are considered normal, and what errors look like.
Most often, documentation lives in the project’s README.md, sometimes in a separate file like docs/api.md. For a learning project, this is more than enough: we are not building a 40-page developer portal, we are building a clear contract that can be verified by hand via curl and that matches what the tests verify.
To avoid confusion in terms, let’s agree on a simple principle right away: if a behavior matters to the client, it must be described. If it matters only to the server (internal details), it must not “leak” outward—neither into the documentation nor into the responses.
Minimum documentation skeleton for our Task API
When you open documentation, the reader’s brain tries to answer three questions in the first 10 seconds: “where do I call?”, “how do I authenticate?”, and “what do I get back?”. If those answers are buried in the middle, assume the reader has already walked away to make tea (and tea won).
Below is a simple skeleton that works well for almost any JSON API, including our learning task service. I’ll show it as a “template” you can keep in README.md.
Header: base URL, version, format
Let’s assume that locally the service listens on http://localhost:8080.
| Parameter | Value |
|---|---|
| Base URL | |
| API prefix | |
| Data format | JSON (UTF-8) |
| Error format | JSON error envelope (see below) |
| Auth | header X-API-Key: <key> |
Important: even if you are only running the service locally for now, it is still useful to write the base URL explicitly. It cuts the number of “why am I getting 404?” questions by about half.
2. curl examples that actually work
curl is like a Swiss Army knife: you can slice a salad, fix a bike, and accidentally cut off a finger if you mix up the method and the URL. In documentation, it is valuable because it provides a reproducible scenario. The reader can copy the command and see the same result (if the conditions are met: the server is up, the key is correct, the routes match).
Most often, examples need four things: method, URL, headers, and request body. In practice, it looks like this:
curl -i \
-X POST 'http://localhost:8080/api/v1/tasks' \
-H 'Content-Type: application/json' \
-H 'X-API-Key: dev-secret' \
-d '{"title":"Buy milk"}'
Here -i asks to show the status and response headers (this is convenient because documentation is not only the JSON body, but also the status code). We set the Content-Type header so the server clearly understands it received JSON, not “just text, but we feel like it’s JSON”.
One more important point: in documentation it’s better to show short commands. If a command grows to 15 lines with tricky substitutions, the reader starts to suspect it is a magical demon-summoning ritual rather than an API request.
4. Authorization: one header, one meaning, zero surprises
Authorization in learning APIs is often “bolted on from the side” and turns into chaos: sometimes you need Authorization, sometimes a cookie, sometimes a ?token=... parameter, sometimes “today without a key because I’m testing”. This quickly kills documentation, testability, and common sense.
We lock in a simple contract: every request to /api/v1/... requires an API key in a header:
- header: X-API-Key: <key>
- if the key is missing or invalid, the client receives an error in our unified format
Example request without a key (we expect an error, and that’s normal):
curl -i 'http://localhost:8080/api/v1/tasks'
Example request with a key:
curl -i \
-H 'X-API-Key: dev-secret' \
'http://localhost:8080/api/v1/tasks'
The important philosophical part (yes, APIs can have philosophy): outwardly we return a clear message, but we do not reveal more than necessary. The general idea “don’t expose internal details as a contract” is useful when designing errors: if you expose internals, you accidentally promise the client that “it will always be like this”, and that is a dangerous promise.
5. Our API endpoints: table + curl examples
To make documentation scannable, it’s convenient to show an endpoint table first and then examples. The table is the “map”, and the examples are “how to walk the road without falling into a ditch”.
Endpoint table
| Method | Path | Purpose | Success |
|---|---|---|---|
|
|
service liveness check | |
|
|
create a task | + task JSON |
|
|
list tasks (with filters) | + JSON array |
|
|
get a task by id | + task JSON |
|
|
mark a task as done | |
|
|
delete a task | |
Notice that we deliberately write {id} in curly braces rather than “42”, so it’s clear this is a path parameter. The routing tests you wrote using ServeMux patterns should confirm that {id} is actually extracted and handled.
6. curl examples: “happy path” scenarios
Health check
Health is a useful thing for monitoring and for “did I even start the server, or am I talking to myself?”.
curl -i 'http://localhost:8080/health'
# HTTP/1.1 204 No Content
There is intentionally no body here: 204 means “everything is fine, but there is no content”.
Creating a task
Create a task Buy milk. Consider this “Hello, world” for a todo service.
curl -i \
-X POST 'http://localhost:8080/api/v1/tasks' \
-H 'Content-Type: application/json' \
-H 'X-API-Key: dev-secret' \
-d '{"title":"Buy milk"}'
An example of the expected response (you can adapt the format to your model; the main thing is that it stays stable):
{
"id": 1,
"title": "Buy milk",
"done": false
}
What matters here is not the specific number 1, but that the client understands the structure: there is id, title, done. If you add fields later, that should also be reflected in the documentation.
Getting the task list
curl -i \
-H 'X-API-Key: dev-secret' \
'http://localhost:8080/api/v1/tasks'
Response:
[
{ "id": 1, "title": "Buy milk", "done": false }
]
If you support filters (for example, done or contains), the documentation should show at least one example so the client does not have to guess “what was intended”:
curl -i \
-H 'X-API-Key: dev-secret' \
'http://localhost:8080/api/v1/tasks?done=false'
Getting a task by id
curl -i \
-H 'X-API-Key: dev-secret' \
'http://localhost:8080/api/v1/tasks/1'
Response:
{ "id": 1, "title": "Buy milk", "done": false }
Mark a task as done
We use a separate endpoint /done so the action reads like a “command” (and so the client doesn’t agonize over whether it should be PATCH or POST). We return 204 because often it’s enough for the client to know the operation succeeded.
curl -i \
-X POST \
-H 'X-API-Key: dev-secret' \
'http://localhost:8080/api/v1/tasks/1/done'
# HTTP/1.1 204 No Content
Deleting a task
Deletion is convenient to make semantically idempotent: if the task is already gone, you can return 404, or you can agree to still return 204. But whatever choice you make, it must be described and verified by tests.
Let’s say our agreement is: if the task doesn’t exist, return 404. Then a “normal delete” looks like this:
curl -i \
-X DELETE \
-H 'X-API-Key: dev-secret' \
'http://localhost:8080/api/v1/tasks/1'
# HTTP/1.1 204 No Content
7. Errors as part of the API: a unified error envelope and a status table
Errors are not a “crash that’s embarrassing to show”—they are a normal execution branch. The client must be able to handle “not found”, “invalid”, “no access”, “something failed internally”. That’s why we fix a single response format so the client can parse errors the same way for all endpoints, without playing a guessing game.
Format (reminder: our contract):
{
"error": {
"code": "validation",
"message": "invalid request",
"fields": { "title": "must not be empty" }
}
}
The fields field is present only for validation errors. In other cases it is either absent or empty.
Table: status → error.code → meaning
| HTTP status | error.code | When it happens |
|---|---|---|
|
|
invalid input data (including id not being a number) |
|
|
missing X-API-Key |
|
|
the key is present, but not accepted (if you distinguish 401/403) |
|
|
resource not found |
|
|
server-side error; we do not expose details |
A key security principle: for 500-class errors, the message for the client must be stable and safe, and the details must go to logs. This approach is a classic in the Go world: a clear message and a correct status for the user; detailed diagnostics separately for the developer.
One more important thought: if in one place you return {"error":"...string..."}, and in another {"message":"..."}, the client starts writing a “zoo of parsers”, and then begins to hate you (and a little bit themselves). That’s why we stick to a single envelope.
8. curl error examples and why you should document them
Validation error (400)
Try to create a task with an empty title:
curl -i \
-X POST 'http://localhost:8080/api/v1/tasks' \
-H 'Content-Type: application/json' \
-H 'X-API-Key: dev-secret' \
-d '{"title":""}'
We expect:
{
"error": {
"code": "validation",
"message": "invalid request",
"fields": { "title": "must not be empty" }
}
}
This matters in documentation for two reasons. First, the client understands what to do (fix the field). Second, the client understands what exactly to parse: fields.title, rather than “extract text with a regex from message” (regexes in responses are a special kind of suffering).
Not found (404)
Request a task that doesn’t exist:
curl -i \
-H 'X-API-Key: dev-secret' \
'http://localhost:8080/api/v1/tasks/999'
Response:
{
"error": {
"code": "not_found",
"message": "task not found"
}
}
Authorization error (401/403)
Request without a key:
curl -i 'http://localhost:8080/api/v1/tasks/1'
Response (example):
{
"error": {
"code": "unauthorized",
"message": "missing api key"
}
}
If you distinguish between “missing key” and “invalid key”, then in the second case:
curl -i \
-H 'X-API-Key: wrong-key' \
'http://localhost:8080/api/v1/tasks/1'
Response (example):
{
"error": {
"code": "forbidden",
"message": "invalid api key"
}
}
What matters here is not so much “what exactly to call it”, but that you lock in the behavior and then don’t change it accidentally.
Internal error (500): why you must not “show err.Error()” to the client
A 500 error in a good API looks boring. And that’s a compliment. It should be as stable in shape as possible:
{
"error": {
"code": "internal",
"message": "internal error"
}
}
If instead you return the real cause such as "dial tcp: connection refused", the client starts depending on the text, and you accidentally expose details you do not want to promise publicly. The recommendation “don’t expose internal details as a contract” is very practical here: what is an internal implementation detail must not become a public promise.
9. Useful nuances: X-Request-ID and “living” documentation
X-Request-ID in documentation
Sometimes it feels like request id is “something for large microservices”, while our learning server is “small anyway”. In practice, X-Request-ID is a super-useful thing even in a small service: it makes it easy to find a request in logs, and in tests you can verify that middleware works.
In documentation, one example is enough: “how to pass a request id” and “that the server will return it back”.
curl -i \
-H 'X-API-Key: dev-secret' \
-H 'X-Request-ID: test-123' \
'http://localhost:8080/api/v1/tasks'
And you expect the response headers to include:
X-Request-ID: test-123
From the client’s perspective, this is transparent and useful functionality; from the server’s perspective, it is discipline that saves hours of debugging.
How to keep documentation “alive”
Documentation usually breaks not because people are bad, but because “we tweaked the handler, tests are green, and README was forgotten”. So the most practical approach is to tie documentation to what is already protected by tests.
When you change API behavior (status code, a field in JSON, error format), you have three “signals”: integration tests, unit tests, and common sense. If tests truly verify the contract, changing the contract almost always forces changes to tests. And since you already went to update the tests, it’s a good moment to update the curl example in the README as well, because it is essentially the same scenario, just for a human.
And yes, it’s normal that documentation sometimes lags a bit. What’s not normal is when it lags forever and turns into API fan fiction.
10. Common mistakes when documenting an API
Mistake #1: documentation describes “how we implemented it right now” instead of “how the client should use it”.
It’s very easy to drift into implementation details: “we have ServeMux there, then writeJSON, then parseID…”. This does not help the client. The client needs methods, paths, headers, JSON, statuses, and errors. Everything else is your internal adventure.
Mistake #2: curl examples cannot be run as-is.
The most painful kind of bad documentation: the commands look nice, but they are missing the base URL, missing the required auth header, or forgot Content-Type. The reader copies it, gets 401/415/400, and concludes the API is broken (and sometimes that they themselves are broken, which is even sadder).
Mistake #3: errors are described in words, but not in structure.
The phrase “in case of an error, the server returns JSON with a message” is almost useless. Useful is: which HTTP status, which error.code, which error.message, and when fields appears. If the client is writing an error handler, they must understand what to parse.
Mistake #4: leaking internal errors in 500 responses.
When the server returns err.Error() to the outside world for an internal error, it may seem convenient (“at least it’s clear!”), but it turns internals into a public contract and may expose unnecessary information. It’s better to keep a fixed message externally and put details in logs.
Mistake #5: no authorization example, or “floating” authorization.
If today you need X-API-Key, tomorrow “it’s optional”, and the day after “only if the Moon is in its third phase”, the client application quickly turns into a pile of hacks. One header, one meaning, one example—and everyone is calmer.
GO TO FULL VERSION