Zuplo API v4
Contract and Lifecycle

Writing the OpenAPI Spec

The OpenAPI document is the v4 contract. The portal, the CLI, the MCP server, SDKs, Terraform and these docs are all generated from it, so its quality is the API's quality.

Where things live

Edit this
spec/openapi.yaml

The contract: plain OpenAPI 3.1, written and reviewed by people.

Generated: never edit
config/routes.oas.json

Generated by npm run build:routes, which adds the Zuplo route configuration (x-zuplo-route) to every operation. Zuplo serves it and Zudoku documents it. CI fails if it is stale.

Gateway policies
config/policies.json

Today: request validation against the contract.

The rules as code
lint/

lint/ruleset.yaml and lint/rules/: the rules on these pages, as code.

V4-OAS-5

config/routes.oas.json is generated from spec/openapi.yaml, and every operation is a Zuplo route.

Lintv4-zuplo-routeShared rule: SPEC-1

v4 is design-first. The contract is written and reviewed before services implement it, and services are tested against it. That is a deliberate difference from SPEC-1 ("specs are generated from code"), which suits an existing fleet.

Document rules

V4-OAS-4

OpenAPI 3.1, one server (https://api.zuplo.com), the conventions and rate limits explained in info.description, and the shared components present.

Lintv4-document
Do
OpenAPI 3.1 nullable values
YAMLCode
description: type: [string, "null"]
Don't
OpenAPI 3.0 style
YAMLCode
description: type: string nullable: true

The version is part of every path, so the server URL has none. The shared building blocks exist once, in components, and are referenced everywhere:

Shared components
schemas

Problem

responses

The error responses: BadRequest, Unauthorized, Forbidden, NotFound, Conflict, TooManyRequests, ServerError

parameters

Limit, Offset

headers

Location, Retry-After, WWW-Authenticate

Operations

V4-OAS-1

A summary of at most 60 characters, a real description, and exactly one declared tag. Every declared tag is in exactly one x-tagGroups group.

Lintv4-operation-docsv4-document
Do
Short summary, full description
YAMLCode
summary: Create an app description: | Creates an app in the pool given by `poolId`. Without `policies`, it starts with a copy of the pool's policy template. Creating an app does not issue a key. tags: [Apps]
Don't
Summary and description swapped
YAMLCode
summary: This endpoint creates a new app. description: Creates an app. tags: [apps, ai-gateway]
summary

A short imperative phrase, at most 60 characters, no trailing period: "Create an app", "List a pool's apps".

description

At least a full sentence. Say what the operation does, its side effects, when changes take effect, and which errors a client should expect for domain reasons ("Fails with 409 while the pool still has apps"). Agents read this text to choose a tool, so write it for them as much as for people.

tags

Exactly one per operation, declared in the top-level tags with a description of the resource. Tags are Title Case plural nouns ("Apps") and become the sections of the API reference.

x-tagGroups

Nests the tags in the sidebar (the "AI Gateway" group holds the AI Gateway resources). Every declared tag is in exactly one group, and a group named like its only tag shows as that tag. A group named like one of several tags lists that tag's operations above the others. For a section named like its group, name the tag differently and show the group's name with x-displayName: the "API Keys" group's keys are the tag Keys, shown as "API Keys".

requestBody

Has a description. Create operations should have at least one realistic request example.

Operation IDs

V4-OAS-2

operationIds are derived mechanically from the method and the URL shape.

Lintv4-operation-idShared rule: NAME-5

operationIds become SDK method names and MCP tool names, so there is nothing to bikeshed:

Method and URL
GET/v4/buckets/{bucketId}/ai/pools/{poolId}/members
operationId
list-ai-pool-members
Shape and methodoperationIdExample
GET collectionlist-{collection}list-ai-pools, list-consumer-api-keys
POST collectioncreate-{resource}create-ai-pool, create-ai-user-consumer
GET item or singletonget-{resource}get-ai-pool, get-ai-user-app-usage
PATCHupdate-{resource}update-ai-pool
POST association collection, PUTupsert-{resource}upsert-ai-pool-member, upsert-ai-user-budget
DELETEdelete-{resource}delete-api-key

Words between the verb and the resource say whose it is, for an association or a credential under its owner (list-ai-pool-members, upsert-ai-app-consumer, list-ai-user-consumers). SDK methods and MCP tools have no namespaces, so a path in a namespace names it right after the verb: /v4/buckets/{bucketId}/ai/pools/{poolId} is get-ai-pool, never get-pool.

Bodies and schemas

V4-OAS-3

Request bodies only on POST, PUT and PATCH, named {Verb}{Resource}Request, where the verb is Create, Update or Upsert (PUT, and the POST that upserts an association). Request and success bodies are always application/json and always reference a named schema in components.schemas.

Lintv4-request-bodiesv4-success-responses
Do
Reference a named schema
YAMLCode
requestBody: description: The new app. content: application/json: schema: $ref: "#/components/schemas/CreateAiAppRequest"
Don't
An inline schema
YAMLCode
requestBody: content: application/json: schema: type: object properties: …

Names are what SDKs and tools show.

Create
CreateAiAppRequest
Create{Resource}Request
Update
UpdateAiAppRequest
Update{Resource}Request
Upsert
UpsertAiPoolMemberRequest
Upsert{Resource}Request
Nested request object
BudgetRuleInput
…Input
Resource
AiApp
{Resource}
List response
AiAppList
{Resource}List
Secret-revealing response
ApiKeyWithSecret
{Resource}WithSecret

Every schema, property and parameter has a description. Use examples on properties (IDs, names, model references) so the generated docs and tools show realistic values.

The lint gate

npm run lint runs three checks, and all three must pass:

1
check:routes

config/routes.oas.json matches spec/openapi.yaml.

2
lint:api

vacuum with lint/ruleset.yaml, failing on any finding, warnings included.

3
test:lint

Every rule is tested against the real spec (must pass) and against deliberately broken copies (must fail with the expected message).

Do
Fix rule, test and page

If a rule blocks something that is right, the rule is wrong. Change all three in the same pull request.

Don't
Ignore, disable or weaken a rule

There are no ignore files. A spec with a single finding does not merge.

Adding an endpoint

  1. Pick the owner and the URL shape (URLs). New collection names go into the table on that page, and new singletons into lint/lib.js.
  2. Add the path to spec/openapi.yaml, referencing the shared parameters, headers and error responses.
  3. Add the schemas: {Resource}, {Resource}List and the request schemas.
  4. Run the gate: npm run build:routes && npm run lint.
  5. Check the result in the docs (npm run docs), the same way a customer would see it.
Lint
Fails CI on any finding
v4-zuplo-routev4-documentv4-operation-docsv4-operation-idv4-request-bodies

Every operation is a Zuplo route (CI also checks that the routes file is current); the document rules and x-tagGroups; operation docs and tags; operationIds; named request bodies.

Review
Checked by people in the pull request

That descriptions are written for agents as much as for people, and that create operations have a realistic request example.

Every rule ID and what enforces it: the rule index
Last modified on