JSON API Design Patterns: Envelope, Async Operations & Resource Actions

Last updated:

Most REST API guides cover HTTP verbs and status codes but skip the concrete JSON design patterns that determine whether an API is actually pleasant to consume. This guide focuses on the patterns that matter in practice: the response envelope that makes collections extensible without breaking changes, the resource action URL pattern for non-CRUD operations, the 202 Accepted pattern for async work, PUT vs PATCH semantics and when to use JSON Merge Patch vs JSON Patch, the ?include parameter for eliminating N+1 queries, and RFC 7807 Problem Details for machine-readable error responses. Every pattern includes a concrete JSON example and the rationale for choosing it over the naive alternative. For foundational JSON API design principles, see the linked guide.

Response Envelope Pattern

The envelope pattern wraps every API response in a consistent top-level structure with data, meta, and links keys. Without an envelope, a collection endpoint returns a bare array: [{"id":1,"name":"Alice"}, ...]. When you later need to add pagination metadata, you have to change the response shape — breaking every client that destructures the array directly. With an envelope, the shape is stable and metadata lives alongside the payload.

// ── Collection response with envelope ─────────────────────────────────
// GET /users?page=2&perPage=20
{
  "data": [
    { "id": "u_001", "name": "Alice", "email": "alice@example.com" },
    { "id": "u_002", "name": "Bob",   "email": "bob@example.com" }
  ],
  "meta": {
    "total":   1200,
    "page":    2,
    "perPage": 20,
    "pages":   60
  },
  "links": {
    "self":  "/users?page=2&perPage=20",
    "first": "/users?page=1&perPage=20",
    "prev":  "/users?page=1&perPage=20",
    "next":  "/users?page=3&perPage=20",
    "last":  "/users?page=60&perPage=20"
  }
}

// ── Singleton resource response ────────────────────────────────────────
// GET /users/u_001
{
  "data": {
    "id":    "u_001",
    "name":  "Alice",
    "email": "alice@example.com",
    "role":  "admin"
  }
}

// ── Error envelope ─────────────────────────────────────────────────────
// POST /users  (validation failure — 400 Bad Request)
{
  "errors": [
    {
      "code":    "invalid_format",
      "field":   "email",
      "message": "Must be a valid email address"
    },
    {
      "code":    "too_short",
      "field":   "password",
      "message": "Must be at least 8 characters"
    }
  ]
}

// ── When to use bare resource (rare) ──────────────────────────────────
// Acceptable only for singleton GET endpoints in extremely simple APIs
// where you are CERTAIN no metadata will ever be added.
// DO NOT use bare resource for collections — you cannot add pagination later.

// ── Adding metadata non-breakingly with envelope ──────────────────────
// v1 response:
{ "data": [...], "meta": { "total": 1200, "page": 1, "perPage": 20 } }

// v1.1 response — added deprecation notice in meta, no breaking change:
{
  "data": [...],
  "meta": {
    "total":      1200,
    "page":       1,
    "perPage":    20,
    "deprecated": "This endpoint will be removed 2027-01-01. Use /v2/users."
  }
}
// Clients that don't know about "deprecated" ignore it — no breakage.

The envelope pattern is the single highest-leverage API design decision. Adopt it from day one — retrofitting it onto a bare-array API requires a major version bump because clients that do response.map(...) break when the response becomes an object. The links object follows the HATEOAS principle: clients can navigate to the next page using the provided URL rather than constructing it themselves, making the pagination implementation details opaque. For JSON Schema patterns to validate envelope structures, see the linked guide.

Collection and Singleton Resource Patterns

URL structure communicates resource semantics to both humans and machines. Collections use plural nouns (/users), singletons use an identifier segment (/users/u_001). Filtering and search happen on the collection via query parameters. Special singletons like "the currently authenticated user" use a fixed named segment rather than an ID.

// ── Collection endpoint: GET /users ───────────────────────────────────
// Filtering via query parameters
GET /users?status=active&role=admin&createdAfter=2026-01-01

// Sorting
GET /users?sort=-createdAt          // descending by createdAt
GET /users?sort=name,-createdAt     // name asc, createdAt desc

// Field selection (sparse fieldsets)
GET /users?fields=id,name,email     // only return these fields

// Search
GET /users?q=alice                  // full-text search

// Response shape
{
  "data": [
    { "id": "u_001", "name": "Alice", "status": "active", "role": "admin" }
  ],
  "meta": { "total": 47, "page": 1, "perPage": 20 }
}

