---
name: forge-api-design
description: REST and RPC discipline for backend APIs. Resource naming, canonical error shape, cursor pagination implementation, idempotency keys, status code decision logic, versioning. Contains worked TypeScript implementations of the patterns. Use when designing new endpoints or auditing existing ones.
license: MIT
---

# forge-api-design

You are designing an API that other engineers will integrate against for years. The API outlives every UI built on top of it. Default agent output produces inconsistent endpoint names, soft error responses, and missing pagination. This skill exists to fix that before the API ships.

## Quick reference

The five things you will never ship:

1. `POST /createUser` instead of `POST /users`.
2. `res.status(500).json({ error: e.message })` returning the raw error to the client.
3. Offset pagination (`?offset=100&limit=10`) on any endpoint that can grow past 10K rows.
4. No idempotency key support on mutating endpoints that take money.
5. Inconsistent JSON casing (`userId` here, `user_id` there).

## Hard rules

### Resource naming

**1. URLs name resources, not actions.**

```
POST   /users                  ✓ create
POST   /users/:id/archive      ✓ action that does not fit CRUD
GET    /users/:id/orders       ✓ subresource

POST   /createUser             ✗ verb in path
GET    /getUsers               ✗ verb in path
POST   /user_creation          ✗ noun-as-action
```

**2. Plural resource names.** `/users`, `/orders`. Never `/user`, never `/userList`.

**3. Pick one JSON casing and use it everywhere.** snake_case or camelCase. Mixing is the most common AI-generated API smell.

```ts
// BAD: inconsistent
res.json({ userId: 1, created_at: "2026-05-22" });

// GOOD: pick one (snake_case shown here)
res.json({ user_id: 1, created_at: "2026-05-22" });
```

**4. URLs are kebab-case.** `/api/billing-invoices`, not `/api/billing_invoices` or `/api/billingInvoices`.

**5. Identifiers are opaque strings, not numbers.** Use ULID or UUID. Autoincrement IDs leak row counts and invite enumeration.

### Status codes (decision logic)

Memorize this short list and use the decision tree below:

```
200 OK            successful GET, PUT, PATCH with body
201 Created       successful POST that created a resource (include the resource + Location header)
204 No Content    successful DELETE or PUT/PATCH with no body
400 Bad Request   malformed payload (parse failure, type mismatch)
401 Unauthorized  missing or invalid auth
403 Forbidden     valid auth but no permission
404 Not Found     missing resource (also: resource the caller is not allowed to know exists)
409 Conflict      state conflict (duplicate key, version mismatch)
422 Unprocessable valid payload that fails business rules
429 Too Many      rate limited (include Retry-After)
500 Internal      unhandled server error ONLY
```

**Decision tree: which status code?**

```
Did the request parse?
  No  → 400 Bad Request
  Yes ↓

Is auth present and valid?
  No  → 401 Unauthorized
  Yes ↓

Does the caller have permission?
  No  → 403 Forbidden (or 404 if you must not reveal existence)
  Yes ↓

Does the resource exist (for GET/PATCH/DELETE)?
  No  → 404 Not Found
  Yes ↓

Does the payload pass business rules?
  No  → 422 Unprocessable Entity
  Yes ↓

Is there a state conflict (duplicate, stale version)?
  Yes → 409 Conflict
  No  ↓

Rate limited?
  Yes → 429 Too Many Requests
  No  ↓

Did the action succeed?
  Yes → 200 OK / 201 Created / 204 No Content
  No  → 500 Internal Server Error (and only if it really is a server bug)
```

**6. 422 vs 400.** 400 = "I could not parse what you sent." 422 = "I parsed it but it does not pass business validation." Mixing these forces clients to write fragile error handling.

### Canonical error shape

**7. One error shape across the whole API. No exceptions.**

```ts
type ApiError = {
  error: {
    code: string;          // machine-readable, stable contract
    message: string;       // human-readable, may change
    details?: Record<string, unknown>;  // endpoint-specific structured data
    request_id: string;    // for log correlation
  };
};
```

Full canonical example:

```json
{
  "error": {
    "code": "user_email_taken",
    "message": "A user with this email already exists.",
    "details": { "field": "email" },
    "request_id": "01HXY..."
  }
}
```

```ts
// reference implementation
function errorResponse(
  code: string,
  message: string,
  status: number,
  requestId: string,
  details?: Record<string, unknown>,
) {
  return Response.json(
    { error: { code, message, request_id: requestId, ...(details ? { details } : {}) } },
    { status },
  );
}

// use:
return errorResponse(
  "user_email_taken",
  "A user with this email already exists.",
  422,
  req.id,
  { field: "email" },
);
```

**8. Error codes are stable contract.** Once you ship `user_email_taken`, you do not rename it to `email_already_used` later. Clients depend on the string.

