Skip to content
Back to skills

Response Design And Pagination

ASecurity

This skill should be used when the user is designing API response formats, implementing pagination (cursor, offset, keyset), creating list endpoints, designing response envelopes, implementing expandable/embeddable objects, or structuring API output. Covers Stripe-style cursor pagination, consistent list envelopes, expand patterns, and response metadata.

  • 34 stars
  • 0 votes
  • 0 copies
  • 3 views
  • Added October 26, 2026
developmentpythonrustgosqlnodeexpressfastapirailsapidatabase

Works with

  • cursor
  • cli
  • api

Security analysis

A100/100

Pro scans all 3 files and shows the line behind each finding

Scanned October 26, 2026

npx -y skills add oborchers/fractional-cto --skill response-design-and-pagination --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Response Design And Pagination?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Response Design And Pagination
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/oborchers-response-design-and-pagination/badge)](https://www.skillsdirectory.com/skills/oborchers-response-design-and-pagination)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: response-design-and-pagination
description: "This skill should be used when the user is designing API response formats, implementing pagination (cursor, offset, keyset), creating list endpoints, designing response envelopes, implementing expandable/embeddable objects, or structuring API output. Covers Stripe-style cursor pagination, consistent list envelopes, expand patterns, and response metadata."
version: 1.0.0
---

# Responses Are Contracts, Not Accidents

Every response your API returns is a contract. The moment a client parses it, the shape is frozen. Get it wrong and you carry the debt forever. Get it right -- consistent envelopes, stable pagination, predictable expand behavior -- and your API becomes a platform developers trust with production traffic.

Response design drives SDK generation, backward compatibility, and client-side caching. A well-structured response eliminates round trips, prevents breaking changes, and makes your API feel like a native library in every language.

## Single Resource Envelope

Return the full object on create and update. Always.

A POST that returns `{ "id": 42 }` forces a second GET to see what was created. A PATCH that returns `204 No Content` leaves the client guessing about server-side side effects like `updated_at` changes and computed defaults.

**Rules:**
- POST (201 Created): return the full resource plus a `Location` header
- PUT/PATCH (200 OK): return the full updated resource
- DELETE: return `204 No Content` or `200 OK` with a deletion confirmation object
- Include `created_at` and `updated_at` timestamps on every resource -- clients need them for display, sorting, and cache invalidation

**The resource shape must be identical everywhere it appears** -- in a GET, in a list, embedded in another resource, or returned from a mutation. If a compact form is needed, offer `?fields=id,name` rather than returning a different shape by default.

## List Envelope

Wrap every list response in a consistent envelope. Never return a naked top-level array.

**Use this structure for every list endpoint:**

```json
{
  "data": [
    { "id": "ord_01HXK3GJ5V", "status": "shipped", "created_at": "2026-01-15T10:30:00Z" },
    { "id": "ord_01HXK3GJ6W", "status": "pending", "created_at": "2026-01-14T08:15:00Z" }
  ],
  "has_more": true,
  "next_cursor": "ord_01HXK3GJ6W"
}
```

**Why this works:**
- `data` is always an array. Clients never check the type.
- `has_more` is a boolean that maps directly to "Load more" and "Show next page" UIs.
- `next_cursor` provides the opaque position marker for the next request.
- No `total_count` by default. Counting millions of rows is expensive and usually unnecessary. Offer it as an opt-in parameter (`?include_count=true`) when clients explicitly need it.

Stripe uses this envelope on every list endpoint across their entire API surface. The `object: "list"` discriminator and `url` field are additional Stripe conventions worth considering at scale -- they enable generic SDK deserialization without endpoint-specific knowledge.

## Pagination Strategy Comparison

| Aspect | Offset | Cursor (Opaque) | Keyset |
|--------|--------|-----------------|--------|
| **Query** | `?page=3&per_page=20` | `?limit=20&after=cursor_abc` | `?limit=20&created_after=2026-01-15T10:30:00Z` |
| **SQL** | `OFFSET 40 LIMIT 20` | `WHERE id < cursor ORDER BY id DESC LIMIT 21` | `WHERE (created_at, id) > (ts, id) LIMIT 21` |
| **Page 1 performance** | < 1ms | < 1ms | < 1ms |
| **Page 50,000 performance** | 500ms+ (O(n) -- scans and discards rows) | < 1ms (index seek) | < 1ms (index seek) |
| **Consistency under writes** | Items skipped or duplicated when rows are inserted/deleted between fetches | Stable -- cursor marks an exact position | Stable -- keyset marks an exact position |
| **Random page access** | Yes (`?page=47`) | No | No |
| **Total count** | Possible but expensive | Possible but expensive | Possible but expensive |
| **Client complexity** | Low | Low | Low-Medium |
| **Best for** | Admin UIs with "Page X of Y" requirements and small datasets | General-purpose API pagination (default choice) | Time-series data, event logs, audit trails |

## Cursor Pagination as the Default

Make cursor-based pagination the default for every list endpoint. It is O(1) at any page depth, consistent under concurrent writes, and trivial for clients to implement.

**Parameters:**
- `limit` -- Number of items to return (1-100, default 20)
- `after` -- Cursor marking the position to start after (for forward pagination)
- `before` -- Cursor marking the position to start before (for backward pagination)

**The `LIMIT + 1` trick:** Fetch one more item than requested. If you get `limit + 1` results, set `has_more: true` and return only `limit` items. If you get `limit` or fewer results, set `has_more: false`. This avoids a separate count query entirely.

**Stripe uses the resource ID as the cursor.** When IDs are naturally ordered (ULIDs, KSUIDs, or Stripe's own prefixed IDs), the ID itself serves as a perfect cursor -- no Base64 encoding needed, fully debuggable in logs. For APIs where the sort order does not align with the ID, use an opaque Base64-encoded cursor that encodes the sort key and a tiebreaker (typically the ID).

**Cursor opacity rule:** Clients must treat cursors as opaque strings. Document this explicitly. Even when the cursor is a plain resource ID, clients should not construct or parse cursors. This gives you the freedom to change the cursor encoding later without breaking clients.

## Expand/Embed Pattern

Default to returning references (IDs). Let clients request full objects with `?expand[]=field`.

**Without expand:**
```json
{
  "id": "ord_01HXK3GJ5V",
  "customer": "cus_4QFJOjw2pOmAGJ",
  "line_items": [
    { "id": "li_01ABC", "product": "prod_NWjs8kKb" }
  ]
}
```

**With `?expand[]=customer&expand[]=line_items.product`:**
```json
{
  "id": "ord_01HXK3GJ5V",
  "customer": {
    "id": "cus_4QFJOjw2pOmAGJ",
    "name": "Ada Lovelace",
    "email": "ada@example.com"
  },
  "line_items": [
    {
      "id": "li_01ABC",
      "product": {
        "id": "prod_NWjs8kKb",
        "name": "Pro Plan",
        "price": 4900
      }
    }
  ]
}
```

**Rules:**
- Limit expansion depth to 3-4 levels. Deeper expansions create performance and security risks.
- Cap the number of expand parameters per request (approximately 20).
- Document which fields are expandable. Not every reference should be expandable.
- Support expand on mutations (POST, PUT, PATCH) so clients get expanded data in the creation/update response without a second round trip.
- For list endpoints, use `?expand[]=data.customer` to expand fields on every item in the list.
- The expand pattern gives 80% of GraphQL's data-fetching benefit with 20% of the complexity. For most CRUD APIs, this is the right trade-off.

## Metadata and Timestamps

Every resource must include temporal metadata. Always.

**Required fields on every resource:**
- `id` -- Prefixed, globally unique identifier
- `created_at` -- ISO 8601 timestamp, set once at creation, never changes
- `updated_at` -- ISO 8601 timestamp, updated on every mutation

**Optional metadata fields:**
- `object` -- String type discriminator (`"customer"`, `"order"`) for polymorphic deserialization
- `deleted` -- Boolean, present on soft-deleted resources
- `metadata` -- Client-controlled key-value store for custom data (Stripe pattern)

Use ISO 8601 for all timestamps (`2026-02-22T10:30:00Z`). Unix timestamps are harder to read in logs and documentation. If you must support Unix timestamps, offer them as an alternative representation, not the primary one.

## Response Consistency Rules

1. **Same shape everywhere.** A customer object returned from `GET /customers/42`, listed in `GET /customers`, embedded in an order via expand, or returned from `POST /customers` must have the identical shape. No "summary" vs "detail" variants unless explicitly requested via `?fields=`.
2. **Null means absent, not undefined.** If a field has no value, return `null`. Never omit the key entirely -- clients that destructure the response will break.
3. **Empty collections are empty arrays, not null.** `"tags": []` not `"tags": null`. Clients should not need a null check before iterating.
4. **Stable field ordering is a courtesy.** JSON objects are unordered by spec, but returning fields in a consistent order (id first, timestamps last) makes responses easier to scan in logs and documentation.
5. **Never nest success data inside a `success` or `ok` wrapper.** Use HTTP status codes for success/failure signaling. `200` with `{ "success": false }` breaks HTTP semantics and is invisible to CDNs, monitoring tools, and client HTTP libraries.

## Pagination Parameters

Standardize pagination query parameters across all list endpoints:

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `limit` | integer | 20 | Items per page (1-100) |
| `after` | string | -- | Cursor: return items after this position |
| `before` | string | -- | Cursor: return items before this position |
| `include_count` | boolean | false | Opt-in total count (expensive) |

**Filtering and sorting apply before pagination.** If a client requests `?status=active&sort=created_at&limit=20&after=cursor`, the server filters to active items, sorts by `created_at`, then paginates from the cursor position.

**Enforce a maximum `limit`.** Without a cap, a client requesting `?limit=1000000` can exhaust server resources. Cap at 100 for most endpoints. Document the cap. Return `400` if exceeded.

**Store pagination state in URLs.** Every paginated response should include enough information for the client to construct the next request. The `next_cursor` value plus the documented parameter name (`after`) is sufficient. Some APIs go further and return full next/previous URLs -- this is a convenience but couples the response to a specific domain.

## Examples

Working implementations in `examples/`:
- **`examples/cursor-pagination.md`** -- Complete cursor-based pagination with Stripe-style `has_more` + `next_cursor`, in Node.js/Express and Python/FastAPI with database queries
- **`examples/expandable-objects.md`** -- Expand pattern implementation where `?expand[]=customer` inlines the full customer object instead of just the ID, in Node.js and Python

## Review Checklist

When reviewing or building API response formats:

- [ ] POST returns 201 with the full created resource and a `Location` header
- [ ] PUT/PATCH returns 200 with the full updated resource
- [ ] Every resource includes `id`, `created_at`, and `updated_at`
- [ ] List endpoints use a consistent envelope: `{ data: [], has_more, next_cursor }`
- [ ] Pagination is cursor-based by default (not offset-based)
- [ ] `limit` parameter is capped (max 100) and documented
- [ ] `has_more` is determined using the `LIMIT + 1` fetch trick (no separate count query)
- [ ] `total_count` is opt-in, not included by default
- [ ] Clients can request expanded related objects via `?expand[]=field`
- [ ] Expansion depth is limited (3-4 levels max)
- [ ] Resource shape is identical everywhere it appears (GET, list, embed, mutation response)
- [ ] Empty collections return `[]`, not `null`

Files in this skill

  • SKILL.md11 KB
  • examples/cursor-pagination.md9.6 KB
  • examples/expandable-objects.md14.9 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.