// ── Singleton: GET /users/u_001 ────────────────────────────────────────
{
  "data": { "id": "u_001", "name": "Alice", "email": "alice@example.com" }
}

// ── Special singleton: /users/me ──────────────────────────────────────
// GET /users/me  — returns the authenticated user
// Avoid: GET /users/current, GET /me/profile (inconsistent nesting)
{
  "data": { "id": "u_001", "name": "Alice", "role": "admin" }
}

// ── Count metadata — always include in collections ─────────────────────
// Without total count, clients cannot render pagination controls.
// Include in meta.total on every collection response.
// If counting is expensive, use an approximate count:
{
  "meta": {
    "total":         1200,
    "totalIsExact":  false,   // flag if using COUNT(*) approximation
    "page":          1,
    "perPage":       20
  }
}

// ── Cursor-based pagination (for large, frequently-updated datasets) ──
// Page-based: GET /users?page=5&perPage=20  — breaks when rows shift
// Cursor-based: GET /users?after=cursor_abc&limit=20  — stable
{
  "data": [...],
  "meta": { "limit": 20, "hasMore": true },
  "links": {
    "next": "/users?after=eyJpZCI6MjB9&limit=20"
  }
}
// The cursor encodes the last item's sort key (base64 of {"id":20})
// Never expose raw database IDs in cursors — encode and sign them.

The /users/me pattern is widely adopted (GitHub, Stripe, Slack all use it) because it lets authenticated clients fetch their own resource without first knowing their ID. Cursor-based pagination is preferable to page-number pagination for datasets that change frequently — if rows are inserted between page 1 and page 2, page-number pagination shows duplicates or skips rows. Cursor pagination always returns the next N items after the last seen item, regardless of insertions. For safe TypeScript parsing of collection responses, see the linked guide.

Resource Action Pattern for Non-CRUD Operations

Many real-world operations don't map cleanly to CRUD. Activating a user account, cancelling an order, sending an invoice, suspending an account, archiving a document — these involve side effects (emails, webhooks, audit logs, permission changes) that are not captured by a simple field update. The resource action pattern models these as POST to a named sub-resource URL: POST /users/{id}/activate.

// ── Why PATCH is wrong for actions with side effects ─────────────────
// PATCH /users/u_001 {"status": "active"}
// Problem: this implies ONLY the status field changes.
// Activation actually does:
//   1. Sets status = "active"
//   2. Sends welcome email
//   3. Creates audit log entry
//   4. Triggers onboarding webhook
//   5. Grants default permissions
// None of this is expressed by "status": "active".

// ── Resource action: POST /users/{id}/activate ────────────────────────
POST /users/u_001/activate
Content-Type: application/json

{}   // body can be empty or carry action-specific params

// 200 OK — returns updated resource
{
  "data": {
    "id":          "u_001",
    "status":      "active",
    "activatedAt": "2026-02-23T10:00:00Z",
    "role":        "member"
  }
}

// ── More action examples ───────────────────────────────────────────────
POST /orders/ord_456/cancel
{ "reason": "customer_request" }

POST /invoices/inv_789/send
{ "to": ["alice@example.com"], "message": "Please find your invoice." }

POST /accounts/acc_012/suspend
{ "reason": "payment_overdue", "suspendUntil": "2026-03-01" }

POST /documents/doc_345/archive
{}

// ── Idempotency for actions ────────────────────────────────────────────
// Network failures can cause clients to retry POST requests.
// Support idempotency keys to make retries safe.
POST /users/u_001/activate
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

// Server stores the key + response for 24 hours.
// If the same key is sent again, return the stored response — no re-execution.
// 200 on first call, 200 with same body on retry.

// ── Bulk actions on collections ────────────────────────────────────────
// For bulk operations, use POST on a plural action resource:
POST /users/bulk-archive
Content-Type: application/json

{ "ids": ["u_001", "u_002", "u_003"] }

// 200 OK
{
  "data": {
    "archived": ["u_001", "u_002"],
    "failed":   [{ "id": "u_003", "reason": "already_archived" }]
  }
}

// ── Action naming conventions ─────────────────────────────────────────
// Use verb-based names that describe the business operation:
// /activate   /deactivate   /cancel    /approve    /reject
// /publish    /unpublish    /archive   /restore    /duplicate
// /send       /resend       /verify    /suspend    /resume