**9. Never return a stack trace in production responses.** Log it server-side under `request_id`; let support pull the trace from logs.

```ts
// BAD
catch (e) {
  return res.status(500).json({ error: e.message, stack: e.stack });
}

// GOOD
catch (e) {
  logger.error({ err: e, requestId: req.id }, "charge failed");
  return errorResponse("internal_error", "Something went wrong.", 500, req.id);
}
```

### Pagination (cursor implementation)

**10. Cursor-based, not offset.** Offset breaks under inserts and gets slow at high page numbers.

Cursor-based pagination, end to end:

```ts
// types
type Cursor = { id: string; created_at: string };

function encodeCursor(c: Cursor): string {
  return Buffer.from(JSON.stringify(c)).toString("base64url");
}
function decodeCursor(s: string): Cursor | null {
  try { return JSON.parse(Buffer.from(s, "base64url").toString()); }
  catch { return null; }
}

// handler
async function listOrders(req: Request) {
  const url = new URL(req.url);
  const limit = Math.min(parseInt(url.searchParams.get("limit") || "50", 10), 200);
  const cursorParam = url.searchParams.get("cursor");
  const cursor = cursorParam ? decodeCursor(cursorParam) : null;

  // ordered by (created_at desc, id desc) so the index is stable across inserts
  const rows = await db.query(`
    SELECT id, created_at, total_cents, status
    FROM orders
    WHERE ($1::text IS NULL OR (created_at, id) < ($2, $1))
    ORDER BY created_at DESC, id DESC
    LIMIT $3
  `, [cursor?.id ?? null, cursor?.created_at ?? null, limit + 1]);

  const hasMore = rows.length > limit;
  const data = rows.slice(0, limit);
  const last = data[data.length - 1];
  const nextCursor = hasMore && last
    ? encodeCursor({ id: last.id, created_at: last.created_at })
    : null;

  return Response.json({ data, next_cursor: nextCursor, has_more: hasMore });
}
```

Response shape:

```json
{
  "data": [{ "id": "01HXY...", "total_cents": 12300, "status": "paid" }],
  "next_cursor": "eyJpZCI6IjAxSFhZ...",
  "has_more": true
}
```

**11. Default limit 20-50. Cap at 100 or 200.** Never let a client pass `limit=10000`.

**12. Total counts are optional and expensive.** Do not return `total` unless asked and you can compute it cheaply. Most APIs never return totals.

### Idempotency (reference implementation)

**13. Mutating endpoints accept an `Idempotency-Key` header.** Critical for payments, order creation, anything that double-charges on retry.

```ts
// reference middleware (Redis-backed, 24h TTL)
async function withIdempotency(req: Request, handler: () => Promise<Response>) {
  const key = req.headers.get("idempotency-key");
  if (!key) return handler();

  const cached = await redis.get(`idem:${key}`);
  if (cached) {
    return new Response(cached, {
      headers: { "content-type": "application/json", "x-idempotent-replay": "true" },
    });
  }

  const response = await handler();
  const body = await response.clone().text();
  await redis.setex(`idem:${key}`, 86400, body);
  return response;
}
```

**14. GET, PUT, DELETE are idempotent by definition.** POST and PATCH are not, which is why they need the header.

**15. Idempotency-Key collisions return the cached response, not 409.** That is the whole point.

### Versioning

**16. Version in the URL path (`/v1/users`) or in an `Accept` header. Pick one before the first endpoint ships.**

Path versioning is louder but obvious in logs and curl. Header versioning is cleaner but harder to debug. Most teams ship path versioning.

**17. Never break v1.** If you need to change semantics, ship v2 alongside.

**18. Additive changes are not breaking.** New field in a response, new optional field in a request, new endpoint - all fine within the same version. Removing or renaming is breaking.

### Auth

**19. Bearer tokens in `Authorization` header, never in query strings.** Query strings end up in logs, browser history, referer headers.

**20. API keys for server-to-server, OAuth or session for user contexts.** Do not invent a third scheme. See [`forge-auth`](../forge-auth/SKILL.md) for the detail.

### Rate limiting

**21. Return rate-limit headers on every response, not just on 429.**

```http
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 847
X-RateLimit-Reset: 1716393600
```

**22. On 429, include `Retry-After` in seconds.** A client that has to guess the retry window will retry too fast and amplify the problem.

### Webhooks

**23. Sign every webhook payload.** HMAC-SHA256 over the raw body, include signature in a header.

```ts
// signing
import { createHmac } from "node:crypto";
const sig = createHmac("sha256", WEBHOOK_SECRET).update(rawBody).digest("hex");
headers.set("X-Signature", sig);

// verifying (receiver side, with constant-time compare)
import { timingSafeEqual } from "node:crypto";
function verify(rawBody: string, sigHeader: string, secret: string) {
  const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
  return timingSafeEqual(Buffer.from(expected), Buffer.from(sigHeader));
}
```

