X Xerobit

JSON API Response Format — Structuring REST API Responses

A consistent JSON response format makes APIs predictable and easier to consume. Here's how to structure JSON API responses — envelope vs. flat, error format, pagination, and...

Mian Ali Khalid · · 5 min read
Use the tool
JSON Diff
Compare two JSON objects structurally with field-by-field diff.
Open JSON Diff →

Consistent API response structure reduces client-side complexity. Teams that define a response format once — for success, error, and pagination cases — write less defensive code everywhere that calls the API.

Use the JSON Diff Tool to compare API responses across versions.

Flat vs envelope response format

Flat (no envelope)

Return the resource directly:

GET /users/123

{
  "id": 123,
  "name": "Alice",
  "email": "alice@example.com"
}

Simple, HTTP status code carries success/error state. Used by Stripe, GitHub APIs.

Envelope format

Wrap the resource in a consistent container:

GET /users/123

{
  "data": {
    "id": 123,
    "name": "Alice",
    "email": "alice@example.com"
  },
  "meta": {
    "requestId": "abc-123",
    "timestamp": "2026-05-11T10:00:00Z"
  }
}

Adds room for metadata without breaking the data shape. Used by Google APIs, JSON:API standard.

Successful list response with pagination

GET /users?page=2&limit=20

{
  "data": [
    { "id": 21, "name": "Charlie" },
    { "id": 22, "name": "Diana" }
  ],
  "pagination": {
    "page": 2,
    "limit": 20,
    "total": 150,
    "totalPages": 8,
    "hasNext": true,
    "hasPrev": true
  }
}

Cursor-based pagination (better for large datasets):

{
  "data": [ ... ],
  "pagination": {
    "nextCursor": "eyJpZCI6IDIyfQ==",
    "prevCursor": "eyJpZCI6IDIxfQ==",
    "hasNext": true,
    "limit": 20
  }
}

Error response format

Consistent error format lets clients handle errors without parsing messages:

HTTP/1.1 400 Bad Request

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request validation failed",
    "details": [
      {
        "field": "email",
        "message": "Must be a valid email address",
        "value": "not-an-email"
      },
      {
        "field": "age",
        "message": "Must be a positive integer",
        "value": -5
      }
    ]
  },
  "requestId": "req_abc123"
}

Common error codes:

// 400 Bad Request:
{ "error": { "code": "VALIDATION_ERROR", "message": "..." } }

// 401 Unauthorized:
{ "error": { "code": "UNAUTHORIZED", "message": "Authentication required" } }

// 403 Forbidden:
{ "error": { "code": "FORBIDDEN", "message": "Insufficient permissions" } }

// 404 Not Found:
{ "error": { "code": "NOT_FOUND", "message": "User 123 not found" } }

// 409 Conflict:
{ "error": { "code": "CONFLICT", "message": "Email already registered" } }

// 429 Too Many Requests:
{ "error": { "code": "RATE_LIMITED", "message": "..." },
  "retryAfter": 60 }

// 500 Internal Server Error:
{ "error": { "code": "INTERNAL_ERROR", "message": "An unexpected error occurred" } }

Implementing consistent responses in Express

// Response helpers:
const respond = {
  success(res, data, statusCode = 200) {
    res.status(statusCode).json({ data });
  },
  
  created(res, data) {
    res.status(201).json({ data });
  },
  
  error(res, code, message, statusCode = 400, details = null) {
    const body = { error: { code, message } };
    if (details) body.error.details = details;
    res.status(statusCode).json(body);
  },
  
  notFound(res, resource) {
    res.status(404).json({
      error: { code: 'NOT_FOUND', message: `${resource} not found` }
    });
  },
  
  paginated(res, data, page, limit, total) {
    res.json({
      data,
      pagination: {
        page,
        limit,
        total,
        totalPages: Math.ceil(total / limit),
        hasNext: page * limit < total,
        hasPrev: page > 1,
      },
    });
  },
};

// Usage:
app.get('/users/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id);
  if (!user) return respond.notFound(res, 'User');
  respond.success(res, user);
});

app.post('/users', async (req, res) => {
  const { error, value } = userSchema.validate(req.body);
  if (error) return respond.error(res, 'VALIDATION_ERROR', 'Validation failed', 400,
    error.details.map(d => ({ field: d.path[0], message: d.message })));
  
  const user = await db.users.create(value);
  respond.created(res, user);
});

JSON:API specification

JSON:API is a formal standard for JSON API response format:

{
  "data": {
    "type": "articles",
    "id": "1",
    "attributes": {
      "title": "Hello World",
      "body": "Article content"
    },
    "relationships": {
      "author": {
        "data": { "type": "people", "id": "9" }
      }
    }
  },
  "included": [
    {
      "type": "people",
      "id": "9",
      "attributes": {
        "name": "Alice"
      }
    }
  ]
}

JSON:API handles relationships, sparse fieldsets, and included resources in a standardized way — useful for complex APIs but adds verbosity.

Versioning in response format

Add a version field to enable future evolution:

{
  "version": "2",
  "data": { ... }
}

Or version via the URL (/v2/users) or Accept header (Accept: application/vnd.myapi.v2+json).


Related posts

Related tool

JSON Diff

Compare two JSON objects structurally with field-by-field diff.

Written by Mian Ali Khalid. Part of the Data & Format pillar.