// Avoid generic names: /update-status, /set-flag, /change-value
// These don't communicate what business logic runs on the server.

The resource action pattern is not a violation of REST — it is a correct application of it. REST does not require that all operations map to field updates; it requires that operations be expressed through resource representations and standard HTTP methods. A sub-resource URL like /users/u_001/activate is a resource (the activation), and POST is the appropriate method for a non-idempotent operation with side effects. Clients that send the same activation request twice should get the same result — hence the idempotency key pattern. See JSON API design for foundational REST principles.

Async Operation Pattern with 202 Accepted

Some operations cannot complete within a single HTTP request-response cycle: generating a large report, sending bulk emails, processing a video, running a data migration. Returning 200 with a partial result misleads clients. The correct pattern is 202 Accepted: acknowledge the request immediately and give the client a job resource to poll.

// ── Trigger async operation ───────────────────────────────────────────
POST /reports/generate
Content-Type: application/json

{
  "type":        "monthly_sales",
  "month":       "2026-01",
  "format":      "xlsx",
  "callbackUrl": "https://myapp.com/webhooks/report-done"  // optional
}

// 202 Accepted — NOT 200, NOT 201
HTTP/1.1 202 Accepted
Location: /jobs/job_abc123

{
  "data": {
    "jobId":               "job_abc123",
    "status":              "pending",
    "statusUrl":           "/jobs/job_abc123",
    "estimatedCompleteMs": 8000,
    "createdAt":           "2026-02-23T10:00:00Z"
  }
}

// ── Job status schema ──────────────────────────────────────────────────
// GET /jobs/job_abc123
// Possible statuses: pending → running → completed | failed | cancelled

// Status: pending (not yet started)
{
  "data": {
    "jobId":     "job_abc123",
    "status":    "pending",
    "progress":  null,
    "createdAt": "2026-02-23T10:00:00Z",
    "startedAt": null,
    "result":    null,
    "error":     null
  }
}

// Status: running (in progress)
{
  "data": {
    "jobId":     "job_abc123",
    "status":    "running",
    "progress":  { "percent": 45, "message": "Processing page 9 of 20" },
    "startedAt": "2026-02-23T10:00:01Z",
    "result":    null,
    "error":     null
  }
}

// Status: completed
{
  "data": {
    "jobId":       "job_abc123",
    "status":      "completed",
    "progress":    { "percent": 100, "message": "Done" },
    "startedAt":   "2026-02-23T10:00:01Z",
    "completedAt": "2026-02-23T10:00:09Z",
    "result": {
      "downloadUrl": "https://cdn.jsonic.io/reports/report_abc123.xlsx",
      "expiresAt":   "2026-03-23T10:00:09Z",
      "sizeBytes":   248320
    },
    "error": null
  }
}

// Status: failed
{
  "data": {
    "jobId":     "job_abc123",
    "status":    "failed",
    "startedAt": "2026-02-23T10:00:01Z",
    "failedAt":  "2026-02-23T10:00:03Z",
    "result":    null,
    "error": {
      "code":    "source_data_unavailable",
      "message": "Sales database is temporarily unreachable."
    }
  }
}

// ── Client polling with exponential backoff ───────────────────────────
async function pollJob(jobId: string): Promise<JobResult> {
  const statusUrl = `/jobs/${jobId}`;
  let delayMs = 1000;          // start at 1s
  const maxDelayMs = 30_000;   // cap at 30s

  while (true) {
    const res = await fetch(statusUrl);
    const { data } = await res.json();

    if (data.status === 'completed') return data.result;
    if (data.status === 'failed')    throw new Error(data.error.message);

    await new Promise(r => setTimeout(r, delayMs));
    delayMs = Math.min(delayMs * 1.5, maxDelayMs);  // exponential backoff
  }
}

// ── Cancel a running job ───────────────────────────────────────────────
DELETE /jobs/job_abc123
// 200 OK — job cancelled
// 409 Conflict — job already completed, cannot cancel

The Location header on the 202 response is important — it gives clients the job URL without requiring them to construct it from the response body. The callbackUrl field in the request body enables a push model that eliminates polling entirely: when the job completes, the server POSTs the result to the client's webhook URL. Always implement both options — polling for simple clients, webhook callback for production integrations. Job resources should be retained for at least 24 hours after completion so clients that were offline can retrieve the result.

