api-design — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited api-design (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Conventions and best practices for designing consistent, developer-friendly REST APIs and GraphQL APIs.
#### URL Structure
# Resources are nouns, plural, lowercase, kebab-case
GET /api/v1/users
GET /api/v1/users/:id
POST /api/v1/users
PUT /api/v1/users/:id
PATCH /api/v1/users/:id
DELETE /api/v1/users/:id
# Sub-resources for relationships
GET /api/v1/users/:id/orders
POST /api/v1/users/:id/orders
# Actions that don't map to CRUD (use verbs sparingly)
POST /api/v1/orders/:id/cancel
POST /api/v1/auth/login
POST /api/v1/auth/refresh#### Naming Rules
# GOOD
/api/v1/team-members # kebab-case for multi-word resources
/api/v1/orders?status=active # query params for filtering
/api/v1/users/123/orders # nested resources for ownership
# BAD
/api/v1/getUsers # verb in URL
/api/v1/user # singular (use plural)
/api/v1/team_members # snake_case in URLs
/api/v1/users/123/getOrders # verb in nested resource#### Method Semantics
| Method | Idempotent | Safe | Use For |
|---|---|---|---|
| GET | Yes | Yes | Retrieve resources |
| POST | No | No | Create resources, trigger actions |
| PUT | Yes | No | Full replacement of a resource |
| PATCH | No* | No | Partial update of a resource |
| DELETE | Yes | No | Remove a resource |
*PATCH can be made idempotent with proper implementation
#### Status Code Reference
# Success
200 OK — GET, PUT, PATCH (with response body)
201 Created — POST (include Location header)
204 No Content — DELETE, PUT (no response body)
207 Multi-Status — Bulk operations with per-item results
304 Not Modified — Conditional GET, resource unchanged
# Client Errors
400 Bad Request — Validation failure, malformed JSON
401 Unauthorized — Missing or invalid authentication
403 Forbidden — Authenticated but not authorized
404 Not Found — Resource doesn't exist
409 Conflict — Duplicate entry, state conflict
422 Unprocessable Entity — Semantically invalid (valid JSON, bad data)
429 Too Many Requests — Rate limit exceeded
# Server Errors
500 Internal Server Error — Unexpected failure (never expose details)
502 Bad Gateway — Upstream service failed
503 Service Unavailable — Temporary overload, include Retry-After#### Common Mistakes
# BAD: 200 for everything
{ "status": 200, "success": false, "error": "Not found" }
# GOOD: Use HTTP status codes semantically
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{ "type": "https://example.com/errors/not-found", "title": "Not Found", "status": 404, "detail": "User not found" }
# BAD: 500 for validation errors
# GOOD: 400 or 422 with field-level details
# BAD: 200 for created resources
# GOOD: 201 with Location header
HTTP/1.1 201 Created
Location: /api/v1/users/abc-123#### Success Response
{
"data": {
"id": "abc-123",
"email": "[email protected]",
"name": "Alice",
"created_at": "2025-01-15T10:30:00Z"
}
}#### Collection Response (with Pagination)
{
"data": [
{ "id": "abc-123", "name": "Alice" },
{ "id": "def-456", "name": "Bob" }
],
"meta": {
"total": 142,
"page": 1,
"per_page": 20,
"total_pages": 8
},
"links": {
"self": "/api/v1/users?page=1&per_page=20",
"next": "/api/v1/users?page=2&per_page=20",
"last": "/api/v1/users?page=8&per_page=20"
}
}#### Error Response (RFC 7807 Problem Details)
Use Content-Type: application/problem+json for all error responses.
{
"type": "https://example.com/errors/validation",
"title": "Validation Failed",
"status": 422,
"detail": "3 fields failed validation",
"instance": "/orders/123",
"errors": [
{ "field": "email", "message": "Invalid format", "code": "INVALID_EMAIL" }
]
}| Field | Required | Description |
|---|---|---|
type | Yes | URI identifying the problem type |
title | Yes | Short, human-readable summary |
status | Yes | HTTP status code |
detail | No | Human-readable explanation for this occurrence |
instance | No | URI reference to the specific occurrence |
errors | No | Extension: field-level validation errors |
#### Response Envelope Variants
// Option A: Envelope with data wrapper (recommended for public APIs)
interface ApiResponse<T> {
data: T;
meta?: PaginationMeta;
links?: PaginationLinks;
}
// Error: RFC 7807 Problem Details (Content-Type: application/problem+json)
interface ProblemDetails {
type: string;
title: string;
status: number;
detail?: string;
instance?: string;
errors?: FieldError[];
}
// Option B: Flat response (simpler, common for internal APIs)
// Success: just return the resource directly
// Error: RFC 7807 Problem Details object
// Distinguish by HTTP status code#### Offset-Based (Simple)
GET /api/v1/users?page=2&per_page=20
# Implementation
SELECT * FROM users
ORDER BY created_at DESC
LIMIT 20 OFFSET 20;Pros: Easy to implement, supports "jump to page N" Cons: Slow on large offsets (OFFSET 100000), inconsistent with concurrent inserts
#### Cursor-Based (Default for Unbounded Data)
GET /api/v1/users?cursor=eyJpZCI6MTIzfQ&limit=20
# Implementation
SELECT * FROM users
WHERE id > :cursor_id
ORDER BY id ASC
LIMIT 21; -- fetch one extra to determine has_next{
"data": [...],
"meta": {
"has_next": true,
"next_cursor": "eyJpZCI6MTQzfQ"
}
}Pros: Consistent performance regardless of position, stable with concurrent inserts Cons: Cannot jump to arbitrary page, cursor is opaque
#### When to Use Which
Cursor pagination is the default for datasets that grow unboundedly; use offset pagination only for fixed/small datasets.
| Use Case | Pagination Type |
|---|---|
| Admin dashboards, small datasets (<10K) | Offset |
| Infinite scroll, feeds, large datasets | Cursor (default) |
| Public APIs | Cursor (default) with offset (optional) |
| Search results | Offset (users expect page numbers) |
#### Filtering
# Simple equality (flat notation)
GET /api/v1/orders?status=active&customer_id=abc-123
# Comparison operators (bracket notation for ranges only)
GET /api/v1/products?price[gte]=10&price[lte]=100
GET /api/v1/orders?created_at[after]=2025-01-01
# Multiple values (comma-separated)
GET /api/v1/products?category=electronics,clothing
# Nested fields (dot notation)
GET /api/v1/orders?customer.country=US#### Sorting
# Single field (prefix - for descending)
GET /api/v1/products?sort=-created_at
# Multiple fields (comma-separated)
GET /api/v1/products?sort=-featured,price,-created_at#### Full-Text Search
# Search query parameter
GET /api/v1/products?q=wireless+headphones
# Field-specific search
GET /api/v1/users?email=alice#### Sparse Fieldsets
# Return only specified fields (reduces payload)
GET /api/v1/users?fields=id,name,email
GET /api/v1/orders?fields=id,total,status&include=customer.name#### Token-Based Auth
# Bearer token in Authorization header
GET /api/v1/users
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
# API key (for server-to-server)
GET /api/v1/data
X-API-Key: sk_live_abc123#### Authorization Patterns
// Resource-level: check ownership
app.get("/api/v1/orders/:id", async (req, res) => {
const order = await Order.findById(req.params.id);
if (!order) return res.status(404).set("Content-Type", "application/problem+json").json({ type: "https://example.com/errors/not-found", title: "Not Found", status: 404 });
if (order.userId !== req.user.id) return res.status(403).set("Content-Type", "application/problem+json").json({ type: "https://example.com/errors/forbidden", title: "Forbidden", status: 403 });
return res.json({ data: order });
});
// Role-based: check permissions
app.delete("/api/v1/users/:id", requireRole("admin"), async (req, res) => {
await User.delete(req.params.id);
return res.status(204).send();
});For all non-idempotent POST/PATCH operations, require Idempotency-Key: <UUID v4> header.
re-executing the operation
POST /api/v1/orders
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000For cacheable GETs, return ETag and Last-Modified headers. Clients use If-None-Match / If-Modified-Since to skip re-fetching unchanged resources. Return 304 Not Modified when the resource is unchanged.
# Server response
HTTP/1.1 200 OK
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Last-Modified: Tue, 15 Jan 2025 10:30:00 GMT
# Client conditional request
GET /api/v1/users/123
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"
# Server response when unchanged
HTTP/1.1 304 Not ModifiedUse POST /resources/batch for bulk create/update. Return 207 Multi-Status with per-item results:
{
"results": [
{ "id": "1", "status": 201, "data": {} },
{ "id": "2", "status": 422, "error": "Invalid email" }
]
}Two distinct endpoints, do NOT merge them:
GET /health: liveness: always returns 200 if the process is alive; never checks dependenciesGET /ready: readiness: checks DB, cache, external deps; returns 503 if any are degradedThese endpoints are outside the versioned API path (no /v1/ prefix).
// GET /ready — degraded example
{ "status": "degraded", "checks": { "db": "ok", "redis": "timeout" } }#### Rate Limiting Headers
Include on all rate-limited responses:
X-RateLimit-Limit: 100X-RateLimit-Remaining: 42X-RateLimit-Reset: 1714000000 (UTC epoch seconds)Return 429 when limit exceeded. Include Retry-After header.
HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1714000000
# When exceeded
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1714000000
Retry-After: 60
Content-Type: application/problem+json
{
"type": "https://example.com/errors/rate-limit-exceeded",
"title": "Too Many Requests",
"status": 429,
"detail": "Rate limit exceeded. Try again in 60 seconds."
}#### Rate Limit Tiers
| Tier | Limit | Window | Use Case |
|---|---|---|---|
| Anonymous | 30/min | Per IP | Public endpoints |
| Authenticated | 100/min | Per user | Standard API access |
| Premium | 1000/min | Per API key | Paid API plans |
| Internal | 10000/min | Per service | Service-to-service |
#### URL Path Versioning (Recommended)
/api/v1/users
/api/v2/usersPros: Explicit, easy to route, cacheable Cons: URL changes between versions
#### Header Versioning
GET /api/users
Accept: application/vnd.myapp.v2+jsonPros: Clean URLs Cons: Harder to test, easy to forget
#### Version Lifecycle
Deprecation and Sunset response headers:Deprecation: true
Sunset: Sat, 01 Jan 2026 00:00:00 GMT#### TypeScript (Next.js API Route)
import { z } from "zod";
import { NextRequest, NextResponse } from "next/server";
const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
});
export async function POST(req: NextRequest) {
// Idempotency-Key required for non-idempotent POST
const idempotencyKey = req.headers.get("Idempotency-Key");
const body = await req.json();
const parsed = createUserSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({
type: "https://example.com/errors/validation",
title: "Validation Failed",
status: 422,
detail: "Request validation failed",
errors: parsed.error.issues.map(i => ({
field: i.path.join("."),
message: i.message,
code: i.code,
})),
}, {
status: 422,
headers: { "Content-Type": "application/problem+json" },
});
}
const user = await createUser(parsed.data);
return NextResponse.json(
{ data: user },
{
status: 201,
headers: { Location: `/api/v1/users/${user.id}` },
},
);
}#### Python (Django REST Framework)
from rest_framework import serializers, viewsets, status
from rest_framework.response import Response
class CreateUserSerializer(serializers.Serializer):
email = serializers.EmailField()
name = serializers.CharField(max_length=100)
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ["id", "email", "name", "created_at"]
class UserViewSet(viewsets.ModelViewSet):
serializer_class = UserSerializer
permission_classes = [IsAuthenticated]
def get_serializer_class(self):
if self.action == "create":
return CreateUserSerializer
return UserSerializer
def create(self, request):
serializer = CreateUserSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = UserService.create(**serializer.validated_data)
return Response(
{"data": UserSerializer(user).data},
status=status.HTTP_201_CREATED,
headers={"Location": f"/api/v1/users/{user.id}"},
)#### Go (net/http)
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// writeProblem sets Content-Type: application/problem+json
writeProblem(w, http.StatusBadRequest, "https://example.com/errors/invalid-json", "Invalid Request Body")
return
}
if err := req.Validate(); err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "https://example.com/errors/validation", "Validation Failed")
return
}
user, err := h.service.Create(r.Context(), req)
if err != nil {
switch {
case errors.Is(err, domain.ErrEmailTaken):
writeProblem(w, http.StatusConflict, "https://example.com/errors/email-taken", "Email Already Registered")
default:
writeProblem(w, http.StatusInternalServerError, "https://example.com/errors/internal", "Internal Server Error")
}
return
}
w.Header().Set("Location", fmt.Sprintf("/api/v1/users/%s", user.ID))
writeJSON(w, http.StatusCreated, map[string]any{"data": user})
}Before shipping a new endpoint:
application/problem+json)Idempotency-Key header required for non-idempotent POST/PATCHETag / Last-Modified returned for cacheable GETsX-RateLimit-* headersDeprecation / Sunset headers set on deprecated endpoints/health (liveness) and /ready (readiness) endpoints present and separated#### Schema Design
PascalCase: UserProfile, OrderItemcamelCase: createdAt, totalAmountUPPER_SNAKE_CASE: ORDER_STATUS_PENDINGInput: CreateOrderInputPayload: CreateOrderPayload#### Mutation Payload Structure
All mutations return a consistent payload:
type CreateOrderPayload {
success: Boolean!
errors: [UserError!]!
order: Order
}
type UserError {
field: String
message: String!
code: String!
}#### N+1 Prevention, DataLoader
Always use DataLoader for batching related-object fetches:
const userLoader = new DataLoader(async (ids: readonly string[]) => {
const users = await db.users.findMany({ where: { id: { in: [...ids] } } })
return ids.map(id => users.find(u => u.id === id) ?? null)
})#### Authorization
#### Production Security
#### Subscriptions
Use graphql-ws protocol (not the deprecated subscriptions-transport-ws).
#### Schema Evolution
@deprecated(reason: "Use X instead")graphql-inspector; block breaking changes#### Pagination
Follow the Relay Cursor Connections Specification for all paginated fields:
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.