api-design-principles/skills/response-design-and-pagination/SKILL.md
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.
npx skillsauth add oborchers/fractional-cto response-design-and-paginationInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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.
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:
Location header204 No Content or 200 OK with a deletion confirmation objectcreated_at and updated_at timestamps on every resource -- clients need them for display, sorting, and cache invalidationThe 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.
Wrap every list response in a consistent envelope. Never return a naked top-level array.
Use this structure for every list endpoint:
{
"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.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.
| 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 |
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.
Default to returning references (IDs). Let clients request full objects with ?expand[]=field.
Without expand:
{
"id": "ord_01HXK3GJ5V",
"customer": "cus_4QFJOjw2pOmAGJ",
"line_items": [
{ "id": "li_01ABC", "product": "prod_NWjs8kKb" }
]
}
With ?expand[]=customer&expand[]=line_items.product:
{
"id": "ord_01HXK3GJ5V",
"customer": {
"id": "cus_4QFJOjw2pOmAGJ",
"name": "Ada Lovelace",
"email": "[email protected]"
},
"line_items": [
{
"id": "li_01ABC",
"product": {
"id": "prod_NWjs8kKb",
"name": "Pro Plan",
"price": 4900
}
}
]
}
Rules:
?expand[]=data.customer to expand fields on every item in the list.Every resource must include temporal metadata. Always.
Required fields on every resource:
id -- Prefixed, globally unique identifiercreated_at -- ISO 8601 timestamp, set once at creation, never changesupdated_at -- ISO 8601 timestamp, updated on every mutationOptional metadata fields:
object -- String type discriminator ("customer", "order") for polymorphic deserializationdeleted -- Boolean, present on soft-deleted resourcesmetadata -- 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.
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=.null. Never omit the key entirely -- clients that destructure the response will break."tags": [] not "tags": null. Clients should not need a null check before iterating.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.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.
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 queriesexamples/expandable-objects.md -- Expand pattern implementation where ?expand[]=customer inlines the full customer object instead of just the ID, in Node.js and PythonWhen reviewing or building API response formats:
Location headerid, created_at, and updated_at{ data: [], has_more, next_cursor }limit parameter is capped (max 100) and documentedhas_more is determined using the LIMIT + 1 fetch trick (no separate count query)total_count is opt-in, not included by default?expand[]=field[], not nulltools
This skill should be used when the user invokes any /plan-* command from the planning-tools plugin (/plan-context, /plan-master, /plan-open-questions, /plan-verify, /plan-tick, /plan-progress, /plan-delete), asks how Claude Code's plan files work, asks where plans are stored, asks to author or audit a multi-phase master planning document, asks how to walk through a plan's Open Questions interactively, asks how to write progress entries, or mentions ~/.claude/plans/ or .claude/planning-tools.local.md. Provides the index of planning-tools commands, the master-plan workflow lifecycle, the v0.3.0+ list-shape mandate (phases and questions as headings + bulleted scope items, never tables), the v0.3.2+ plain-bullet shape (no `- [ ]` checkboxes — heading emoji is the sole tick signal), the progress-entry methodology, and the mechanics of Claude Code's plan-mode file storage.
testing
This skill should be used by the plan-verifier agent and the /plan-verify command to audit a drafted master plan against a fixed checklist. Covers universal-core completeness, the v0.3.0+ no-tables-for-phases-or-questions rule, trigger-based section-coverage gaps, phase actionability (heading + per-phase TL;DR + bulleted scope + exit criteria), the v0.3.1+ per-phase TL;DR requirement, the v0.3.2+ plain-bullet scope shape (legacy `- [ ]`/`- [x]` accepted silently), the v0.3.3+ context-block shape (plan-level `**TL;DR:**` + bulleted metadata, legacy `>` blockquote accepted silently), integer phase numbering enforcement, dependency traceability, citation resolution, callout/evidence convention compliance, Open Questions placement, and the one-PR-per-master-plan rule. Single-owner of the audit checklist.
tools
This skill should be used when authoring, reviewing, or modifying a multi-phase master planning document via the planning-tools plugin (especially the /plan-master and /plan-verify commands). Codifies the universal core sections, trigger-based optional sections, integer-only phase numbering, Open Questions placement, one-PR-per-plan rule, status conventions, evidence attribution, callouts, cross-reference formats, the v0.3.0 list-shape mandate (phases and questions are heading + bulleted list, never markdown tables), the v0.3.1 per-phase TL;DR requirement (1–3 sentence what/why summary under each phase heading for glance-ability), the v0.3.2 plain-bullet scope shape (`- <action>` items, no `- [ ]` checkboxes — the phase status emoji is the sole tick signal), and the v0.3.3 context-block shape (a plan-level `**TL;DR:**` + a bulleted metadata list instead of a `>` blockquote; legacy blockquote blocks accepted silently). Project-agnostic — no ticket-prefix or plan-type taxonomy.
testing
This skill should be used when the user is adjusting spacing, padding, margins, content density, section gaps, vertical rhythm, or separation between elements. Also applies when reviewing whether a design feels cramped or too sparse, choosing between borders and whitespace for separation, or defining a spacing system. Covers the 4px/8px spacing system, macro vs micro whitespace, content density spectrum, separation techniques (whitespace > background shifts > borders), and vertical rhythm.