Partial Update Patterns: PUT vs PATCH

The distinction between PUT and PATCH is frequently misunderstood. PUT replaces the entire resource. PATCH modifies specific fields. The choice has significant implications for race conditions, required fields, and client implementation complexity. Two standard PATCH dialects exist: JSON Merge Patch (RFC 7396) and JSON Patch (RFC 6902).

// ── PUT: replace entire resource ──────────────────────────────────────
// Current resource state:
{
  "id":       "u_001",
  "name":     "Alice",
  "email":    "alice@example.com",
  "bio":      "Engineer at Acme Corp",
  "timezone": "America/New_York"
}

// PUT /users/u_001 — must include ALL fields
PUT /users/u_001
{
  "name":     "Alice Johnson",
  "email":    "alice@example.com",
  "bio":      "Senior Engineer at Acme Corp",
  "timezone": "America/New_York"
}
// If you omit "timezone", it gets set to null/default.
// Client must fetch the current state first, then PUT the full document.

// ── PATCH: JSON Merge Patch (RFC 7396) ────────────────────────────────
PATCH /users/u_001
Content-Type: application/merge-patch+json

{
  "name": "Alice Johnson",     // SET name to "Alice Johnson"
  "bio":  null                 // DELETE bio field
  // email and timezone are NOT mentioned → untouched
}
// Result: name updated, bio removed, email/timezone unchanged.

// Rules:
// - Present field with non-null value → SET to that value
// - Present field with null value     → DELETE the field
// - Absent field                      → no change

// Limitation: cannot SET a field to null (null means delete)
// Limitation: cannot update a specific array element by index

// ── PATCH: JSON Patch (RFC 6902) ──────────────────────────────────────
PATCH /users/u_001
Content-Type: application/json-patch+json

[
  { "op": "replace", "path": "/name",     "value": "Alice Johnson" },
  { "op": "remove",  "path": "/bio"                                },
  { "op": "add",     "path": "/tags/-",   "value": "premium"      },
  { "op": "replace", "path": "/address/city", "value": "Austin"   }
]
// Operations: add, remove, replace, move, copy, test

// "test" operation — conditional patch (atomic):
[
  { "op": "test",    "path": "/version", "value": 5             },  // fails if version != 5
  { "op": "replace", "path": "/status",  "value": "published"  }
]
// If "test" fails, entire patch is rejected — useful for optimistic locking.

// ── Array operations with JSON Patch ──────────────────────────────────
[
  { "op": "add",     "path": "/tags/0",  "value": "urgent"  },  // insert at index 0
  { "op": "add",     "path": "/tags/-",  "value": "new"     },  // append to end
  { "op": "remove",  "path": "/tags/2"                      },  // remove index 2
  { "op": "replace", "path": "/tags/1",  "value": "updated" }   // replace index 1
]

// ── Decision guide ────────────────────────────────────────────────────
// Use PUT when:
//   - Client always has the full current state (e.g., a form edit that loads all fields)
//   - You want simple server-side logic (just replace the whole document)
//
// Use PATCH (JSON Merge Patch) when:
//   - Client knows only which fields to change (sparse update)
//   - Update source is a UI component that edits one field at a time
//   - You need to delete optional fields (set to null)
//
// Use PATCH (JSON Patch) when:
//   - You need to update specific array elements by index
//   - You need conditional (optimistic lock) updates via "test"
//   - You need atomic multi-operation patches
//   - You are implementing a CRDT or collaborative editing system

The most common bug with PUT is a client that fetches a resource, modifies one field in the JavaScript object, and PUTs the modified object — this appears to work but silently overwrites any changes made by other clients between the fetch and the PUT. JSON Merge Patch avoids this because untouched fields are absent from the request body. JSON Patch's "test" operation enables true optimistic concurrency control: the patch is only applied if the current value matches the expected value, equivalent to a compare-and-swap. For JSON Schema validation of incoming PATCH bodies, see the linked guide.

Embedded vs Linked Resource Pattern

When a resource has related resources (a post has an author, tags, and comments), you have two choices: embed the related resources inside the primary response, or return only foreign key references and require separate requests. The right answer depends on access patterns, but a naive "always embed everything" approach causes N+1 queries. The ?include parameter pattern gives clients control.

