# v4 API Design Guidelines

<Lead>

These are the rules every endpoint of the Zuplo Developer API v4
(`https://api.zuplo.com/v4`) follows. They are written to be enforced. Every rule
that a machine can check is a lint rule that runs in CI, and a spec with a single
finding does not merge.

</Lead>

:::note{title="Status: draft for review"}

This is the first version of the v4 guidelines, written together with the first
v4 surface (the [AI Gateway](/ai-gateway/overview)). Everything here is open for
discussion. The [decisions page](/guidelines/decisions) lists what is decided,
what deliberately differs from today's guidelines, and the open questions.

:::

## <N n="1" />Why v4

Zuplo has grown several API surfaces, each with its own shape. The same concept
(an API key, a bucket, a "configuration") is reached through different URLs with
different payloads.

<DoDont>
<Dont label="Today" title="Several APIs, several shapes">

- `api.zuplo.com` for the portal and Agent Z
- `dev.zuplo.com` v1 to v3 for customers
- services that each picked their own URL scheme

</Dont>
<Do label="v4" title="One canonical API">

- one prefix, `/v4`, for every product
- one set of conventions, enforced by lint
- one OpenAPI document

</Do>
</DoDont>

<Tiles cols={3}>
<Tile label="Portal first" title="If the portal cannot be built on v4, v4 is wrong">

The portal is the first consumer. Building it on v4 also retires
`api.zuplo.com` over time.

</Tile>
<Tile label="One document" title="Every client from one spec">

The **Zuplo CLI**, the **MCP server** (an MCP tool for each operation) and
**generated SDKs and Terraform providers** all come from one OpenAPI document.

</Tile>
<Tile label="Human-first" title="Reads like the APIs people know">

It should read like Stripe or GitHub, because that is also what agents are
trained on.

</Tile>
</Tiles>

## <N n="2" />The rules in one minute

<Principles>
<Principle n="1" title="URLs are short" href="/guidelines/urls">

Every resource is listed, created and addressed in one collection, directly in
its container, and its owner is a filter:
<Path p="/v4/buckets/{bucketId}/ai/apps?poolId=…" /> then
<Path p="/v4/buckets/{bucketId}/ai/apps/{appId}" />

</Principle>
<Principle n="2" title="The bucket is the container" href="/guidelines/urls#containers-accounts-projects-and-buckets">

Gateway data lives in a **bucket**, and every path to it starts with
<Path p="/v4/buckets/{bucketId}" />. Accounts and projects are the other
containers: what a project deploys starts with <Path p="/v4/projects/{projectId}" />.

</Principle>
<Principle n="3" title="The method and URL shape tell you everything" href="/guidelines/methods">

Fixed methods per URL shape, and fixed status codes per method.

</Principle>
<Principle n="4" title="Updates and deletes are safe to retry" href="/guidelines/idempotency">

`PUT`, `PATCH`, `DELETE` and the `POST` that upserts an association are idempotent
by design. A `POST` that creates has no `Idempotency-Key` yet.

</Principle>
<Principle n="5" title="Every list paginates" href="/guidelines/collections">

Every list returns `{ data, limit, offset, total, hasMore, next }`.

</Principle>
<Principle n="6" title="Everything is typed" href="/guidelines/naming-and-data">

No free-form JSON, no `metadata` blobs, and separate request and response
schemas.

</Principle>
<Principle n="7" title="Names follow one convention" href="/guidelines/naming-and-data">

camelCase fields, kebab-case URLs and enums, timestamps ending in `On`, opaque
IDs exactly as the services mint them (people too: `user_…`), and `null` instead
of omitted fields.

</Principle>
<Principle n="8" title="Errors are problems" href="/guidelines/errors">

Errors are RFC 9457 problems, and every operation documents the errors it can
return.

</Principle>
<Principle n="9" title="One header for every caller" href="/guidelines/auth">

One `Authorization: Bearer` header for API keys and OAuth tokens. Authorization
is decided in the service.

</Principle>
</Principles>

