1. Why lock down the JSON contract
When people hear “JSON API”, they often think: “Well it’s simple: send JSON, receive JSON.” But “just JSON” is like “just agreeing to meet”: without time, address, and the wording “in what format are you expecting me”, the meeting turns into a TV series. A JSON API contract is not only “which fields”, but also “which headers”, “what to do on error”, “how the client recognizes a problem without parsing text word by word”.
If you don’t lock down the contract, you get the classic bug: the frontend expects the done field, the backend renamed it to isDone, there are no tests, and the user “for some reason” keeps seeing “not done”. And that’s still the better scenario.
To make the contract readable and stable, today we’ll lock down three things:
- how and when to use Content-Type and Accept,
- how to design JSON request and response bodies (via DTOs),
- how to return errors consistently via an error envelope: { "error": { "code": "...", "message": "...", "fields": { ... } } }.
2. JSON API headers: Content-Type and Accept
HTTP headers seem “secondary” to many people because “the body is what matters”. But in reality headers are like a label on a shipping box. Sure, you can deliver a package without a label, but then people will shake it, sniff it, and guess: “is this definitely a mug and not a brick?”. Headers help you avoid guessing.
Content-Type: what it is and why it matters
Content-Type is a header about what you are sending in the body. If you send JSON in a request (for example, you create a task), your request must honestly say: “there’s JSON inside”.
A typical example:
POST /api/v1/tasks HTTP/1.1
Content-Type: application/json
{"title":"buy milk"}
If Content-Type is missing or it says something else, the server has every right to say: “I don’t know how to read this” — and return an error. Most often in HTTP this uses status 415 Unsupported Media Type, but for us right now the key is not the numerology; it’s the contract idea.
An important nuance: if a request has no body (for example, many GET requests), then Content-Type usually isn’t needed. Don’t send Content-Type: application/json “just in case”: it’s like labeling an empty envelope “there’s a pastry inside”.
Accept: what it is and why it matters
Accept is a header about what the client wants to receive. In a simple JSON API, most often the client says: “I expect application/json”.
Example:
GET /api/v1/tasks HTTP/1.1
Accept: application/json
If the client does not specify Accept, the server may still return JSON (especially if the API is strictly JSON-only), but a solid contract prefers explicitness.
An important psychological point: Content-Type is “what I brought you”, Accept is “what I want from you”. These are two different roles, like “hand in documents” and “receive a receipt”.
Mini table to remember
So your brain doesn’t try to hold this as “header magic”, it’s handy to see a short table:
| Header | Who sets it | What it’s about | When it’s needed |
|---|---|---|---|
|
the body sender | body format (e.g., JSON) | when there is a body |
|
the client | expected response format | almost always, if the format matters to the client |
3. Request/response formats: why DTOs
When you design an API, it’s very tempting to take your domain structure Task and just add json:"..." tags so that “everything just works”. It looks like saving time, but usually it’s like taking a loan at 300% APR: a small joy now and a big renovation later.
The reason is simple: the domain model is responsible for meaning and business rules, while the JSON contract is responsible for the external communication format with the client. Those are different responsibilities. If you glue them together, you start being afraid to change the domain because “I’ll break the API”, and afraid to change the API because “I’ll break the domain”. You end up with an architectural sandwich made of fear.
That’s why we introduce DTOs (Data Transfer Object): structures for JSON “on the outside”. Domain types live “on the inside” and don’t have to know what JSON is.
DTOs for tasks
Let’s imagine our training app “task tracker” (a task list). At the API level we want to:
- create a task by title,
- get a task,
- get a list of tasks.
Minimally clear fields for JSON:
- id — a number,
- title — a string,
- done — a boolean.
Let’s create a DTO:
package main
type TaskDTO struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
And for the create-task request (usually the client does not send id — the server assigns it):
package main
type CreateTaskRequest struct {
Title string `json:"title"`
}
Notice: we intentionally do not use title,omitempty — because an “empty title” is most often an error for a task, and we want missing/empty values to surface explicitly during validation rather than “disappearing”.
What JSON looks like without a server
We are not starting an HTTP server yet (that will be a separate story), but we can debug the JSON format already “in a vacuum”.
package main
import (
"encoding/json"
"fmt"
)
func main() {
req := CreateTaskRequest{Title: "buy milk"}
b, _ := json.MarshalIndent(req, "", " ")
fmt.Println(string(b))
// {
// "title": "buy milk"
// }
}
This is a simple but very useful technique: you literally see what you are promising the client.
4. A single error envelope: the error contract
Errors are the place where an API most often turns into a “zoo”. One endpoint returns {"error":"bad"}, another returns {"message":"oops"}, a third returns plain text like “panic: runtime error”. The client side starts writing: “if there is an error field — it’s an error; if there is a message field — also an error; if HTML comes back — well… probably an error too”.
To avoid that, we lock down a single error format that is the same across the entire API. That is the error envelope.
The idea fits well with the Go approach “errors are values”: errors can be constructed, wrapped, classified, and displayed consistently.
Canonical format
Our module contract:
{
"error": {
"code": "...",
"message": "...",
"fields": { "...": "..." }
}
}
Rules:
- error.code — a machine code (for client logic),
- error.message — short safe text (for humans),
- error.fields — appears only for validation errors, as “field → problem”.
This matters: fields must not turn into a junk drawer for “anything”. The client will expect that fields means “errors for specific input fields”.
Envelope structures in Go
We describe the envelope so that the JSON reliably matches the contract.
package main
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
Fields map[string]string `json:"fields,omitempty"`
}
type ErrorEnvelope struct {
Error APIError `json:"error"`
}
Why is Fields marked with omitempty? Because in most errors fields is not needed, and we don’t want to always send "fields": {}. Emptiness should remain empty.
Why you need both code and message
Sometimes you want to keep only message, because “a human reads it”. But a computer reads it too (a client app, scripts, integrations). If the client wants to distinguish not_found from validation, it should not have to parse a string and look for “not found” like it’s a quest.
And sometimes you want to keep only code and remove message, because “let the frontend developer write the texts”. That’s also bad: you lose a consistent UX, and different clients start showing different messages. For a small training API, it’s more useful for the server to return both a machine code and a short message.
This is a good place to recall a Go practice idea: as soon as you make something recognizable and stable, you turn it into part of the API contract, and changing it later is painful. That’s why we lock down the error envelope as a “contract”.
5. Error examples: validation, not_found, internal
It’s useful to “see errors with your eyes”, not just describe them in words. We’ll do this again with json.MarshalIndent, so you can verify the JSON shape without starting a server.
Validation error with fields
A typical scenario: the client sent an empty title.
package main
import (
"encoding/json"
"fmt"
)
func main() {
env := ErrorEnvelope{
Error: APIError{
Code: "validation",
Message: "invalid request",
Fields: map[string]string{"title": "must not be empty"},
},
}
b, _ := json.MarshalIndent(env, "", " ")
fmt.Println(string(b))
// {
// "error": {
// "code": "validation",
// "message": "invalid request",
// "fields": {
// "title": "must not be empty"
// }
// }
// }
}
Notice two things.
First, fields is a map, not an array of strings. This is convenient for the client: you can highlight a specific form field.
Second, message here is general (“invalid request”), and the details are in fields. This helps avoid turning message into a 12-volume novel.
Not found: without fields
When a resource is not found (for example, there is no task with such an id), fields is usually not needed: this is not “an error of a specific input field”, but the absence of an entity.
package main
import (
"encoding/json"
"fmt"
)
func main() {
env := ErrorEnvelope{
Error: APIError{
Code: "not_found",
Message: "resource not found",
},
}
b, _ := json.MarshalIndent(env, "", " ")
fmt.Println(string(b))
// {
// "error": {
// "code": "not_found",
// "message": "resource not found"
// }
// }
}
This is where omitempty shows itself: fields does not appear in JSON, and the client doesn’t think: “Oh, fields is present, so this must be validation.”
Internal: a safe message
The most common beginner mistake in an API is “just return err.Error() to the outside”. Sometimes it even seems convenient (“it literally says what happened”), but in the real world this means:
- you expose internal details (file paths, SQL, configs),
- you accidentally make the contract depend on the error text,
- and sometimes you expose secrets.
We return a stable message, for example "internal error". Details go to logs, not into the contract. And that is exactly the boundary: what counts as part of the public API, and what is internal implementation.
6. How to connect the error envelope and Go errors
Now an important point: the error envelope is the JSON shape at the system boundary, but inside you still have normal Go errors (error), which you return from functions, wrap, and check via errors.Is/errors.As (you already know this from previous days).
The idea is simple: inside the application you keep a rich error (with context), and at the output (in HTTP) you turn it into one of the public classes:
- validation → 400 + envelope with fields,
- not_found → 404 + envelope without fields,
- internal → 500 + envelope without internal details.
Why? Because “error as a value” lets you keep rich context for the program, but the public contract must be stable and safe.
Mini dictionary of messages by code
To avoid sticking texts “however it comes out” in different places, it’s convenient to keep a small dictionary. This is not “perfect architecture”, it’s simply discipline.
package main
func messageFor(code string) string {
switch code {
case "validation":
return "invalid request"
case "not_found":
return "resource not found"
default:
return "internal error"
}
}
Yes, it looks like “mini i18n”, just without localizations. The main thing is stability.
Envelope constructor
So you don’t have to write ErrorEnvelope{Error: APIError{...}} every time, let’s make a small function:
package main
func newErrorEnvelope(code string, fields map[string]string) ErrorEnvelope {
return ErrorEnvelope{
Error: APIError{
Code: code,
Message: messageFor(code),
Fields: fields,
},
}
}
The point here is not “beauty”, but that the error contract becomes centralized. If errors are formed in one place, the chance of “accidentally changing the format” is much lower.
7. Success response and error in one style
Even without a server, you can show what a typical successful response and a typical error response will look like. This helps keep in mind that an API has two equally important branches: success and error. There is no API “without errors”; there is only an API “with unexpected errors”.
Success: one task
package main
import (
"encoding/json"
"fmt"
)
func main() {
task := TaskDTO{ID: 1, Title: "buy milk", Done: false}
b, _ := json.MarshalIndent(task, "", " ")
fmt.Println(string(b))
// {
// "id": 1,
// "title": "buy milk",
// "done": false
// }
}
Error: invalid request
package main
import (
"encoding/json"
"fmt"
)
func main() {
env := newErrorEnvelope("validation", map[string]string{
"title": "must not be empty",
})
b, _ := json.MarshalIndent(env, "", " ")
fmt.Println(string(b))
// {
// "error": {
// "code": "validation",
// "message": "invalid request",
// "fields": {
// "title": "must not be empty"
// }
// }
// }
}
The error shape does not depend on the endpoint. That’s the main value: the client can write one error handler and reuse it everywhere.
Where the JSON API contract lives: a diagram
Sometimes it’s useful to see a picture so your brain stops “chewing on this” as pure abstraction.
flowchart LR
C[Client] -->|HTTP request + Accept + JSON body| S[Server]
S -->|2xx + JSON DTO| C
S -->|4xx/5xx + JSON ErrorEnvelope| C
The point of the diagram: the client always expects JSON (via Accept), the server always responds either with DTO data or with an error envelope. “Always the same error shape” is not pedantry; it’s what saves you weeks of integration work.
8. Common mistakes
Mistake #1: confusing Content-Type and Accept (or setting both “just in case”).
When a request has a JSON body, Content-Type: application/json is mandatory; otherwise the server has to guess the format. Accept is the client’s request for the response format. If you set headers mechanically, it’s easy to end up in a situation where the client “asks for JSON” but sends the body as “who knows what”, and then is surprised the server can’t read the data.
Mistake #2: returning different error formats across different endpoints.
This often starts innocently: “here I’ll return a string, there I’ll return an object”. A month later the client has three error handlers and a nervous tic. A single error envelope is discipline that makes an API predictable. Once you’ve locked it down, treat it as a public contract, not as a “draft you can improve”.
Mistake #3: turning message into a dump of details (or, conversely, having only code).
If message contains internal details ("sql: no rows", file paths, pieces of a stack trace), you either expose too much or accidentally make the error text part of the contract. If you keep only code, you get inconsistent client messages and lose a unified UX. A good pair is code for logic and message for humans.
Mistake #4: using fields for the wrong purpose.
fields should mean “validation errors for specific input fields”. If you start stuffing anything in there (for example, "storage": "down"), the client will stop trusting the field and will be forced to write hacks. In the end you lose the main advantage: the ability to highlight specific input problems to the user.
Mistake #5: leaking an internal error outward via err.Error().
It’s useful to wrap, log, and analyze internal errors inside the server — in Go this is natural, because errors are values and can be structured. But outward, in the HTTP contract, you should return a stable and safe message. Otherwise you “bake” the implementation into the public API and create obligations you never planned to take on.
GO TO FULL VERSION