LearnFrenzy
Sign in Take a free test

Requests, Responses and Data · Lesson 7 of 30

JSON and Content Negotiation in REST APIs

4 min read Updated Nov 8, 2026 Saurabh Samir

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

DataSendWhy
Dates and timesISO 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
Moneyinteger minor units + currency: {"amount": 149900, "currency": "INR"}floating point can't represent 0.1 exactly
Large idsstrings: "id": "9007199254740993"JavaScript loses precision above 253
Missing valuespick null or omit – and document whichclients must handle both consistently
Enumsstable 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);

Real responses

$ 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 on Accept – the same resource as JSON or CSV. Unsupported requests get 406 Not Acceptable.
  • A body in a format you don't parse gets 415 Unsupported Media Type rather than being silently ignored.
  • When you serve several representations, also send Vary: Accept so 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

  1. 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);

    Real response

    $ 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.

Next lesson

Search LearnFrenzy

Esc

Quick links