Requests, Responses and Data · Lesson 7 of 30
JSON and Content Negotiation in REST APIs
Almost every REST API today speaks JSON. It's easy to produce and parse, but "JSON" alone doesn't settle the questions that cause real bugs: how to send dates, money, empty values and large numbers, and what to do when a client sends – or asks for – a different format. This lesson sets the conventions and shows the headers that negotiate formats.
In this lesson
- JSON conventions that avoid bugs
- Content-Type and Accept
- Content negotiation, 406 and 415
- Envelopes: wrap or not?
JSON conventions
| Data | Send | Why |
|---|---|---|
| Dates and times | ISO 8601 in UTC: "2026-11-08T09:30:00Z" | unambiguous, sortable, every language parses it |
| Dates only | "2026-12-01" | no time zone confusion for birthdays or due dates |
| Money | integer minor units + currency: {"amount": 149900, "currency": "INR"} | floating point can't represent 0.1 exactly |
| Large ids | strings: "id": "9007199254740993" | JavaScript loses precision above 253 |
| Missing values | pick null or omit – and document which | clients must handle both consistently |
| Enums | stable strings: "status": "active" | readable and extendable, unlike numbers |
Content-Type and Accept
Content-Type describes the body you're sending; Accept says what you'd like to receive. A careful API checks both:
// server.js
import express from 'express';
const app = express();
app.use(express.json());
const report = [
{ region: 'North', revenue: 125000 },
{ region: 'South', revenue: 98000 },
];
// The client chooses the format with Accept
app.get('/api/reports/revenue', (req, res) => {
res.format({
'application/json': () => res.json(report),
'text/csv': () => res.type('text/csv').send('region,revenue\n' + report.map((r) => `${r.region},${r.revenue}`).join('\n')),
default: () => res.status(406).json({ error: 'Supported formats: application/json, text/csv' }),
});
});
// The server insists on JSON input
app.post('/api/feedback', (req, res) => {
if (!req.is('application/json')) {
return res.status(415).json({ error: 'Send the body as application/json' });
}
res.status(201).json({ received: req.body, at: '2026-11-08T09:30:00Z' });
});
app.listen(process.env.PORT ?? 3000);
$ curl http://localhost:3000/api/reports/revenue -H 'Accept: text/csv'
HTTP/1.1 200 OK
Vary: Accept
Content-Type: text/csv; charset=utf-8
region,revenue
North,125000
South,98000
$ curl http://localhost:3000/api/reports/revenue -H 'Accept: application/xml'
HTTP/1.1 406 Not Acceptable
Vary: Accept
Content-Type: application/json; charset=utf-8
{
"error": "Supported formats: application/json, text/csv"
}
$ curl -X POST http://localhost:3000/api/feedback -H 'Content-Type: text/plain' -d 'great course'
HTTP/1.1 415 Unsupported Media Type
Content-Type: application/json; charset=utf-8
{
"error": "Send the body as application/json"
}
$ curl -X POST http://localhost:3000/api/feedback -H 'Content-Type: application/json' -d '{"rating":5}'
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
{
"received": {
"rating": 5
},
"at": "2026-11-08T09:30:00Z"
}res.format()picks a representation based onAccept– the same resource as JSON or CSV. Unsupported requests get406 Not Acceptable.- A body in a format you don't parse gets
415 Unsupported Media Typerather than being silently ignored. - When you serve several representations, also send
Vary: Acceptso caches keep them apart.
To wrap or not to wrap
# bare resource # envelope
{ "id": 7, "title": "REST API" } { "data": { "id": 7, "title": "REST API" },
"meta": { "requestId": "req_81" } }
Bare resources are simpler and work naturally with HTTP (status in the status line, metadata in headers). Envelopes help when you need metadata in the body – most often for lists: {"data": [...], "page": {...}}. A common, sensible compromise: bare objects for single resources, an envelope for collections. Whatever you choose, use it everywhere.
Be strict in what you send, tolerant in what you receive
Always respond with the same, documented shape. When receiving, ignore unknown fields rather than rejecting them – that lets clients and servers evolve independently. But validate the fields you do use (next chapter).
Practice
Try it yourself
Return a product price as minor units with currency, plus a display string, from
GET /api/products/pen.Show solution
// server.js import express from 'express'; const app = express(); app.get('/api/products/pen', (req, res) => { const amount = 4999; // ₹49.99 in paise res.json({ id: 'pen', price: { amount, currency: 'INR', display: `₹${(amount / 100).toFixed(2)}` }, updatedAt: '2026-11-01T10:00:00Z', }); }); app.listen(process.env.PORT ?? 3000);$ curl http://localhost:3000/api/products/pen HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "id": "pen", "price": { "amount": 4999, "currency": "INR", "display": "₹49.99" }, "updatedAt": "2026-11-01T10:00:00Z" }
Quick check
Which header says what format a client wants back?
Accept.
What status for a body in an unsupported format?
415 Unsupported Media Type.
Why not send money as 49.99?
Floating-point numbers can't represent many decimals exactly; use integer minor units plus a currency.
Summary
- Use ISO 8601 UTC dates, integer money with currency, string ids and stable enum strings.
- Content-Type describes the body sent; Accept the body wanted. Answer with 406/415 when you can't comply.
- Pick one response shape – bare or enveloped – and apply it consistently.
Finished this lesson?
Mark it complete to track your progress, then carry on with the next lesson.