jsonrest-apihttpapi-designcontent-typeerror-handlingtutorial

Working with JSON in REST APIs: The Complete Guide

JSON Tools Team
12 min read

JSON and REST APIs go hand in hand. Since Roy Fielding first described the REST architectural style, JSON has become the dominant format for data exchange in web APIs, overtaking XML by a wide margin. Today, the vast majority of public and internal APIs accept and return JSON.

Yet despite JSON's apparent simplicity, working with it in REST APIs involves more nuance than most tutorials cover. Content-Type headers, proper error responses, pagination patterns, status codes, nested relationships, date handling — each of these areas has conventions and pitfalls that affect how robust your API integrations will be.

This guide covers everything you need to know about working with JSON in REST APIs, from basic request and response patterns to advanced topics like error handling, versioning, and performance optimization. Whether you are building APIs or consuming them, you will find practical guidance and code examples you can apply immediately.

What Is a REST API and Why JSON?

A REST (Representational State Transfer) API is an interface that allows clients and servers to communicate over HTTP using standard methods like GET, POST, PUT, PATCH, and DELETE. Each resource (a user, a product, an order) is identified by a URL, and clients interact with resources by sending HTTP requests and receiving responses.

JSON became the standard data format for REST APIs because of several advantages:

  • Human-readable — developers can inspect and debug payloads without special tools.
  • Lightweight — JSON has minimal syntax overhead compared to XML, resulting in smaller payloads.
  • Native to JavaScript — browsers parse JSON natively with JSON.parse(), making it ideal for web clients.
  • Universal support — every major programming language has built-in or standard-library JSON support.
  • Flexible schema — JSON does not require a predefined schema (unlike XML with XSD), making it easier to evolve APIs over time.

HTTP Headers: Content-Type and Accept

The single most important detail when working with JSON APIs is getting the HTTP headers right. Two headers govern how JSON is sent and received:

Content-Type

The Content-Type header tells the server what format the request body is in. For JSON, the correct value is:

Content-Type: application/json

Omitting this header or setting it incorrectly is one of the most common causes of "400 Bad Request" errors. Many APIs will reject the request or try to parse the body as form data, leading to confusing errors.

Accept

The Accept header tells the server what format the client wants in the response. For JSON:

Accept: application/json

While many APIs default to JSON, explicitly setting the Accept header is a best practice. Some APIs support multiple formats (JSON, XML, CSV) and use content negotiation to decide which to return.

Sending JSON in API Requests

Let us look at how to properly send JSON data in the most common HTTP methods.

POST: Creating a Resource

POST requests create new resources. The JSON payload describes the resource to create:

// Creating a new user with fetch
const response = await fetch('https://api.example.com/users', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
    'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIs...'
  },
  body: JSON.stringify({
    name: 'Alice Johnson',
    email: 'alice@example.com',
    role: 'editor'
  })
});

const newUser = await response.json();
console.log(newUser.id); // 42 (server-assigned ID)

Notice that we call JSON.stringify() on the request body. The fetch API does not automatically serialize objects to JSON — you must do this explicitly. Forgetting to stringify is a common source of bugs.

PUT vs. PATCH: Updating a Resource

Both PUT and PATCH update existing resources, but they differ in semantics:

  • PUT replaces the entire resource. You must send all fields, even ones that have not changed.
  • PATCH applies a partial update. You only send the fields you want to change.
// PUT — full replacement (must include all fields)
await fetch('https://api.example.com/users/42', {
  method: 'PUT',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    name: 'Alice Johnson',
    email: 'alice.johnson@newdomain.com',  // changed
    role: 'editor'                          // unchanged but required
  })
});

// PATCH — partial update (only changed fields)
await fetch('https://api.example.com/users/42', {
  method: 'PATCH',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    email: 'alice.johnson@newdomain.com'   // only the field that changed
  })
});

PATCH is generally preferred in modern APIs because it reduces payload size and avoids accidentally overwriting fields with stale data.

