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 claudepluginhub oborchers/fractional-cto --plugin api-design-principlesThis skill uses the workspace's default tool permissions.
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.
Fetches up-to-date documentation from Context7 for libraries and frameworks like React, Next.js, Prisma. Use for setup questions, API references, and code examples.
Retrieves current documentation, API references, and code examples for libraries, frameworks, SDKs, CLIs, and services via Context7 CLI. Ideal for API syntax, configs, migrations, and setup queries.
Uses ctx7 CLI to fetch current library docs, manage AI coding skills (install/search/generate), and configure Context7 MCP for AI editors.
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": "ada@example.com"
},
"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 null