An API Is Not a URL; It Is a System Contract
The first four articles in Programmer Craft in the AI Era covered programs, data structures, execution models, and module communication. From this article on, we enter the section about turning features into services that can handle real traffic. The first topic is APIs.
AI writes APIs quickly. Give it “implement a user query endpoint”, and it can immediately generate a route, parameters, ORM query, and JSON response. The problem is that the most important part of an API is not the URL, nor the few lines in the handler. It is the promise the system makes to callers: what fields look like, how errors work, whether retry is safe, how permissions are checked, how versions evolve, and whether old clients break when new fields appear.
An API is not a URL. It is a contract. If the contract is unclear, faster code generation only makes the later cleanup harder.
The URL Is Only the Entrance
Many API design discussions stop at this level:
GET /api/users/{id}
POST /api/orders
GET /api/orders?page=1&page_size=20
These choices matter, but they are only the entrance. The real contract includes at least:
- Request parameters and field types.
- Whether fields are required, and what defaults exist.
- Whether the response structure is stable.
- Error codes and error semantics.
- Permission requirements.
- Idempotency rules.
- Pagination and sorting rules.
- Rate-limit and timeout boundaries.
- Version compatibility strategy.
- Example parameters and responses.
- Observability fields such as
request_idandtrace_id.
The most common problem in AI-generated APIs is that they “look callable”. Being callable does not mean being usable, and it certainly does not mean being maintainable.
For example, a tool-call history query API written as:
GET /tool-history?user_id=xxx
leaves many key questions unanswered:
- Can users only query their own history, or can administrators query everyone?
- Is there a maximum time range?
- What is the default sorting order?
- Do returned fields include sensitive parameters?
- Is pagination
page/page_sizeor cursor-based? - Is
tool_idsearch exact or fuzzy? - With no results, should it return an empty list or 404?
- What error is returned when the query is too slow?
If these are unclear, frontend, backend, testing, and data analysis will each guess differently.
Write the Schema Before the Handler
The most basic part of an API contract is the schema. A schema is not documentation decoration. It should constrain code.
For order creation, a casual request may look like this:
{
"user_id": "u_123",
"items": [
{"sku": "book_001", "count": 2}
],
"coupon": "NEWUSER"
}
But the contract must be clearer:
{
"user_id": "string, required",
"items": "array, required, 1..100",
"items[].sku": "string, required, max 64",
"items[].quantity": "integer, required, 1..999",
"coupon_code": "string, optional, max 64",
"client_order_id": "string, required, max 128"
}
Several small changes are important.
count becomes quantity, which is clearer. items has a length limit so one request cannot send tens of thousands of rows. quantity has a range, avoiding zero, negative, or absurd values. client_order_id supports idempotency and prevents client retries from creating duplicate orders.
In Python, the schema can be written directly as a model:
from pydantic import BaseModel, Field
class OrderItemIn(BaseModel):
sku: str = Field(min_length=1, max_length=64)
quantity: int = Field(ge=1, le=999)
class CreateOrderRequest(BaseModel):
user_id: str = Field(min_length=1, max_length=64)
items: list[OrderItemIn] = Field(min_length=1, max_length=100)
coupon_code: str | None = Field(default=None, max_length=64)
client_order_id: str = Field(min_length=1, max_length=128)
This code is not hard. AI can write it. The point is that your prompt should require the contract first:
Do not write the handler yet.
First define request and response schemas. Explain each field's type, requiredness,
default value, length range, enum range, and business meaning.
Write the implementation only after the schema is confirmed.
An API without a schema is a system boundary without types. Small projects can rely on memory. Large systems need constraints.
Response Structures Must Be Stable
API responses suffer when a field is added casually today and a structure is changed tomorrow.
A relatively stable list response usually looks like this:
{
"items": [
{
"id": "order_123",
"status": "paid",
"total_amount": 12900,
"created_at": "2026-07-03T10:00:00+08:00"
}
],
"page_info": {
"next_cursor": "eyJpZCI6...",
"has_more": true
},
"request_id": "req_abc"
}
Several details are worth preserving:
- Lists always live in
items; do not call themdatasometimes andlistother times. - Pagination information lives in
page_info, not inside each item. - Money uses the smallest currency unit as an integer to avoid floating-point errors.
- Time uses ISO 8601 strings with explicit timezone.
request_idis returned to callers for troubleshooting.
AI often directly returns an ORM object from handler internals:
return order
This is dangerous. ORM objects may contain internal fields, sensitive fields, debug fields, and silent API changes when the model changes. Response models should be defined separately:
class OrderOut(BaseModel):
id: str
status: str
total_amount: int
created_at: datetime
class ListOrdersResponse(BaseModel):
items: list[OrderOut]
page_info: PageInfo
request_id: str
When asking AI to write an API, be direct:
Do not return ORM objects directly.
Define independent response schemas.
Internal fields, secrets, cost fields, and debug fields must not appear in responses.
Specific constraints are easier for AI to follow.
Error Codes Matter More Than Successful Responses
The success path is usually simple. Failure is where API design gets hard.
Many AI-generated APIs handle errors like this:
raise HTTPException(status_code=400, detail="invalid request")
Or worse:
return {"success": False, "message": "error"}
When callers receive this kind of error, they cannot decide what to do next. Errors need a stable structure:
{
"error": {
"code": "ORDER_ITEM_OUT_OF_STOCK",
"message": "Some items are out of stock",
"retryable": false,
"details": {
"sku": "book_001"
}
},
"request_id": "req_abc"
}
An error should answer:
- What is the machine-readable error code?
- What is the human-readable message?
- Can the caller retry?
- Is it a parameter error, permission error, missing resource, state conflict, or system error?
- Is there a
request_idfor debugging? - Does
detailscontain necessary context?
HTTP status codes and business error codes should work together:
| HTTP status | Scenario | Example business code |
|---|---|---|
| 400 | Bad request format or invalid field | INVALID_ARGUMENT |
| 401 | Not logged in or invalid token | UNAUTHENTICATED |
| 403 | Logged in but no permission | PERMISSION_DENIED |
| 404 | Resource missing or invisible | RESOURCE_NOT_FOUND |
| 409 | State conflict or duplicate operation | ORDER_ALREADY_PAID |
| 422 | Semantic validation failure | ORDER_ITEM_OUT_OF_STOCK |
| 429 | Rate or quota exceeded | RATE_LIMITED |
| 500 | Internal service error | INTERNAL_ERROR |
| 503 | Downstream unavailable or temporary overload | SERVICE_UNAVAILABLE |
With error codes, clients can write stable logic. RATE_LIMITED can show “try again later”; ORDER_ALREADY_PAID can refresh order status; ORDER_ITEM_OUT_OF_STOCK can ask the user to update the cart.
When asking AI to write error handling, require:
Define a unified error response structure.
For each error code, explain:
- HTTP status
- code
- whether it is retryable
- what the caller should do
- whether a security audit log is required
Error contracts reveal API maturity more than success contracts.
Idempotency Is Not Only for Payment APIs
Idempotency means that executing the same operation multiple times has the same final effect as executing it once.
Many people only think of idempotency for payment APIs. In reality, whenever clients retry, gateways retry, networks time out, or queues redeliver messages, idempotency matters.
Creating orders, submitting forms, triggering tasks, sending verification codes, executing tools, and importing files may all need idempotency.
A typical creation API can ask the client to send Idempotency-Key:
POST /api/orders
Idempotency-Key: create-order:user_123:client_order_789
The server records processing results for that key:
create table idempotency_keys (
key text primary key,
request_hash text not null,
status text not null,
response_body jsonb,
created_at timestamptz not null,
expires_at timestamptz not null
);
Two points are critical.
The same key with the same request hash can return the first result. The same key with a different request hash should be rejected, because the client reused an idempotency key with different content.
Pseudocode:
def create_order(request: CreateOrderRequest, idempotency_key: str):
request_hash = hash_request(request)
existing = idempotency_store.get(idempotency_key)
if existing:
if existing.request_hash != request_hash:
raise Conflict("IDEMPOTENCY_KEY_REUSED")
return existing.response_body
with transaction():
idempotency_store.reserve(idempotency_key, request_hash)
order = order_service.create(request)
response = to_response(order)
idempotency_store.mark_done(idempotency_key, response)
return response
This logic still needs details around concurrent claims of the same key, timeout while processing, and result retention time, but the direction is right.
When asking AI to implement creation APIs, write:
This API must support idempotency.
The client passes the idempotency key through Idempotency-Key.
Same key + same request body returns the first result.
Same key + different request body returns 409.
Write a concurrency test: two requests with the same key arrive at the same time,
and only one business record may be created.
Without these sentences, AI will likely write an ordinary insert.
Pagination Is Not Just page_size
List APIs are common and easy to get wrong.
The simplest pagination is:
GET /orders?page=10&page_size=20
It fits small data and admin pages where ordering stability is not critical. But on high-frequency APIs or large tables, offset pagination gets slower and can skip or duplicate data.
Suppose the list is sorted by creation time descending. The user reads the first page, then a new order is inserted, then the user reads the second page. A row that was at the end of the first page may be pushed into the second page and appear twice. Or some rows may be skipped.
Cursor pagination is more stable:
GET /orders?limit=20&cursor=eyJjcmVhdGVkX2F0Ijoi...IiwiaWQiOiI...In0=
The cursor usually contains the sorting field and a unique ID:
{
"created_at": "2026-07-03T10:00:00+08:00",
"id": "order_123"
}
The query condition is similar to:
select *
from orders
where user_id = :user_id
and (
created_at < :cursor_created_at
or (created_at = :cursor_created_at and id < :cursor_id)
)
order by created_at desc, id desc
limit :limit;
Here created_at, id together guarantee stable ordering. The corresponding index must exist:
create index idx_orders_user_created_id
on orders (user_id, created_at desc, id desc);
When asking AI to write a list API, be explicit:
The list API uses cursor pagination, not offset.
Sort by created_at desc, id desc, and the order must be stable.
limit defaults to 20 and maxes out at 100.
The response returns next_cursor and has_more.
Provide the recommended index.
Pagination is an API contract and a database access contract. page_size alone is not enough.
Permissions Must Be Bound to Data Filtering
Permission bugs are common, especially in AI-generated CRUD APIs.
A dangerous implementation:
def get_order(order_id: str, current_user: User):
order = order_repository.get(order_id)
if not order:
raise NotFound()
return order
This does not check whether the order belongs to the current user. A better approach binds permission scope in the query:
def get_order(order_id: str, current_user: User):
order = order_repository.get_visible_order(
order_id=order_id,
user_id=current_user.id,
)
if not order:
raise NotFound()
return order
Why return 404 instead of 403 when access is not allowed? It depends on the business. Many user-resource APIs use 404 to avoid revealing whether a resource exists. Admin APIs may return 403 and record audit logs.
The permission contract must be clear:
- Who can call this API?
- Can users only see their own data, or can they see data by organization, role, or project scope?
- Can administrators query across users?
- Does unauthorized access return 403 or 404?
- Is an audit log required?
- Are response fields masked by permission?
Field-level permission also matters. The same API may return different fields to ordinary users and administrators:
{
"id": "tool_call_123",
"tool_id": "fin_cap_001",
"status": "success",
"created_at": "2026-07-03T10:00:00+08:00"
}
Administrators may also need cost, vendor, and stack trace fields; ordinary users should not see these internals.
When asking AI to write an API, require:
Permissions must be reflected in query conditions. Do not query everything and filter in memory.
Ordinary users can only access their own resources; administrators can query by user_id.
Unauthorized access to ordinary user resources returns 404.
Administrator queries must be recorded in audit logs.
Response fields are filtered by role; ordinary users must not see internal cost or vendor error details.
These constraints directly affect repository, service, and response schema design.
Version Compatibility Starts on Day One
Once an API has users, it becomes a public promise. Even internal APIs need version compatibility.
Common compatibility mistakes include:
- Deleting fields.
- Changing field types.
- Changing enum meanings.
- Turning an optional field into a required one.
- Changing error codes.
- Changing pagination order.
- Changing money units.
- Returning
nullinstead of an empty list.
Any of these can break callers.
Compatible evolution usually follows a few rules:
- Adding fields is generally safe, and callers should ignore unknown fields.
- Adding enum values is not always safe; old clients may not recognize them.
- Deprecate a field first, then observe callers.
- Keep old fields during a migration window.
- Use a new field for semantic changes instead of reusing an old field.
- For incompatible major changes, separate them with a version path or header.
For example, an API originally returns:
{
"status": "running"
}
Later we add timeout. If old clients only know pending/running/success/failed, the new enum may display incorrectly. A more stable response also returns a display state:
{
"status": "timeout",
"display_status": "failed",
"can_retry": true
}
Or the contract can require clients to handle unknown states as unknown.
When asking AI to modify an API, do not merely say “add a field”. Write:
This is a live API and must remain backward compatible.
Do not delete fields or change existing field types.
Before adding enum values, explain how old clients handle them.
New fields must be optional.
List compatibility risks and migration steps.
AI can write new features quickly, and it can break compatibility quickly. Version rules must be in the instruction early.
Example Parameters Are Not Decoration
Many API docs have field tables but poor examples. Without examples, AI-generated clients, test cases, and telemetry scripts easily guess wrong.
A good example should cover a realistic scenario, not only the shortest happy path.
For a search API, a weak example might be:
{
"query": "test"
}
This is almost useless. A better example is closer to a real user:
{
"query": "Query Kweichow Moutai's dividends and dividend yield over the past year",
"language": "en",
"filters": {
"provider_type": "finance",
"market": "cn"
},
"limit": 10
}
It helps callers understand the API’s purpose and helps tests cover real paths.
Examples should also include errors:
{
"query": "",
"limit": 1000
}
Expected error:
{
"error": {
"code": "INVALID_ARGUMENT",
"message": "query must not be empty, and limit must not exceed 100",
"retryable": false
},
"request_id": "req_abc"
}
When asking AI to generate API documentation, require:
For every API, provide at least:
- One realistic success example.
- One boundary success example.
- One parameter error example.
- One permission error example.
- One retryable system error example.
Examples must use real business semantics, not foo/bar/test.
The more realistic examples are, the less likely the API is to drift in the wrong direction.
Contract Tests Pin the Promise Down
An API contract ultimately needs tests to hold it in place.
Ordinary unit tests check implementation details. Contract tests check external promises. They do not care how the database is queried internally; they care whether inputs and outputs match the protocol.
A contract test can look like this:
def test_create_order_validation(client):
response = client.post("/api/orders", json={
"user_id": "u_123",
"items": [],
"client_order_id": "c_001",
})
assert response.status_code == 400
body = response.json()
assert body["error"]["code"] == "INVALID_ARGUMENT"
assert body["error"]["retryable"] is False
assert "request_id" in body
Idempotency tests are more important:
def test_create_order_idempotency(client):
payload = {
"user_id": "u_123",
"items": [{"sku": "book_001", "quantity": 1}],
"client_order_id": "c_001",
}
headers = {"Idempotency-Key": "create-order:u_123:c_001"}
first = client.post("/api/orders", json=payload, headers=headers)
second = client.post("/api/orders", json=payload, headers=headers)
assert first.status_code == 200
assert second.status_code == 200
assert first.json()["id"] == second.json()["id"]
Pagination tests are also necessary:
def test_list_orders_cursor_is_stable(client, seed_orders):
first = client.get("/api/orders?limit=20")
next_cursor = first.json()["page_info"]["next_cursor"]
create_new_order()
second = client.get(f"/api/orders?limit=20&cursor={next_cursor}")
first_ids = {item["id"] for item in first.json()["items"]}
second_ids = {item["id"] for item in second.json()["items"]}
assert first_ids.isdisjoint(second_ids)
These tests are not for prettier coverage numbers. They prevent later AI-generated or human refactors from breaking the contract.
An API-contract Prompt for AI
When asking AI to write an API, start with this prompt:
Do not write the API implementation yet.
Design the contract for this API:
1. URL, HTTP method, authentication method, and permission scope.
2. Request schema: field type, requiredness, default value, length range, enum, business meaning.
3. Response schema: success structure, field masking, money and time formats.
4. Error structure: HTTP status, business code, retryable, caller handling advice.
5. Idempotency rules: which operations need Idempotency-Key, and how repeated requests are handled.
6. Pagination and sorting: whether cursor is used, whether sorting fields are stable, limit upper bound.
7. Version compatibility: new fields, deprecated fields, new enums, old-client strategy.
8. Observability: request_id, trace_id, audit logs, key metrics.
9. Examples: realistic success example, boundary example, parameter error, permission error, retryable error.
10. Contract tests: at least 5 test cases.
After the contract is confirmed, write handler, service, repository, and tests.
This is more effort than “write an API for me”, but it saves a large amount of rework later.
Ask These Questions Before Writing an API
After an API goes live, callers treat it as a promise. Before writing code, ask:
- Can this field be renamed later?
- Can this enum be extended later?
- Can callers automatically handle this error?
- Can this creation API be safely retried?
- Can this list pagination skip or duplicate data?
- Can this resource be accessed without permission?
- Does this response leak internal fields?
- What happens when old clients see new fields, new enums, or new error codes?
- When something goes wrong, can we find the full path by
request_id?
AI can quickly write APIs that run. The programmer’s job is to push “runs” into “can be relied on by others for a long time”.
That is the value of an API contract. It is not documentation obsession. It is the most basic trust between systems.
Follow ZiCode on WeChat
If this post was useful, you can follow later updates on WeChat as well.
X / Twitter
Follow @ax2_zicode
Faster technical notes, short thoughts, and new-post alerts are posted on X.
More in this column
- AI Can Write Code, But You Still Need to Know What a Program Is
- Data Structures Are Not Interview Tricks; They Are the Skeleton of a System
- Do Not Let AI Guess Your Execution Model
- How Modules Talk to Each Other
- A Database Is Not a Place to Dump JSON
- Do Not Put Slow Work Inside the Request
- High Concurrency Is Not Just Adding More Machines
- Tests Are the Seatbelt for AI Coding