// ── Linked only (no ?include) ─────────────────────────────────────────
// GET /posts/p_001
{
  "data": {
    "id":       "p_001",
    "title":    "JSON API Design Patterns",
    "authorId": "u_042",    // reference only — not embedded
    "tagIds":   ["t_1", "t_2"]
  }
}
// Client must make separate requests to get author and tags.
// Simple server logic, but N+1 if fetching a collection of 20 posts.

// ── With ?include — embed requested relations ─────────────────────────
// GET /posts/p_001?include=author,tags
{
  "data": {
    "id":       "p_001",
    "title":    "JSON API Design Patterns",
    "author":   { "id": "u_042", "name": "Alice", "avatar": "..." },
    "tags":     [{ "id": "t_1", "name": "api" }, { "id": "t_2", "name": "json" }]
  }
}
// Server does a JOIN: 1 query instead of 1 + N.

// ── N+1 problem in code ───────────────────────────────────────────────
// BAD: N+1 — 1 query for posts, then 1 per post for author
const posts = await db.post.findMany();
for (const post of posts) {
  post.author = await db.user.findUnique({ where: { id: post.authorId } });
  // 20 posts → 21 queries total
}

// GOOD: eager load — 1 JOIN query
const posts = await db.post.findMany({
  include: { author: true, tags: true }  // Prisma eager load
});
// 1 query total

// Server implementation: parse ?include param → build include object
function parseIncludes(query: string) {
  const allowed = new Set(['author', 'tags', 'comments']);
  return query.split(',')
    .filter(r => allowed.has(r))   // whitelist — prevent unbounded JOINs
    .reduce((acc, rel) => ({ ...acc, [rel]: true }), {});
}
// GET /posts?include=author,tags → { author: true, tags: true }

// ── JSON:API sideloading pattern ──────────────────────────────────────
// In JSON:API spec, related resources appear in top-level "included"
// array, deduplicated by type+id. Primary data contains only references.
// GET /posts?include=author
{
  "data": [
    {
      "id":   "p_001",
      "type": "posts",
      "attributes": { "title": "JSON API Design Patterns" },
      "relationships": {
        "author": { "data": { "type": "users", "id": "u_042" } }
      }
    },
    {
      "id":   "p_002",
      "type": "posts",
      "attributes": { "title": "REST Best Practices" },
      "relationships": {
        "author": { "data": { "type": "users", "id": "u_042" } }
        // same author — appears once in "included", not duplicated
      }
    }
  ],
  "included": [
    { "id": "u_042", "type": "users", "attributes": { "name": "Alice" } }
    // author appears once even though referenced by 2 posts
  ]
}

// ── Depth limit for nested includes ──────────────────────────────────
// Allow: ?include=author,tags           (depth 1 — OK)
// Allow: ?include=author.organization   (depth 2 — acceptable)
// Reject: ?include=author.org.country.region  (depth 4 — reject with 400)
// Enforce max depth to prevent unbounded recursive JOINs.

The ?include parameter is a contract between client and server: the client declares what it needs, the server fetches it efficiently. Always implement this before shipping a collection endpoint — adding it later requires a version bump if clients have been relying on always-embedded resources. The sideloading pattern from the JSON:API specification is particularly useful when the same related resource (e.g., an author) appears in many items in the collection: it appears once in included rather than being duplicated for every post, reducing payload size. For JSON caching strategies that complement the ?include pattern, see the linked guide.

Error Response Patterns

Error responses deserve as much design attention as success responses. A good error response is machine-readable (stable error codes clients can branch on), human-readable (a clear message for developers), and safe (no internal stack traces or database details exposed). RFC 7807 Problem Details defines a standard format for HTTP API error responses.

// ── RFC 7807 Problem Details (application/problem+json) ──────────────
// For server errors (5xx) and domain errors (4xx):
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json

{
  "type":     "https://jsonic.io/errors/rate-limit-exceeded",
  "title":    "Rate Limit Exceeded",
  "status":   429,
  "detail":   "You have made 1001 requests this hour. Limit is 1000.",
  "instance": "/logs/req_3f8a1c9d",
  "retryAfter": 3542   // seconds until reset (custom extension field)
}

// "type" — URI identifying the error class. Must be stable across deploys.
//           Clients use this to branch on error type — NOT "title" or "detail"
//           (those change; "type" never does).
// "title" — Short, human-readable summary. Same for all instances of this type.
// "status" — HTTP status code (mirrors the HTTP response status).
// "detail" — Specific explanation for this occurrence.
// "instance" — URI to a specific log entry or error report.