GET: Retrieving Resources

GET requests do not have a request body. Any parameters go in the URL as query strings:

// Fetching a list with query parameters
const params = new URLSearchParams({
  status: 'active',
  sort: 'created_at',
  order: 'desc',
  limit: '20',
  offset: '0'
});

const response = await fetch(
  `https://api.example.com/users?${params}`,
  {
    headers: {
      'Accept': 'application/json',
      'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIs...'
    }
  }
);

const data = await response.json();
console.log(data.users.length); // 20

Parsing JSON API Responses

When the server responds, you need to parse the JSON body and handle the various response scenarios correctly.

Checking the Status Code First

Always check the HTTP status code before parsing the response body. A common mistake is calling .json() on every response without checking whether the request actually succeeded:

async function apiRequest(url, options = {}) {
  const response = await fetch(url, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      'Accept': 'application/json',
      ...options.headers
    }
  });

  // Parse the JSON body (both success and error responses)
  let body;
  const contentType = response.headers.get('content-type');
  if (contentType && contentType.includes('application/json')) {
    body = await response.json();
  } else {
    body = await response.text();
  }

  // Throw on error status codes
  if (!response.ok) {
    const error = new Error(
      body?.message || `HTTP ${response.status}: ${response.statusText}`
    );
    error.status = response.status;
    error.body = body;
    throw error;
  }

  return body;
}

// Usage
try {
  const user = await apiRequest('https://api.example.com/users/42');
  console.log(user.name);
} catch (err) {
  if (err.status === 404) {
    console.log('User not found');
  } else if (err.status === 401) {
    console.log('Authentication required');
  } else {
    console.error('API error:', err.message);
  }
}

This wrapper function handles three important details: it sets the correct headers, it checks the Content-Type before parsing, and it throws a structured error for non-2xx responses. Most production applications benefit from a similar utility function.

Designing JSON API Response Structures

Well-designed JSON responses follow consistent patterns that make the API predictable and easy to consume. Here are the most common conventions.

Single Resource

{
  "id": 42,
  "name": "Alice Johnson",
  "email": "alice@example.com",
  "role": "editor",
  "created_at": "2026-01-15T10:30:00Z",
  "updated_at": "2026-02-20T14:22:00Z"
}

Collection with Pagination

{
  "data": [
    { "id": 1, "name": "Alice Johnson", "role": "editor" },
    { "id": 2, "name": "Bob Smith", "role": "viewer" },
    { "id": 3, "name": "Carol White", "role": "admin" }
  ],
  "pagination": {
    "total": 156,
    "limit": 20,
    "offset": 0,
    "has_more": true
  },
  "links": {
    "self": "/users?limit=20&offset=0",
    "next": "/users?limit=20&offset=20",
    "last": "/users?limit=20&offset=140"
  }
}

Wrapping collections in a data property (rather than returning a bare array) allows you to include pagination metadata and links alongside the results. This is the approach recommended by the JSON:API specification and used by most well-designed APIs.

Error Response

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "The request body contains invalid fields.",
    "details": [
      {
        "field": "email",
        "message": "Must be a valid email address.",
        "value": "not-an-email"
      },
      {
        "field": "role",
        "message": "Must be one of: admin, editor, viewer.",
        "value": "superuser"
      }
    ]
  }
}

Good error responses include a machine-readable error code, a human-readable message, and details about specific field-level errors when applicable. This structure enables clients to display targeted error messages to users rather than generic "Something went wrong" alerts.

Error Handling Patterns

Robust error handling is what separates production-quality API integrations from tutorial-level code. Here are the patterns you should implement.

HTTP Status Codes

