From python
Guides REST API design with standards for resource naming, versioning, and RFC 7807 error responses. Use when designing endpoints, pagination, or API structure.
How this skill is triggered — by the user, by Claude, or both
Slash command
/python:typescript-api-designThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Best practices for designing REST APIs with consistent structure, error handling, and resource patterns.
Best practices for designing REST APIs with consistent structure, error handling, and resource patterns.
Use consistent, predictable URL patterns:
# Collection resources (plural nouns)
GET /api/v1/users # List users
POST /api/v1/users # Create user
GET /api/v1/users/:id # Get user
PUT /api/v1/users/:id # Update user (full)
PATCH /api/v1/users/:id # Update user (partial)
DELETE /api/v1/users/:id # Delete user
# Nested resources
GET /api/v1/users/:userId/posts # List user's posts
POST /api/v1/users/:userId/posts # Create post for user
GET /api/v1/users/:userId/posts/:postId # Get specific post
# Actions (use verbs sparingly)
POST /api/v1/users/:id/activate # Activate user
POST /api/v1/posts/:id/publish # Publish post
POST /api/v1/invoices/:id/send # Send invoice
/users, not /user)/ledger-accounts)/users/:id/posts/:id)Version APIs in the URL path:
/api/v1/users
/api/v2/users
# Not in headers (harder to test/debug)
# Not in query params (breaks caching)
// v1/routes.ts
export async function v1Routes(app: FastifyInstance) {
app.get('/users', getUsersV1)
app.post('/users', createUserV1)
}
// v2/routes.ts
export async function v2Routes(app: FastifyInstance) {
app.get('/users', getUsersV2) // Breaking change in response structure
app.post('/users', createUserV2)
}
// server.ts
app.register(v1Routes, { prefix: '/api/v1' })
app.register(v2Routes, { prefix: '/api/v2' })
Standardized error response format:
interface ProblemDetail {
type: string // Error type identifier
status: number // HTTP status code
title: string // Short, human-readable summary
detail: string // Specific explanation for this occurrence
instance: string // URI reference to specific occurrence
traceId: string // Request trace ID for debugging
}
// Example error response
{
"type": "NOT_FOUND",
"status": 404,
"title": "Not Found",
"detail": "User with ID usr_01h455vb4pex5vsknk084sn02q not found",
"instance": "/api/v1/users/usr_01h455vb4pex5vsknk084sn02q",
"traceId": "req_abc123xyz"
}
// Domain error base class
abstract class AppError extends Error {
abstract readonly status: number
abstract readonly type: string
constructor(message: string, public readonly context?: ErrorContext) {
super(message)
}
toResponse(instance: string, traceId: string): ProblemDetail {
return {
type: this.type,
status: this.status,
title: this.name,
detail: this.message,
instance,
traceId,
...this.context,
}
}
}
// Specific error types
class NotFoundError extends AppError {
readonly status = 404
readonly type = 'NOT_FOUND'
}
class ConflictError extends AppError {
readonly status = 409
readonly type = 'CONFLICT'
constructor(
message: string,
public readonly retryable: boolean = false,
context?: ErrorContext
) {
super(message, context)
}
}
class ServiceUnavailableError extends AppError {
readonly status = 503
readonly type = 'SERVICE_UNAVAILABLE'
constructor(
message: string,
public readonly retryable: boolean = true,
context?: ErrorContext
) {
super(message, context)
}
}
See references/error-responses.md for complete examples.
Use cursor-based pagination for large datasets:
// Request
GET /api/v1/posts?limit=20&cursor=pst_01h455vb4pex5vsknk084sn02q
// Response
{
"items": [
{ "id": "pst_01h455w3x8k5z9y7q1m0n2b3c4", ... },
{ "id": "pst_01h455x2y9l6a0z8r2n1o3c5d6", ... }
],
"nextCursor": "pst_01h455z1a0m7b8y9s3o2p4d6e7",
"hasMore": true
}
interface PaginatedRequest {
limit?: number // Max items to return (default 20, max 100)
cursor?: string // Cursor for next page (opaque to client)
}
interface PaginatedResponse<T> {
items: T[]
nextCursor?: string
hasMore: boolean
}
async function listPosts(req: PaginatedRequest): Promise<PaginatedResponse<Post>> {
const limit = Math.min(req.limit ?? 20, 100)
const queryLimit = limit + 1 // Fetch one extra to check hasMore
const posts = await db.query.posts.findMany({
where: req.cursor ? gt(posts.id, req.cursor) : undefined,
orderBy: desc(posts.createdAt),
limit: queryLimit,
})
const hasMore = posts.length > limit
const items = posts.slice(0, limit)
const nextCursor = hasMore ? items[items.length - 1].id : undefined
return { items, nextCursor, hasMore }
}
❌ Offset-based (/posts?offset=40&limit=20)
- Unstable: Items can shift if new records inserted
- Performance: DB must scan all previous rows
- Inaccurate: Can miss or duplicate items
✅ Cursor-based (/posts?cursor=pst_xyz&limit=20)
- Stable: Cursor points to specific item
- Performant: DB uses index seek
- Accurate: No gaps or duplicates
Use query parameters for filtering and sorting:
# Filtering
GET /api/v1/users?status=active&role=admin
GET /api/v1/posts?author=usr_abc&published=true
# Sorting
GET /api/v1/posts?sort=-createdAt # Descending (- prefix)
GET /api/v1/users?sort=name # Ascending
# Combined
GET /api/v1/posts?author=usr_abc&status=published&sort=-createdAt&limit=20
interface ListPostsQuery {
author?: string
status?: 'draft' | 'published'
sort?: 'createdAt' | '-createdAt' | 'title' | '-title'
limit?: number
cursor?: string
}
async function listPosts(query: ListPostsQuery): Promise<PaginatedResponse<Post>> {
const conditions = []
if (query.author) {
conditions.push(eq(posts.authorId, query.author))
}
if (query.status) {
conditions.push(eq(posts.status, query.status))
}
const orderByColumn = query.sort?.startsWith('-')
? query.sort.slice(1)
: query.sort ?? 'createdAt'
const orderByDirection = query.sort?.startsWith('-') ? desc : asc
return await db.query.posts.findMany({
where: conditions.length > 0 ? and(...conditions) : undefined,
orderBy: orderByDirection(posts[orderByColumn]),
limit: query.limit ?? 20,
})
}
Use status codes consistently:
# Success
200 OK # Successful GET, PUT, PATCH
201 Created # Successful POST (include Location header)
204 No Content # Successful DELETE, PUT with no response body
# Client Errors
400 Bad Request # Invalid request body/parameters
401 Unauthorized # Missing or invalid authentication
403 Forbidden # Valid auth, but lacks permission
404 Not Found # Resource doesn't exist
409 Conflict # Resource already exists, optimistic lock failure
422 Unprocessable # Validation error (semantic)
429 Too Many Requests # Rate limit exceeded
# Server Errors
500 Internal Server Error # Unexpected error
503 Service Unavailable # Temporary unavailability, retry later
Don't use envelopes for simple CRUD:
// ❌ Unnecessary wrapping
GET /api/v1/users/123
{
"success": true,
"data": { "id": "123", "name": "Alice" }
}
// ✅ Return resource directly
GET /api/v1/users/123
{
"id": "123",
"name": "Alice"
}
Use envelopes for pagination:
// ✅ Envelope needed for metadata
GET /api/v1/users?limit=20
{
"items": [...],
"nextCursor": "usr_xyz",
"hasMore": true
}
Use ISO 8601 format for all timestamps:
{
"createdAt": "2024-01-15T14:30:00.000Z", // ISO 8601 UTC
"updatedAt": "2024-01-16T09:15:30.123Z"
}
// In entities
toResponse(): UserResponse {
return {
...
createdAt: this.createdAt.toISOString(), // Date → ISO string
updatedAt: this.updatedAt.toISOString(),
}
}
Use idempotency keys for safe retries:
// Request
POST /api/v1/transactions
Headers:
Idempotency-Key: txn_abc123xyz
Body:
{ "amount": 100, "from": "usr_123", "to": "usr_456" }
// Implementation
async function createTransaction(rq: CreateTransactionRequest, idempotencyKey: string) {
// Check if transaction with this key already exists
const existing = await db.query.transactions.findFirst({
where: eq(transactions.idempotencyKey, idempotencyKey),
})
if (existing) {
return TransactionEntity.fromRecord(existing) // Return existing
}
// Create new transaction
const transaction = TransactionEntity.fromRequest(rq, idempotencyKey)
return await transactionRepo.create(transaction)
}
ledger-accounts/api/v1/, /api/v2/ for breaking changes.toISOString() for datesnpx claudepluginhub martinffx/atelier --plugin codeEstablishes REST API design patterns for resource naming, HTTP methods and status codes, pagination, filtering, error responses, versioning, and rate limiting for production APIs.
Guides REST API design patterns for production-grade endpoints including resource naming, status codes, pagination, filtering, error responses, versioning, and rate limiting. Use when designing new APIs or reviewing contracts.
Provides RESTful API design standards covering resource naming, HTTP methods, status codes, pagination, versioning, and error response formats.