// ── Field-level validation errors (400 Bad Request) ───────────────────
HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "type":   "https://jsonic.io/errors/validation-failed",
  "title":  "Validation Failed",
  "status": 400,
  "errors": [
    {
      "field":   "email",
      "code":    "invalid_format",
      "message": "Must be a valid email address"
    },
    {
      "field":   "password",
      "code":    "too_short",
      "message": "Must be at least 8 characters (got 5)"
    },
    {
      "field":   "username",
      "code":    "already_taken",
      "message": "This username is not available"
    }
  ]
}
// "code" is machine-readable — UI maps code → localized error message.
// "message" is English fallback for developers.
// "field" uses dot notation for nested fields: "address.city"

// ── Nested field error paths ───────────────────────────────────────────
{
  "errors": [
    { "field": "address.city",    "code": "required",        "message": "City is required" },
    { "field": "items[0].price",  "code": "must_be_positive","message": "Price must be > 0" },
    { "field": "items[2].sku",    "code": "not_found",       "message": "SKU does not exist" }
  ]
}

// ── Authorization errors — don't reveal resource existence ────────────
// If user lacks permission OR resource doesn't exist, return the same response:
HTTP/1.1 403 Forbidden
{
  "type":   "https://jsonic.io/errors/forbidden",
  "title":  "Forbidden",
  "status": 403,
  "detail": "You do not have access to this resource."
}
// Do NOT: return 404 when resource exists but user can't see it.
// An attacker can enumerate resource IDs by observing 403 vs 404.

// ── Server error — never expose internals ─────────────────────────────
HTTP/1.1 500 Internal Server Error
{
  "type":     "https://jsonic.io/errors/internal",
  "title":    "Internal Server Error",
  "status":   500,
  "detail":   "An unexpected error occurred. Please try again.",
  "instance": "/logs/req_9b2e7f1a"   // correlation ID — look up in server logs
}
// "instance" lets support staff find the full stack trace in logs
// WITHOUT exposing it to the client.

// ── Single error vs error array ────────────────────────────────────────
// Server errors: single error object (one thing went wrong)
// Validation errors: error array (multiple fields can fail simultaneously)
// Always use the array shape for 400 validation responses — even if only
// one field is invalid. Clients should not branch on array vs object shape.

// ── Machine-readable error code catalog ───────────────────────────────
// Publish a stable list of error codes at your error type URIs:
// https://jsonic.io/errors/rate-limit-exceeded  → 429
// https://jsonic.io/errors/validation-failed    → 400
// https://jsonic.io/errors/not-found            → 404
// https://jsonic.io/errors/forbidden            → 403
// https://jsonic.io/errors/unauthorized         → 401
// https://jsonic.io/errors/conflict             → 409 (duplicate, stale version)
// https://jsonic.io/errors/internal             → 500

The most common error design mistake is returning only a human-readable message string. Clients cannot reliably parse natural language to determine the error type — they need a stable code or type URI. The second most common mistake is exposing stack traces or database error messages in 500 responses — use a correlation ID in instance and log the full trace server-side. RFC 7807 is now a W3C recommendation and supported by default in ASP.NET Core 7+ and Spring Boot 3+ — in Node.js, use the http-problem-details npm package. For JSON to Markdown conversion of API documentation, see the linked guide.

Key Terms

