Practical API Design with Claude Code: OpenAPI, Tests, and Breaking Change Checks
Design reliable REST APIs with Claude Code: OpenAPI workflow, mock server, tests, versioning, security, and pitfalls.
API design is not just naming attractive URLs. It is the agreement between your team and every client about what can be sent, what comes back, and what happens when something fails.
When that agreement is vague, frontend code, mobile apps, partner integrations, tests, and monitoring all invent their own interpretation. The later you repair the contract, the more expensive the repair becomes.
Claude Code is useful here, but only if you use it as a design reviewer, not just as a code generator. Ask it to draft an OpenAPI contract, challenge the endpoint model, generate mocks and tests, and check whether a change breaks existing clients.
Use primary references while working: the OpenAPI Specification, RFC 9110 HTTP Semantics, JSON Schema docs, and the OWASP API Security Top 10. For implementation depth, continue with Production API Development, API Test Automation, and API Versioning.
What API Design Means
An API is a screen for another program. A human-facing screen can explain itself with labels and layout. An API explains itself through paths, HTTP methods, status codes, JSON field names, schemas, examples, and error payloads.
For beginners, API design means deciding five things before implementation goes too far.
| Decision | Plain meaning | Example |
|---|---|---|
| Resource | The noun the API exposes | orders, customers, invoices |
| Operation | What happens to that noun | GET, POST, PATCH, DELETE |
| Schema | The shape and rules of JSON | items must contain at least one item |
| Error | How failures are reported | 400, 401, 403, 404, 422 with useful details |
| Compatibility | How you avoid breaking clients | Adding a required request field is breaking |
REST can sound abstract, but the practical habit is simple: use noun-based URLs and let HTTP methods express the action. Prefer POST /orders over POST /orders/create, and prefer GET /orders/ord_123 over GET /getOrder?id=ord_123.
The Claude Code Workflow
The best workflow is staged. Do not ask Claude Code to design, implement, test, and document everything in one breath. Keep each step reviewable.
flowchart TD
A["Summarize business rules"] --> B["Draft the OpenAPI contract"]
B --> C["Review HTTP, schema, and security risks"]
C --> D["Generate mocks and API tests"]
D --> E["Check breaking changes in CI"]
E --> F["Implement, document, and sell the API confidently"]
OpenAPI is a machine-readable contract for HTTP APIs. JSON Schema is the vocabulary for describing JSON shapes and constraints. HTTP status codes are shared semantics for success and failure. Claude Code can help connect these pieces, but the official specs and your tests remain the source of truth.
In Masa’s small verification project, asking for endpoint names first produced a decent list. The problem appeared later: authentication, pagination, idempotency, and error detail had to be patched in after the fact. A better prompt asks for recovery behavior and client failure modes at the same time as the URL model.
Copy-Paste Starter Kit
This starter kit avoids external dependencies so you can focus on API design. It gives you an OpenAPI sample, a mock server, and a tiny breaking-change check.
mkdir api-design-lab
cd api-design-lab
mkdir docs examples
node --version
Create docs/openapi.yaml. The official OpenAPI page now points to a newer published spec, but this example uses 3.1 because common tooling still supports it well.
openapi: 3.1.0
info:
title: Orders API
version: 1.0.0
servers:
- url: https://api.example.com
paths:
/v1/orders:
post:
summary: Create an order
operationId: createOrder
security:
- bearerAuth: []
parameters:
- name: Idempotency-Key
in: header
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateOrderRequest"
responses:
"201":
description: Created
content:
application/json:
schema:
$ref: "#/components/schemas/Order"
"422":
description: Validation error
content:
application/json:
schema:
$ref: "#/components/schemas/Problem"
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
schemas:
CreateOrderRequest:
type: object
required: [customerId, items]
properties:
customerId:
type: string
minLength: 3
items:
type: array
minItems: 1
items:
type: object
required: [sku, quantity]
properties:
sku:
type: string
minLength: 3
quantity:
type: integer
minimum: 1
Order:
type: object
required: [id, status, customerId, total]
properties:
id:
type: string
status:
type: string
enum: [accepted, cancelled]
customerId:
type: string
total:
type: integer
Problem:
type: object
required: [type, title, status, detail]
properties:
type:
type: string
title:
type: string
status:
type: integer
detail:
type: string
errors:
type: array
items:
type: object
Create examples/mock-server.mjs. It runs with Node only.
import { createServer } from "node:http";
import { randomUUID } from "node:crypto";
function readJson(req) {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (chunk) => {
body += chunk;
if (body.length > 1_000_000) req.destroy(new Error("Body too large"));
});
req.on("end", () => {
if (!body) return resolve({});
try {
resolve(JSON.parse(body));
} catch (error) {
reject(error);
}
});
req.on("error", reject);
});
}
function send(res, status, body, headers = {}) {
res.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"x-content-type-options": "nosniff",
...headers,
});
res.end(JSON.stringify(body, null, 2));
}
function problem(status, title, detail, errors = []) {
return {
type: "https://example.com/problems/request",
title,
status,
detail,
errors,
};
}
function validateOrder(input) {
const errors = [];
if (typeof input.customerId !== "string" || input.customerId.length < 3) {
errors.push({
path: "customerId",
message: "customerId must be a string with 3+ characters",
});
}
if (!Array.isArray(input.items) || input.items.length === 0) {
errors.push({ path: "items", message: "items must contain at least one item" });
}
for (const [index, item] of (input.items ?? []).entries()) {
if (typeof item.sku !== "string" || item.sku.length < 3) {
errors.push({
path: `items.${index}.sku`,
message: "sku must be a string with 3+ characters",
});
}
if (!Number.isInteger(item.quantity) || item.quantity < 1) {
errors.push({
path: `items.${index}.quantity`,
message: "quantity must be a positive integer",
});
}
}
return errors;
}
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://localhost");
if (req.method === "GET" && url.pathname === "/health") {
return send(res, 200, { ok: true });
}
const customerMatch = url.pathname.match(/^\/v1\/customers\/([a-z0-9-]+)$/);
if (req.method === "GET" && customerMatch) {
return send(res, 200, {
id: customerMatch[1],
name: "Aki Tanaka",
plan: "pro",
});
}
if (req.method === "POST" && url.pathname === "/v1/orders") {
const idempotencyKey = req.headers["idempotency-key"];
if (!idempotencyKey) {
return send(
res,
400,
problem(400, "Missing Idempotency-Key", "POST /v1/orders requires the header.")
);
}
try {
const body = await readJson(req);
const errors = validateOrder(body);
if (errors.length > 0) {
return send(res, 422, problem(422, "Invalid request body", "Fix errors.", errors));
}
return send(
res,
201,
{
id: `ord_${randomUUID()}`,
status: "accepted",
customerId: body.customerId,
total: 4200,
},
{ location: "/v1/orders/example" }
);
} catch {
return send(res, 400, problem(400, "Malformed JSON", "Request body must be JSON."));
}
}
return send(res, 404, problem(404, "Not found", `${req.method} ${url.pathname} is undefined.`));
});
server.listen(3000, () => {
console.log("Mock API running at http://localhost:3000");
});
Run it, then call it from another terminal.
node examples/mock-server.mjs
curl -i http://localhost:3000/health
curl -i -X POST http://localhost:3000/v1/orders \
-H "content-type: application/json" \
-H "idempotency-key: demo-001" \
-d '{"customerId":"cus_123","items":[{"sku":"book-1","quantity":2}]}'
curl -i -X POST http://localhost:3000/v1/orders \
-H "content-type: application/json" \
-H "idempotency-key: demo-002" \
-d '{"customerId":"x","items":[]}'
Create examples/contract-check.mjs. It intentionally fails so you can see the breaking changes.
import assert from "node:assert/strict";
const previous = {
paths: {
"/v1/orders": {
post: {
request: {
required: ["customerId", "items"],
properties: ["customerId", "items", "couponCode"],
},
response: {
required: ["id", "status", "customerId", "total"],
properties: ["id", "status", "customerId", "total"],
},
},
},
},
};
const next = structuredClone(previous);
next.paths["/v1/orders"].post.request.required.push("shippingAddress");
next.paths["/v1/orders"].post.response.properties =
next.paths["/v1/orders"].post.response.properties.filter((name) => name !== "total");
function diffContract(oldSpec, newSpec) {
const breaking = [];
for (const [path, methods] of Object.entries(oldSpec.paths)) {
for (const [method, oldOperation] of Object.entries(methods)) {
const newOperation = newSpec.paths[path]?.[method];
if (!newOperation) {
breaking.push(`${method.toUpperCase()} ${path} was removed`);
continue;
}
const oldRequired = new Set(oldOperation.request.required);
for (const field of newOperation.request.required) {
if (!oldRequired.has(field)) {
breaking.push(`${method.toUpperCase()} ${path} now requires "${field}"`);
}
}
const newResponseFields = new Set(newOperation.response.properties);
for (const field of oldOperation.response.properties) {
if (!newResponseFields.has(field)) {
breaking.push(`${method.toUpperCase()} ${path} removed response "${field}"`);
}
}
}
}
return breaking;
}
const breaking = diffContract(previous, next);
console.log(breaking.join("\n") || "No breaking changes found");
assert.equal(breaking.length, 0, "Breaking API changes detected");
node examples/contract-check.mjs
A failing result is the expected result here. The script catches a new required request field and a removed response field, which are exactly the kind of changes you want CI to block before release.
Prompts for Claude Code
Use separate prompts for drafting, reviewing, generating examples, and checking compatibility. That keeps the work inspectable.
claude -p "
Create an OpenAPI draft at docs/openapi.yaml for an e-commerce orders API.
Resources: customers, orders, invoices.
Include summary, operationId, requestBody, responses, examples, and bearerAuth.
Use OpenAPI 3.1 and JSON Schema style constraints.
"
claude -p "
Review docs/openapi.yaml as an API design reviewer.
Return findings first and do not edit files yet.
Check RFC 9110 method/status semantics, vague schemas, pagination,
idempotency, authentication, and common OWASP API Security risks.
"
claude -p "
Generate a Node.js mock server and API test examples from docs/openapi.yaml.
Cover success, auth failure, validation failure, and missing resource cases.
Keep long lines under 150 characters and add README commands.
"
claude -p "
Compare the current docs/openapi.yaml with HEAD.
List breaking changes before suggesting edits.
Check removed paths, new required fields, removed response fields,
changed status codes, and changed auth scopes.
"
This style treats Claude Code output as review material. That is the right posture for API contracts. Speed matters, but client compatibility matters more.
Realistic Use Cases
The first use case is a SaaS orders API. Admin screens, billing jobs, email notifications, and accounting exports may all consume the same Order model. If total, currency, tax handling, and cancellation states are vague, every downstream integration pays the price.
The second use case is a mobile profile API. Old app versions can remain in the wild for months. Removing a response field or changing an enum meaning can crash clients that cannot be upgraded immediately. Additive changes and deprecation windows matter.
The third use case is a B2B partner API. External developers do not know your internal conventions. They need error codes, rate limits, retry guidance, a sandbox, and stable examples. Without those, support tickets become the documentation.
The fourth use case is an internal admin API. Internal does not mean harmless. Object-level authorization still matters: a user should not fetch another tenant’s order just because they found an ID. This is one of the practical lessons behind OWASP API security guidance.
Pitfalls and Failure Cases
A common failure is stuffing verbs into paths: /cancelOrder, /getUserOrders, and /updateOrderStatus. This grows into overlapping endpoints with inconsistent names. Model resources first, then use methods and subresources where needed, such as POST /orders/{id}/cancellation.
Another failure is returning HTTP 200 for every business error. That makes monitoring, retries, SDK behavior, and client error handling harder. Use status codes to communicate meaning: 400 for malformed input, 401 for missing authentication, 403 for forbidden actions, 404 for unknown resources, and 422 for semantically invalid requests.
Forgetting retry safety on POST is also expensive. Order creation and payment initiation often get retried after a timeout. Design an idempotency key before launch so the server can recognize a repeated attempt.
Schema ambiguity causes slow bugs. Decide whether null means “clear this value” or “unknown.” Define required fields, minimum lengths, page limits, date formats, and additionalProperties behavior instead of relying on examples alone.
Finally, do not treat response cleanup as harmless. Removing a field can be a production incident for a client. Plan a deprecation period, migration notes, contract tests, and a clear versioning policy.
Versioning, Errors, Schema, and Security
Versioning is more than adding /v1 to the path. Teams need a shared rule for what counts as breaking. Adding an optional response field is often safe. Adding a required request field, removing a response field, changing status semantics, tightening auth scopes, or changing the meaning of an enum can break existing clients.
Error design should tell the caller what to do next. Invalid request is not enough. Include a stable shape with type, title, status, detail, and field-level errors where useful. Do not leak stack traces, SQL, internal IDs, or secrets in production responses.
Schema design should encode constraints, not just examples. A schema with only type: string is weak. Document ID formats, minimum lengths, array limits, pagination defaults, and timezone rules so clients can validate before sending.
Security design separates authentication from authorization. Authentication answers “who is this?” Authorization answers “may this actor access this object?” Bearer tokens do not protect you if GET /orders/{id} returns another tenant’s order. Avoid API keys in query strings, limit sensitive response data, rate limit risky operations, and keep audit logs for important actions.
Monetization and Consulting CTA
Readers searching for API design are often close to a real project. They may be preparing a public integration, stabilizing a mobile backend, or writing review rules for a team. This article should not just inform them; it should show that the workflow is concrete enough to hire.
ClaudeCodeLab can help with Claude Code API design reviews, OpenAPI cleanup, test automation, and breaking-change checks. Teams can start with training and consulting. Individual builders can use the free resources and adapt the prompts in this article.
The CTA works because the article includes working code, a failing contract check, official references, and implementation links. A reader can see the difference between generic AI advice and a practical engineering workflow.
What I Verified
For this article, I ran the mock server with Node v24.14.1. GET /health returned 200, a valid POST /v1/orders returned 201, and an empty items array returned 422. The contract check failed intentionally and printed the new required request field plus the removed response field. That gives readers a small but real path from Claude Code prompts to OpenAPI, mocks, errors, and CI-friendly compatibility checks.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.