---
name: api-design-principles
type: skill
description: REST API design decisions — URL conventions, error contracts, versioning, pagination, idempotency, auth patterns.
related-rules:
  - api-design-guide.md
  - security-guide.md
allowed-tools: Read, Write, Edit, Bash
---

# API Design Principles Skill

> Practical reference for consistent, production-ready API design decisions.

## URL & Method Conventions

```
✅ Plural nouns, kebab-case, resource hierarchy max 2 levels
   GET    /users/{id}
   POST   /orders
   PATCH  /orders/{id}
   DELETE /orders/{id}
   POST   /orders/{id}/cancel      ← actions as sub-resource verbs

❌ Verbs in base path
   POST /createOrder
   GET  /getUser?id=123
```

| Operation | Method | Success code |
|---|---|---|
| Create | POST | 201 |
| Read | GET | 200 |
| Full update | PUT | 200 |
| Partial update | PATCH | 200 |
| Delete | DELETE | 204 |
| Async action | POST | 202 |

## Standard Error Contract

Every error must follow the same shape — never return raw exception messages.

```json
{
  "error": {
    "code": "ORDER_NOT_FOUND",
    "message": "Order ord_123 not found",
    "details": [{ "field": "items[0].quantity", "issue": "must be > 0" }],
    "request_id": "req_abc123"
  }
}
```

```python
# FastAPI
raise HTTPException(
    status_code=404,
    detail={"code": "ORDER_NOT_FOUND", "message": f"Order {id} not found",
            "request_id": request.state.request_id}
)
```

## Pagination

**Cursor-based** — preferred for live/large datasets:
```python
class PaginatedResponse(BaseModel, Generic[T]):
    items: List[T]
    next_cursor: Optional[str] = None   # base64-encoded, opaque to client

def encode_cursor(last_id: int) -> str:
    return base64.b64encode(str(last_id).encode()).decode()
```

**Offset-based** — only for small static datasets where total count is cheap.

## Versioning

```
URL versioning for breaking changes:   /api/v1/orders → /api/v2/orders
Header for minor variations:           Accept: application/vnd.myapi.v2+json

Rules:
- v1 stays alive ≥ 6 months after v2 launch
- Deprecated: return  Deprecation: true  +  Sunset: <date>  headers
- Never remove a field without a major version bump
```

## Idempotency (POST mutations)

```python
@router.post("/orders", status_code=201)
async def create_order(
    body: CreateOrderRequest,
    idempotency_key: Optional[str] = Header(None, alias="X-Idempotency-Key"),
):
    if idempotency_key:
        cached = await redis.get(f"idempotency:{idempotency_key}")
        if cached:
            return JSONResponse(json.loads(cached), status_code=200)

    order = await order_service.create(body)

    if idempotency_key:
        await redis.setex(f"idempotency:{idempotency_key}", 86400, order.model_dump_json())
    return order
```

## Security Checklist

- [ ] Auth middleware applied before handler — never inside handler
- [ ] Ownership check: `if resource.owner_id != current_user.id: raise 403`
- [ ] All inputs validated via Pydantic/Zod before use
- [ ] Rate limiting on public + auth endpoints
- [ ] No secrets or PII in URL parameters
- [ ] CORS: no `allow_origins=["*"]` in production
- [ ] Response never leaks internal IDs, stack traces, or system paths

## Response Design Checklist

- [ ] Consistent envelope: `{ data: T }` or flat — choose one, never mix
- [ ] Dates always ISO 8601 with timezone: `2024-03-15T14:30:00Z`
- [ ] Monetary values: string decimal or integer cents — never float
- [ ] Nullable fields explicit in schema (not absent)
- [ ] List responses always return array, never `null` when empty