REST APIs communicate outcomes through standard HTTP status codes. The most important ones for JSON APIs:

  • 200 OK — Request succeeded. Response body contains the result.
  • 201 Created — Resource created successfully. Response body contains the new resource.
  • 204 No Content — Request succeeded with no response body (common for DELETE).
  • 400 Bad Request — Invalid JSON syntax or invalid field values.
  • 401 Unauthorized — Missing or invalid authentication credentials.
  • 403 Forbidden — Authenticated but insufficient permissions.
  • 404 Not Found — The requested resource does not exist.
  • 409 Conflict — The request conflicts with the current state (e.g., duplicate email).
  • 422 Unprocessable Entity — Valid JSON, but semantically invalid data.
  • 429 Too Many Requests — Rate limit exceeded. Check the Retry-After header.
  • 500 Internal Server Error — Server-side error. The client should retry or report the issue.

Retry Logic with Exponential Backoff

For transient errors (429, 500, 502, 503, 504), implement retry logic with exponential backoff:

async function fetchWithRetry(url, options, maxRetries = 3) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url, options);

      // Don't retry client errors (except 429)
      if (response.status >= 400 && response.status < 500
          && response.status !== 429) {
        return response;
      }

      if (response.ok) return response;

      // Retry on server errors and rate limiting
      if (attempt < maxRetries) {
        const retryAfter = response.headers.get('Retry-After');
        const delay = retryAfter
          ? parseInt(retryAfter) * 1000
          : Math.min(1000 * Math.pow(2, attempt), 30000);
        await new Promise(r => setTimeout(r, delay));
        continue;
      }

      return response;
    } catch (networkError) {
      // Retry on network failures
      if (attempt < maxRetries) {
        const delay = Math.min(1000 * Math.pow(2, attempt), 30000);
        await new Promise(r => setTimeout(r, delay));
        continue;
      }
      throw networkError;
    }
  }
}

Handling Dates and Times in JSON

JSON has no native date type, which means dates are transmitted as strings. The universally accepted format is ISO 8601:

{
  "event": "User signed up",
  "timestamp": "2026-03-01T14:30:00Z",
  "local_time": "2026-03-01T09:30:00-05:00"
}

Always use ISO 8601 format with timezone information. The trailing Z indicates UTC. The -05:00 variant indicates a UTC offset. Avoid sending dates as Unix timestamps in user-facing APIs — while they are unambiguous, they are not human-readable when you inspect the JSON.

On the client side, parse ISO 8601 dates carefully:

// Parse ISO 8601 from JSON response
const data = await response.json();
const signupDate = new Date(data.timestamp);

// Format for display (respects user's locale)
const formatted = signupDate.toLocaleDateString('en-US', {
  year: 'numeric',
  month: 'long',
  day: 'numeric',
  hour: '2-digit',
  minute: '2-digit',
  timeZoneName: 'short'
});
// "March 1, 2026, 02:30 PM UTC"

Nested Resources and Relationships

APIs often need to represent relationships between resources. There are two main approaches:

Embedding (Eager Loading)

{
  "id": 42,
  "title": "Quarterly Report",
  "author": {
    "id": 7,
    "name": "Alice Johnson",
    "avatar_url": "https://cdn.example.com/avatars/7.jpg"
  },
  "comments": [
    {
      "id": 101,
      "text": "Great analysis!",
      "author": { "id": 3, "name": "Bob Smith" },
      "created_at": "2026-02-28T16:00:00Z"
    }
  ]
}

Embedding related resources directly in the response reduces the number of API calls the client needs to make. This is ideal when the client almost always needs the related data.

References (Lazy Loading)

{
  "id": 42,
  "title": "Quarterly Report",
  "author_id": 7,
  "comment_ids": [101, 102, 103],
  "links": {
    "author": "/users/7",
    "comments": "/reports/42/comments"
  }
}

References keep the initial response small and let the client fetch related resources only when needed. This is better when related data is large or rarely used.

Many APIs offer both approaches through query parameters like ?include=author,comments or ?expand=author, letting the client decide what to load.

Common Mistakes When Working with JSON APIs

1. Not Setting Content-Type Headers

