An API Is Not a URL; It Is a System Contract
· 60 min read · Views --

An API Is Not a URL; It Is a System Contract

Author: Alex Xiang


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_id and trace_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_size or cursor-based?
  • Is tool_id search 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 them data sometimes and list other 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_id is 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_id for debugging?
  • Does details contain necessary context?

HTTP status codes and business error codes should work together:

HTTP statusScenarioExample business code
400Bad request format or invalid fieldINVALID_ARGUMENT
401Not logged in or invalid tokenUNAUTHENTICATED
403Logged in but no permissionPERMISSION_DENIED
404Resource missing or invisibleRESOURCE_NOT_FOUND
409State conflict or duplicate operationORDER_ALREADY_PAID
422Semantic validation failureORDER_ITEM_OUT_OF_STOCK
429Rate or quota exceededRATE_LIMITED
500Internal service errorINTERNAL_ERROR
503Downstream unavailable or temporary overloadSERVICE_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 null instead 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.