InTab

Blog

Designing API responses: errors, envelopes and pagination that professionals choose

By Uxair Ijaz · 2026-11-06 · 10 min read

Two APIs can expose the same data and still feel completely different to use. The difference is rarely the endpoints — it is the responses: whether errors say what went wrong, whether pagination survives a million rows, whether a timeout means "maybe it happened". This guide walks through each decision with real examples and a recommendation at the end of it.

Decision 1: bare resource or envelope?

The envelope wraps every response in metadata:

{ "data": { "id": "42", "status": "placed" },
  "meta": { "request_id": "req_9f2" } }

Bare resources return the object itself and put metadata in headers:

HTTP/1.1 200 OK
X-Request-Id: req_9f2
Content-Type: application/json

{ "id": "42", "status": "placed" }

The honest comparison:

Envelope Bare + headers
Room for meta without touching the resource ✓ headers only
One shape for every endpoint (clients parse once) ✓ varies by endpoint
Self-describing to curl and browsers ✗ ({"data": ...} noise) ✓
HTTP caching of the resource itself muddier clean
Examples in the wild Google, JSON:API Stripe, GitHub, Shopify

What professionals choose: bare resources, HTTP status codes for the verdict, headers for request IDs and rate limits — with a consistent exception: collections come back as an object when they need room for pagination metadata (Stripe's {"object": "list", "data": [...], "has_more": true}). That is not noise; it is where the next cursor lives. Whichever way you go: never two different top-level shapes for the same resource on different endpoints.

Decision 2: what does an error look like?

The industry has quietly converged on the shape in RFC 9457, "Problem Details for HTTP APIs":

{
  "type": "https://api.example.com/errors/insufficient-stock",
  "title": "Insufficient stock",
  "status": 422,
  "detail": "Only 3 of tee-blue-m remain, 5 requested.",
  "instance": "/orders/42"
}

type is a stable machine-readable identifier; title is a short human summary; detail is the specifics; extension members (like fields) carry the rest. Field-level validation errors fit as an extension:

{
  "type": "https://api.example.com/errors/validation-failed",
  "title": "2 fields are invalid",
  "status": 422,
  "fields": { "qty": "Must be at least 1", "sku": "Unknown SKU" }
}

The rules professionals keep:

  • Error types are forever. Clients switch on them (if (err.type === '...insufficient-stock')). Renaming one is a breaking change.
  • Messages are for humans, codes for machines. Never parse English in a client.
  • No stack traces, no SQL, no internal hostnames — a 500 returns a reference ID the logs can find.
  • The status code is always the truth. An error body inside 200 breaks every generic client, cache and dashboard.

Decision 3: pagination that survives scale

Offset pagination (?page=3&limit=20 → ?offset=40&limit=20):

{ "data": [ ... 20 orders ... ], "total": 1483, "page": 3 }

Familiar, jump-to-page-7 friendly — and wrong under writes: insert an order between two page fetches and page 3 re-serves page 2's rows. total is not free either; on a 50-million-row table COUNT(*) can cost more than the page itself.

Cursor pagination (?limit=20&cursor=eyJpZCI6NDF9 — typically the last row's ID or timestamp, base64-encoded):

{ "data": [ ... 20 orders ... ], "has_more": true, "next_cursor": "eyJpZCI6MjF9" }

Stable under inserts and deletes, constant-time on any table size, and the standard for infinite-scroll UIs. Its costs: no jumping to arbitrary pages, and it needs a deterministic sort (a unique tiebreaker column).

What professionals choose: cursors for anything that can grow large (events, invoices, logs), offsets only for small fixed lists like admin screens. Cursor-encode an opaque tuple — clients must not be able to invent cursors.

Decision 4: writes that might have happened

A payment times out. Did it go through? Without help, the only safe move is a lookup. With idempotency keys, the client sends a unique key per attempt:

POST /payments
Idempotency-Key: 6b9e-4f2a-attempt-1

The server stores the first response against the key; a retry with the same key returns that stored response instead of charging again. Stripe, Adyen and every serious payments API work this way. Professionals extend the pattern to any POST that is expensive or consequential (orders, migrations, sends), expire keys after 24–48 hours, and return a distinct error when the same key arrives with a different body.

Decision 5: metadata your clients will thank you for

  • X-Request-Id echoed on every response — when a user reports a failure, the ID turns a support ticket into a log query. Accept an incoming request ID; generate one when absent.
  • Rate-limit headers (RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset) so well-behaved clients pace themselves instead of discovering 429 the hard way.
  • Consistent timestamps: ISO 8601 in UTC (2026-11-06T09:30:00Z), never local time, never epoch-milliseconds in one endpoint and ISO in another. Epoch in, epoch out, is a valid house style — just pick one and document it.
  • Money as minor units or decimal strings ("total": 5400 cents, or "54.00"), never floating point.

A reference response, assembled

HTTP/1.1 200 OK
X-Request-Id: req_9f2
RateLimit-Remaining: 47
Content-Type: application/json

{ "object": "list",
  "data": [
    { "id": "order_42", "status": "placed", "total": "54.00",
      "currency": "USD", "created": "2026-11-06T09:30:00Z" }
  ],
  "has_more": true,
  "next_cursor": "eyJpZCI6NDF9" }

Every convention in this guide is visible at once: the list-envelope for pagination metadata, ISO timestamps, string money, headers for the meta, a cursor for the next page.

The checklist

  1. Bare resources; status code is the verdict; headers carry request IDs and limits.
  2. Collections use the list-envelope only when pagination metadata is needed.
  3. Errors follow RFC 9457 with stable types and field-level details on 422.
  4. Cursor pagination on anything unbounded; offsets only on small lists.
  5. Idempotency keys on consequential POSTs.
  6. ISO 8601 UTC timestamps; decimal or minor-unit money; no floats.
  7. Every rule above in your docs — a response convention the client cannot discover is a convention that does not exist.

Validate your own payloads against these rules with the JSON validator, pretty them for reading with the JSON formatter, and if you are still choosing the URLs themselves, start with our guide to REST fundamentals and finish with the naming conventions.

Tools in this article

  • JSON Formatter: Beautify, minify, validate and sort JSON. Handles escaped/stringified JSON.
  • JSON Validator: Check JSON is valid and see the exact line, column and a fix hint when it is not.
  • JSON to Code: Generate TypeScript, C#, Java, Python, Go, Kotlin, Swift, Rust, PHP, Dart types from JSON.