The number one mistake is forgetting to set Content-Type: application/json. Without it, the server may parse your body as URL-encoded form data, leading to a 400 error or silently incorrect data. Every POST, PUT, and PATCH request with a JSON body must include this header.

2. Sending Strings Instead of JSON

Accidentally sending a stringified-stringified body is more common than you might think:

// WRONG: double-stringified
const body = JSON.stringify(JSON.stringify({ name: 'Alice' }));
// Result: '"{\"name\":\"Alice\"}"' (a JSON string containing escaped JSON)

// CORRECT: single stringify
const body = JSON.stringify({ name: 'Alice' });
// Result: '{"name":"Alice"}'

If your API is receiving string values where you expect objects, check whether you are double-stringifying the request body. Using our JSON Formatter to inspect the payload can reveal this immediately.

3. Ignoring Error Response Bodies

Many developers check for error status codes but do not read the error response body. Well-designed APIs return detailed error information in JSON format that tells you exactly what went wrong and how to fix it. Always parse the error body — it is the API trying to help you.

4. Assuming Field Order Matters

JSON objects are unordered by specification. {"a":1,"b":2} and {"b":2,"a":1} are semantically identical. Never write code that depends on JSON keys appearing in a particular order. If your API needs ordered data, use an array.

5. Not Handling Null and Missing Fields

In JSON, there is a difference between a field set to null and a field that is absent entirely. Your code should handle both cases:

// Safe field access with defaults
const userName = response.user?.name ?? 'Unknown';
const userAge = response.user?.age ?? null; // null if missing or null

Best Practices for JSON in REST APIs

  • Always set Content-Type and Accept headers. This is non-negotiable for reliable API communication.
  • Use consistent naming conventions. Pick camelCase or snake_case and use it everywhere. Most JSON APIs use snake_case for field names.
  • Return appropriate status codes. Do not return 200 with an error message in the body. Use 4xx and 5xx codes so clients can handle errors programmatically.
  • Include pagination for collections. Never return unbounded collections. Always provide limit/offset or cursor-based pagination.
  • Use ISO 8601 for dates. Avoid Unix timestamps, custom formats, or locale-specific date strings.
  • Version your API. Use URL versioning (/v1/users) or header versioning to allow non-breaking changes.
  • Validate request bodies. Reject malformed JSON early with clear 400-level error messages.
  • Use a JSON viewer for debugging. When API responses are complex, paste them into a JSON Viewer to explore the structure interactively.

Debugging JSON API Issues

When an API integration is not working, the debugging process follows a predictable pattern:

  1. Inspect the raw request and response. Use your browser's DevTools Network tab or a tool like Postman to see the exact headers and body being sent and received.
  2. Validate the JSON. Paste the request body into the JSON Validator to confirm it is valid JSON.
  3. Format the response. Copy the raw response and paste it into the JSON Formatter to make nested structures visible.
  4. Check headers. Verify that Content-Type is application/json and not text/plain or application/x-www-form-urlencoded.
  5. Read the error body. The server's error response usually tells you exactly what is wrong. Do not ignore it.

Debug Your API Responses Now

Working with JSON APIs is much easier when you have the right tools. Use our JSON Formatter to pretty-print API responses for inspection, the JSON Validator to catch syntax errors in request bodies, and the JSON Viewer to explore deeply nested response structures interactively.

All tools run entirely in your browser — your API data stays private and never leaves your machine.

Conclusion

JSON and REST APIs are foundational technologies for modern web development. Getting the basics right — proper headers, consistent response structures, robust error handling, and thoughtful date formatting — makes the difference between fragile integrations that break at the first edge case and resilient ones that handle real-world complexity gracefully.

The patterns in this guide apply whether you are building APIs or consuming them. Set your headers correctly, validate your payloads, handle errors gracefully, and use the right tools for debugging. REST APIs are simple in concept but demand attention to detail in practice — and JSON is the format that makes it all work.