api-endpoint-designer.skill.md---
name: api-endpoint-designer
description: >
Design RESTful API endpoints with correct HTTP methods, input validation,
consistent error responses, authentication middleware, and rate limiting.
Every endpoint is predictable and well-documented.
---
# API Endpoint Designer
You design RESTful APIs that are consistent, secure, and predictable.
## URL Design
### Resource Naming
- Use **plural nouns** for collections: `/users`, `/orders`, `/products`
- Use **IDs** for individual resources: `/users/:id`
- Use **nesting** for relationships: `/users/:id/orders`
- Maximum nesting depth: 2 levels (beyond that, use query params or top-level routes)
- Use **kebab-case**: `/user-profiles`, not `/userProfiles` or `/user_profiles`
### HTTP Methods
| Method | Purpose | Idempotent | Status |
|--------|---------|------------|--------|
| GET | Read resource(s) | Yes | 200 |
| POST | Create resource | No | 201 |
| PUT | Replace resource entirely | Yes | 200 |
| PATCH | Partial update | No | 200 |
| DELETE | Remove resource | Yes | 204 |
## Input Validation
Validate ALL inputs at the API boundary. Never trust client data.
```typescript
// Define a schema for every endpoint
const createUserSchema = z.object({
email: z.string().email().max(255),
name: z.string().min(1).max(100).trim(),
role: z.enum(["user", "admin"]).default("user"),
})
// Validate early, fail fast
export async function POST(req: Request): Promise<Response> {
const body = await req.json()
const parsed = createUserSchema.safeParse(body)
if (!parsed.success) {
return Response.json(
{ error: "Validation failed", details: parsed.error.flatten() },
{ status: 400 }
)
}
// parsed.data is now fully typed and validated
const user = await createUser(parsed.data)
return Response.json(user, { status: 201 })
}
```
### Validation Rules
1. **Validate types** — strings, numbers, booleans, arrays
2. **Validate constraints** — min/max length, regex patterns, enums
3. **Sanitize strings** — trim whitespace, escape HTML if stored
4. **Validate IDs** — ensure UUID format or numeric range
5. **Reject unknown fields** — don't silently accept extra properties
## Error Response Format
Every error response uses the same shape:
```json
{
"error": "Human-readable message",
"code": "MACHINE_READABLE_CODE",
"details": {},
"requestId": "uuid-for-debugging"
}
```
### Status Code Usage
| Code | When |
|------|------|
| 400 | Invalid input (validation failed) |
| 401 | Not authenticated (no valid token) |
| 403 | Not authorised (valid token, insufficient permissions) |
| 404 | Resource not found |
| 409 | Conflict (duplicate entry, version mismatch) |
| 422 | Semantically invalid (valid structure, impossible values) |
| 429 | Rate limit exceeded |
| 500 | Server error (log it, don't expose internals) |
### Never Expose Internals
```typescript
// BAD: Leaks implementation details
{ "error": "SQLITE_CONSTRAINT: UNIQUE constraint failed: users.email" }
// GOOD: Safe, actionable message
{ "error": "A user with this email already exists", "code": "DUPLICATE_EMAIL" }
```
## Authentication Middleware
```typescript
async function requireAuth(req: Request): Promise<User> {
const token = req.headers.get("Authorization")?.replace("Bearer ", "")
if (!token) {
throw new ApiError(401, "Authentication required", "NO_TOKEN")
}
const payload = await verifyToken(token)
if (!payload) {
throw new ApiError(401, "Invalid or expired token", "INVALID_TOKEN")
}
return payload.user
}
```
## Rate Limiting
Apply rate limits to all endpoints:
- **Authentication endpoints** (login, register): 5 requests/minute per IP
- **Read endpoints** (GET): 100 requests/minute per user
- **Write endpoints** (POST, PUT, DELETE): 30 requests/minute per user
- **Expensive operations** (search, export): 10 requests/minute per user
Include rate limit headers in responses:
```
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 97
X-RateLimit-Reset: 1709078400
```
## Pagination
Always paginate list endpoints. Use cursor-based for large datasets:
```typescript
// GET /users?cursor=abc123&limit=20
{
"data": [...],
"pagination": {
"nextCursor": "def456",
"hasMore": true
}
}
```
Rules:
- Default limit: 20, max limit: 100
- Never return unbounded results
- Include total count only if the query is cheap (indexed COUNT)