**24. Retry with exponential backoff for at least 24 hours.** A receiver outage of 30 minutes should not lose events.

**25. Webhook payloads are events, not state.** Include enough to identify what happened, but the receiver should call your API to fetch fresh state.

## Common AI-output patterns to reject

| Pattern | Why it is wrong | Fix |
| --- | --- | --- |
| `POST /createUser` | Verb in URL | `POST /users` |
| `res.json({ users })` for paginated list | No pagination metadata | `{ data: [...], next_cursor, has_more }` |
| `catch (e) { res.json({ error: e.message }) }` | Leaks internals, no code, no request_id | Canonical `{ error: { code, message, request_id } }` |
| `res.status(500).json(...)` for missing user | Wrong code | 404 Not Found |
| `?page=5&per_page=10` | Offset pagination | Cursor-based |
| Inline regex for email validation | Wrong, every time | Use a schema lib (zod / valibot) |
| `Bearer` token in query string | Logged everywhere | `Authorization: Bearer ...` header |
| Webhook sent without signature | No way to verify origin | HMAC-SHA256 over raw body |
| Renaming a field in v1 response | Breaking change with no version bump | Add the new field, deprecate the old, ship v2 |

## Worked example: complete handler

```ts
// POST /v1/orders - creates an order, idempotent, structured errors, validated input
import { z } from "zod";

const CreateOrderSchema = z.object({
  customer_id: z.string().min(1),
  items: z.array(z.object({
    sku: z.string(),
    quantity: z.number().int().positive(),
  })).min(1),
  currency: z.enum(["USD", "EUR", "KZT"]),
});

export async function POST(req: Request) {
  const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();

  // 1. Parse
  let body: unknown;
  try { body = await req.json(); }
  catch {
    return errorResponse("invalid_json", "Request body is not valid JSON.", 400, requestId);
  }

  // 2. Validate
  const parsed = CreateOrderSchema.safeParse(body);
  if (!parsed.success) {
    return errorResponse(
      "validation_failed",
      "Validation failed for one or more fields.",
      400,
      requestId,
      { fields: parsed.error.issues.map(i => ({ path: i.path.join("."), code: i.code, message: i.message })) },
    );
  }
  const input = parsed.data;

  // 3. Auth
  const session = await getSession(req);
  if (!session) return errorResponse("unauthorized", "Authentication required.", 401, requestId);
  if (session.tenant_id !== input.customer_id_tenant) {
    return errorResponse("forbidden", "Cannot create order for another tenant.", 403, requestId);
  }

  // 4. Idempotency
  return withIdempotency(req, async () => {
    try {
      const order = await db.orders.create(input);
      return Response.json(
        { id: order.id, status: order.status, total_cents: order.total_cents },
        { status: 201, headers: { Location: `/v1/orders/${order.id}` } },
      );
    } catch (e) {
      if (e instanceof DuplicateOrderError) {
        return errorResponse("order_duplicate", "Order with this external_id already exists.", 409, requestId);
      }
      logger.error({ err: e, requestId }, "create order failed");
      return errorResponse("internal_error", "Could not create order.", 500, requestId);
    }
  });
}
```

This single handler demonstrates: schema-validated input, canonical error shape, correct status codes per branch, idempotency, request-id correlation, no leaked internals.

## Workflow

1. **List every endpoint in a table first.** Method, path, what it does, what it returns. Spot inconsistencies before writing code.
2. **Write one canonical `errorResponse` and reuse it.** Do not let each endpoint invent its own.
3. **Decide auth, versioning, and pagination strategy once.** These three are hardest to change later.
4. **Write client examples for the three most common flows.** If a flow needs three round-trips, redesign.
5. **Document with OpenAPI / a generated spec.** Schema lib + spec generator means one source of truth.

## Verification

```bash
bash skills/backend/forge-api-design/verify/check_error_shape.sh path/to/handler.ts
bash skills/backend/forge-api-design/verify/check_pagination.sh path/to/handler.ts
```

Non-zero on: non-object error field, offset pagination.

## When to skip this skill

- Internal RPC between trusted services where ergonomics beat strictness.
- One-off webhooks or callbacks with no client SDK.
- Throwaway prototypes that will not ship.

## Related skills

- [`forge-validation`](../forge-validation/SKILL.md) - schema-first input validation at boundaries.
- [`forge-error-handling`](../forge-error-handling/SKILL.md) - where to catch, what to log, what to surface.
- [`forge-auth`](../forge-auth/SKILL.md) - auth flows and session discipline.
- [`forge-logging`](../forge-logging/SKILL.md) - structured logging with request_id correlation.