Response Envelope
A JSON API design pattern where every response is wrapped in a consistent top-level object with data, meta, and links keys, rather than returning the resource or array directly. The envelope provides a stable extension point: metadata (pagination counts, deprecation notices, rate limit headers) can be added inside meta without changing the data shape that clients parse. Error responses use an errors key instead of data. The pattern is mandated by the JSON:API specification and followed by most major API providers. The primary benefit is backward compatibility: adding a new field to meta never breaks clients that only read data.
Resource Action
A REST API pattern for non-CRUD operations, modeled as POST to a named sub-resource URL: POST /users/{id}/activate. Used when an operation has side effects beyond a simple field update — activating a user might send a welcome email, create audit logs, and grant permissions, none of which are expressed by PATCH {"status": "active"}. The action name uses a verb describing the business operation. Bulk actions on collections use POST /resource/bulk-{action} with a body containing an array of IDs. Resource actions support idempotency keys to allow safe client retries on network failure.
Async Operation (202 Accepted)
An HTTP API pattern for operations that cannot complete within a single request-response cycle. The server responds with HTTP 202 Accepted (not 200), a Location header pointing to a job resource, and a JSON body containing a jobId and statusUrl. The client polls the job resource until status transitions to completed or failed. Polling should use exponential backoff starting at 1 second and capping at 30 seconds. For production integrations, support a callbackUrl field in the original request so the server can push the result via webhook instead of requiring polling. Job resources must be retained for at least 24 hours after completion.
JSON Merge Patch (RFC 7396)
A partial update format for HTTP APIs using Content-Type: application/merge-patch+json. The request body is a partial JSON object: fields present with non-null values are set on the target resource, fields present with a null value delete the corresponding field from the resource, and fields absent from the body are left unchanged. This makes it straightforward for clients that know only what they want to change — they do not need to fetch the full resource state first. Limitation: you cannot set a field to null (null means delete), and you cannot update specific array elements by index. Defined by RFC 7396, published October 2014.
JSON Patch (RFC 6902)
A partial update format for HTTP APIs using Content-Type: application/json-patch+json. The request body is an array of operation objects, each with op (add, remove, replace, move, copy, test), path (a JSON Pointer per RFC 6901, e.g. /address/city), and value. Operations are applied atomically — if any operation fails, the entire patch is rejected and the resource is unchanged. The test operation enables conditional updates: if the test fails (the field does not have the expected value), the patch is rejected — equivalent to optimistic concurrency control. JSON Patch supports array index operations that JSON Merge Patch cannot express.
Sideloading
A JSON API pattern from the JSON:API specification where related resources are included in the top-level included array of the response, deduplicated by type and ID, while the primary data array contains only relationship references (type + ID). Sideloading avoids duplicating shared resources — if 20 posts share the same author, the author object appears once in included rather than being embedded in each post. This reduces payload size and keeps the response structure flat. Triggered by the ?include=author,tags query parameter. The alternative — embedding related resources directly inside each item — is simpler but duplicates data and makes it harder to update related resources without re-fetching the entire collection.
N+1 Query
A performance anti-pattern in API backends where a collection request triggers one database query for the primary resource list, then one additional query per item to fetch a related resource — resulting in N+1 total queries for a collection of N items. Example: fetching 20 posts, then fetching each post's author in a loop, produces 21 queries. The fix is eager loading: fetch all related resources in a single JOIN or batch query. In ORMs like Prisma, this is the include option; in SQL, it is a JOIN or an IN clause. API-layer, trigger eager loading only when the client requests it via the ?include parameter — unconditional eager loading can be wasteful when the client does not need related resources.
Problem Details (RFC 7807)
An IETF standard for machine-readable HTTP API error responses, using Content-Type: application/problem+json. Defines five standard fields: type (a URI identifying the error class — the primary machine-readable identifier, must be stable), title (short human-readable summary, same for all instances of the type), status (mirrors the HTTP status code), detail (specific explanation for this occurrence), and instance (URI to a specific log entry). The type URI is what clients branch on — never branch on title or detail strings, which may change. RFC 7807 allows extension fields alongside the standard ones. The format is supported natively in ASP.NET Core 7+ and Spring Boot 3+. Published May 2016, now a W3C recommendation.

FAQ

Should I wrap JSON API responses in an envelope or return the resource directly?

Use an envelope for collection endpoints and any response that needs metadata, and consider bare resources only for simple singleton GET endpoints where additional context will never be needed. The envelope wraps your payload in a data key alongside meta and links: {"data": [...], "meta": {"total": 1200, "page": 1}, "links": {"next": "/users?page=2"}}. The primary advantage is extensibility without breaking changes — you can add pagination metadata, rate limit info, or deprecation warnings inside meta without touching the data shape that clients parse. Error responses should always use an envelope — the errors array pattern allows multiple errors from a single request, which is essential for form validation. Adopt the envelope consistently: asymmetry between collection and singleton shapes causes client bugs where code written for collections is accidentally applied to singletons.

How do I design a JSON API endpoint for an operation that isn't a simple CRUD action?

Use the Resource Action Pattern: model the operation as a POST to a sub-resource URL named after the action. Examples: POST /users/{id}/activate, POST /orders/{id}/cancel, POST /invoices/{id}/send. This is semantically correct when the action has side effects beyond a field update — activating a user might send a welcome email, create audit logs, and grant permissions. None of those effects are captured by PATCH /users/{id} {"status":"active"}. The response returns the updated resource in a standard envelope. For idempotency, support an Idempotency-Key header so clients can safely retry on network failure. For bulk actions, use POST /users/bulk-archive with an IDs array in the body. If the action takes more than a few seconds to complete, return 202 Accepted with a job resource instead of blocking the connection.