## <N n="3" />How to read these pages

Every page follows the same pattern: the rule, an example to copy, and the
counter-example that fails.

<DoDont>
<Do title="An example to copy">

Green cards show what a v4 API does. Copy them.

</Do>
<Dont title="A counter-example">

Red cards show what lint or review rejects, usually something today's APIs do.

</Dont>
</DoDont>

<Tiles>
<Tile label="Rule boxes" title="The rule itself">

A box with a pink edge states a rule. Lint messages and reviews cite its ID, and
every ID is a link target: [V4-URL-2](/guidelines/urls#v4-url-2).

</Tile>
<Tile label="Enforcement" title="Lint or review">

**Lint** means a rule in `lint/ruleset.yaml` fails CI. **Review** means a person
checks it in the pull request. Every page ends with what enforces it.

</Tile>
</Tiles>

Paths on these pages are colored by the role of each segment:

<PathLegend />

## <N n="4" />How these guidelines relate to the shared Zuplo guidelines

Zuplo already has API design guidelines: the original
[Notion page](https://app.notion.com/p/zuplo/API-Design-Guidelines-c3fae70de7214e28a2051c1c39b3dac8),
refined into the decided rules (June 2026) in `zuplo/internal-tools`
(`plugins/zuplo-engineering/skills/api-design`) with IDs like `URL-4` and `COLL-2`.
Those rules are not wrong. They were written to steer an existing fleet, so they
allow grandfathered exceptions, "use judgment" cases, and several valid shapes.

**v4 does not add a competing standard.** It builds on the shared guidelines:

<Tiles>
<Tile tone="do" label="Applies" title="Most shared rules apply to v4">

These pages cite the shared rule IDs, and the v4 spec also passes the shared lint
ruleset with a perfect score. That ruleset checks the mechanical rules only. A
reviewer checks the rest, URL-4 for example.

</Tile>
<Tile tone="warn" label="Departs" title="v4 contradicts seven shared rules">

One `/v4` for everything (VER-1), URLs that keep their container (URL-4),
account-rooted URLs (URL-4a), IDs as the services mint them, not always prefixed
(DATA-3), a hand-written spec (SPEC-1), and no `Idempotency-Key` (ROB-1) or
`ETag` (ROB-2).

</Tile>
<Tile tone="info" label="Narrows" title="v4 removes the choices">

Where the shared rules offer options or say SHOULD, v4 picks one and makes it a
MUST: one URL scheme and pagination on every list.

</Tile>
<Tile tone="info" label="Adds" title="v4 adds rules where the shared ones are silent">

Those carry `V4-*` IDs: containers and re-rooting inside them, typed schemas only,
separate request and response schemas, and operation IDs derived from the URL.

</Tile>
<Tile tone="dont" label="Never" title="Nothing is grandfathered">

v4 starts clean, so there are no ignore files and no exceptions list. If a rule
is wrong, change the rule.

</Tile>
</Tiles>

When v4 settles, its additions can move into the shared ruleset so other APIs
benefit too. Every place where v4 deliberately chooses differently is listed in
[Decisions and Open Questions](/guidelines/decisions#where-v4-differs-from-the-shared-guidelines).

## <N n="5" />How the rules are enforced

<Flow>
<FlowStep label="The contract" code="spec/openapi.yaml">

OpenAPI 3.1, hand-written, reviewed in pull requests.

</FlowStep>
<FlowStep label="The ruleset" code="lint/ruleset.yaml">

[vacuum](https://quobix.com/vacuum/) plus 20 custom rules in `lint/rules/`.

</FlowStep>
<FlowStep label="The proof" code="lint/tests/rules.test.mjs">

Each rule is shown a spec that breaks it.

</FlowStep>
<FlowStep label="The gate" code="npm run lint">

Routes up to date, zero findings at any severity, and every rule test passes.

</FlowStep>
</Flow>

The [rule index](/guidelines/rule-index) maps every rule ID to the lint rule that
enforces it, or marks it "review" when it needs human judgment.