What is the correct HTTP status code for an async JSON API operation?

202 Accepted is the correct status code. The semantic difference from 200 OK is precise: 200 means "I processed your request and here is the result", 202 means "I accepted your request and processing will continue asynchronously — check back later." The 202 response body should include a job resource with a jobId, status: "pending", and a statusUrl for polling. The client polls GET /jobs/{jobId} using exponential backoff (start at 1 second, cap at 30 seconds) until status transitions to completed or failed. Also include a Location header pointing to the job URL. For push-based integrations, support a callbackUrl field in the original request body so the server can POST the result when done. Never use 200 for async operations — it misleads clients into thinking the operation is already done.

When should I use PUT vs PATCH for JSON API updates?

PUT replaces the entire resource — every field not included in the body is reset to its default. Use PUT when the client has fetched the full current state, made modifications, and is sending the complete updated document back. PUT is idempotent. PATCH applies a partial update — only fields in the request body are changed, other fields are untouched. Use PATCH when the client only knows what it wants to change, or when you want to avoid race conditions from read-modify-write cycles. Two standard PATCH formats: JSON Merge Patch (RFC 7396) with Content-Type: application/merge-patch+json — send a partial object where null deletes a field and missing means no change. JSON Patch (RFC 6902) with Content-Type: application/json-patch+json — send an array of operations that supports array index updates and conditional "test" operations. In practice: use PATCH with JSON Merge Patch for most partial updates, PUT for full resource replacement, and resource action endpoints when the operation has side effects beyond field changes.

What is the difference between JSON Merge Patch and JSON Patch for partial updates?

JSON Merge Patch (RFC 7396) is simple: send a partial JSON object. Present fields with non-null values are set. Present fields with null values delete the field. Absent fields are unchanged. Content-Type: application/merge-patch+json. Limitation: you cannot set a field to null (null means delete), and you cannot update specific array elements by index. JSON Patch (RFC 6902) is operation-based: send an array like [{"op":"replace","path":"/status","value":"active"},{"op":"remove","path":"/bio"}]. Content-Type: application/json-patch+json. Operations: add, remove, replace, move, copy, test. The "test" operation enables conditional patches — if the current value doesn't match the test, the entire patch is rejected atomically. JSON Patch can set fields to null, update specific array indexes, and perform complex transformations. Choose JSON Merge Patch for simple sparse object updates. Choose JSON Patch when you need array index operations, conditional logic, or atomic multi-operation patches.

How do I handle including related resources in a JSON API response without N+1 queries?

Implement the ?include query parameter: clients request related resources as a comma-separated list — GET /posts?include=author,tags. Without ?include, return only foreign key references. With ?include=author, perform a JOIN or ORM eager load to embed the full author object. This eliminates N+1 queries: instead of 1 query for posts then 1 per post for authors (21 queries for 20 posts), you do 1 JOIN query. Server-side, the ?include parameter maps to ORM eager loading: include: { author: true, tags: true } in Prisma. Always whitelist which relationships can be included to prevent clients from triggering unbounded JOIN chains. Apply a depth limit (2-3 levels) for nested includes like author.organization. Always implement ?include before shipping a collection endpoint — adding it later requires a version bump because clients that received always-embedded data will break when you remove it.

How should I structure JSON API error responses for form validation vs server errors?

Use RFC 7807 Problem Details for server errors and an errors array for field-level validation failures. RFC 7807 format: {"type": "https://jsonic.io/errors/rate-limit-exceeded", "title": "Rate Limit Exceeded", "status": 429, "detail": "...", "instance": "/logs/req_abc"}. The type URI is machine-readable and stable — clients branch on it, not on title or detail strings. For form validation (400), return an errors array: {"errors": [{"field": "email", "code": "invalid_format", "message": "..."}, {"field": "password", "code": "too_short", "message": "..."}]}. The code field is machine-readable — UI uses it to map to localized inline error messages. Never return only a human-readable message for validation — the UI needs a stable code. For 500 errors, include a correlation ID in instance and log the full stack trace server-side — never expose internal details to the client.

Further reading and primary sources