Zuplo API v4
Designing Resources

URLs and Resources

A v4 URL is short and flat. It starts at its container, a bucket, a project or an account, then names a collection and an ID, never a whole ancestry, and it looks the same for every product. Once you have an ID, you start again at the container.

Today
Deep URLs, a scheme per service
Code
/v1/buckets/{bucketId}/configurations/{configurationId}/members/{sub} /v1/api-keys/{bucketId}/users/me/consumers/{consumerId}/roll-key /v1/accounts/{accountName}/projects/{projectName}/ai-provider-assignments/{assignmentId}
v4
The same operations
Code
/v4/buckets/{bucketId}/ai/pools/{poolId}/members/{userId} /v4/buckets/{bucketId}/consumers/{consumerId}/api-keys /v4/buckets/{bucketId}/ai/provider-assignments/{assignmentId}

Anatomy of a v4 URL

1/v42/buckets3/{bucketId}4/ai5/pools6/{poolId}7/members
  1. 1VersionEvery path, the whole API, on https://api.zuplo.com
  2. 2ContainerA bucket, a project or an account: every path starts with one
  3. 3IDcamelCase, ends in Id
  4. 4NamespaceThe product, a registered word
  5. 5CollectionPlural, kebab-case
  6. 6IDcamelCase, ends in Id
  7. 7Sub-collectionAssociations (no ID of their own) and credentials, in the parent's namespace
After the container, at most four segments and two parameters (five for a person's credentials; the namespace does not count), and never two parameters in a row.
V4-URL-1

Every path starts with /v4/ on https://api.zuplo.com. Literal segments are kebab-case. Path parameters are camelCase and end in Id.

Lintv4-path-shapeShared rules: URL-1, VER-1
Do
kebab-case paths, …Id parameters
Code
/v4/buckets/{bucketId}/ai/user-apps/{userAppId} /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/budget
Don't
Other casings, or no Id suffix
Code
/v4/buckets/{bucketId}/ai/userApps/{user_app_id} /v4/buckets/{bucketId}/ai/user-apps/{id}
Do
Only nouns and IDs
Code
/v4/buckets/{bucketId}/consumers/{consumerId}/api-keys /v4/buckets/{bucketId}/api-keys/{apiKeyId}
Don't
Verbs, :roll, trailing /, .json
Code
/v4/buckets/{bucketId}/api-keys/{apiKeyId}/roll /v4/buckets/{bucketId}/api-keys/{apiKeyId}:roll /v4/buckets/{bucketId}/api-keys/{apiKeyId}/ /v4/buckets/{bucketId}/api-keys.json

Accounts and projects are addressed by ID too ({accountId}, {projectId}), never by name. See containers.

Re-root at the container

This is the rule that keeps URLs short.

V4-URL-2 · V4-URL-4 · V4-URL-5

Every path starts at its container, /v4/buckets/{bucketId}, /v4/projects/{projectId} or /v4/accounts/{accountId}. A resource that has its own ID has one collection, directly in its container, where it is listed, created and addressed: /v4/{container}/{containerId}/{collection} and …/{collection}/{id}, and only there. Its owner is a property, and a filter on the list. Only associations, which have no ID of their own, and credentials, which are listed and created under what holds them, are nested. A namespace is part of the collection's name, so /v4/buckets/{bucketId}/ai/apps/{appId} has that shape too.

Lintv4-path-shapev4-canonical-urlShared rule: URL-4

IDs are globally unique and prefixed (csmr_…), so a resource never needs its owner in the URL: the server knows an app's pool from the app's ID, and a client finds a pool's apps with a filter. The URL that lists and creates a resource is the one that addresses it, minus the ID. As soon as you have an ID, you start again at the container:

  1. List and create/v4/buckets/{bucketId}/ai/poolslist and create pools in a bucket
  2. By ID, in the container/v4/buckets/{bucketId}/ai/pools/{poolId}a pool
  3. List and create/v4/buckets/{bucketId}/ai/apps?poolId={poolId}list the pool's apps; create one with its poolId
  4. By ID, in the container/v4/buckets/{bucketId}/ai/apps/{appId}an app
  5. By ID, in the container/v4/buckets/{bucketId}/consumers/{consumerId}a consumer (a credential)
  6. List and create/v4/buckets/{bucketId}/ai/apps/{appId}/consumerslink it to the app by its ID; the app lists it
  7. List and create/v4/buckets/{bucketId}/consumers/{consumerId}/api-keyslist its keys; roll it
  8. By ID, in the container/v4/buckets/{bucketId}/api-keys/{apiKeyId}a key

The container is the one ancestor every URL keeps. Every request names the bucket, project or account it touches, so a client set up for one gateway sends the same {bucketId} on every call, and a URL in a log shows where the resource lives.

A container re-roots all the way: anything addressed by its own ID is at the root unless there is a reason to nest it. A project or a bucket is listed and created in its account (/v4/accounts/{accountId}/projects), because the account owns what is created in it and a list without it would span every account a sign-in can use. Once it has an ID, it is addressed at the root: /v4/projects/{projectId}, /v4/buckets/{bucketId}.

Do
In its container, by its ID
Code
/v4/buckets/{bucketId}/ai/apps/{appId}
Don't
Drop the container
Code
/v4/ai/apps/{appId}

The URL shapes

Every v4 path starts at a container and has one of these shapes after it. Anything else fails lint.

/aiNamespace/appsCollection/{appId}ID/usageSingleton
CollectionGETPOST
/{collection}
e.g./v4/buckets/{bucketId}/ai/pools
ItemGETPATCHDELETE
/{collection}/{id}
e.g./v4/buckets/{bucketId}/ai/apps/{appId}
Sub-collectionGETPOST
/{collection}/{id}/{collection}
e.g./v4/buckets/{bucketId}/ai/pools/{poolId}/members

Associations, where a POST adds or updates, and credentials, where a POST creates one: /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/consumers. Other resources with an ID are never listed or created under their owner.

SingletonGETPUTPATCH
/{collection}/{id}/{singleton}
e.g./v4/buckets/{bucketId}/api-keys/{apiKeyId}/secret

Or directly in the container: /v4/buckets/{bucketId}/ai/budget-rules

Association itemGETPUTDELETE
/{collection}/{id}/{collection}/{key}
e.g./v4/buckets/{bucketId}/ai/pools/{poolId}/members/{userId}

Or read-only, a person in a user app: /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}. Or a person in the container itself, keyed by {userId} directly in it:

/v4/projects/{projectId}/members/{userId}
A person's resourcesGETPOSTPUTPATCH
/{collection}/{id}/users/{userId}/{credentials|singleton}
e.g./v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/consumers

What a person has in a resource: the credentials they hold, or one of their singletons, such as /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/budget. Only these follow users/{userId}.

ContainerGETPATCHDELETE
/{container}/{id}
e.g./v4/buckets/{bucketId}

At the root, by its ID. It is listed and created in its parent container (/v4/accounts/{accountId}/buckets), or at the root when it has none (/v4/accounts). What is in it is under its own URL: /v4/buckets/{bucketId}/ai/pools,

/v4/projects/{projectId}/environments

That is, after the container, at most four segments and two parameters (five for a person's credentials), and never two parameters in a row. A namespace belongs to the collection after it and does not count: /v4/buckets/{bucketId}/ai/pools/{poolId}/members/{userId} is an association item.

One canonical URL per resource

Do
One item URL, in the container
Code
/v4/buckets/{bucketId}/ai/apps/{appId}
Don't
A second, nested URL
Code
/v4/buckets/{bucketId}/ai/pools/{poolId}/apps/{appId}
Creates point at the canonical URL

A collection that creates resources (POST /v4/buckets/{bucketId}/ai/apps) must have a canonical item URL, in the same container, for what it creates. The Location header of the 201 points there.

One parameter name per collection

A collection is identified by the same parameter everywhere: {poolId} in …/ai/pools/{poolId} and in …/ai/pools/{poolId}/members, and {bucketId} in every path in a bucket.

The container in the path must match

The service checks that the resource is in the account, project or bucket in the path. A resource from another container is 404, as if it did not exist.

The owner is a property

The owner is the resource whose lifecycle the child shares. Deleting the owner deletes or orphans the child, and the child cannot move to another owner. The owner is a property and a filter, not a path: required when the child is created, never changed, and a filter on the child's list.

App
owned by its pool

POST/v4/buckets/{bucketId}/ai/apps with poolId

GET/v4/buckets/{bucketId}/ai/apps?poolId=…
Provider
owned by its bucket
POST/v4/buckets/{bucketId}/ai/providers
Consumer
the exception: held, not owned
POST/v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/consumers

A credential is listed and created under what holds it. See credentials.

Do
The apps collection, by poolId
Code
GET /v4/buckets/{bucketId}/ai/apps?poolId=config_IM1F8Z4rUT6rDPhabvfnrt27
Code
POST /v4/buckets/{bucketId}/ai/apps Content-Type: application/json { "poolId": "config_IM1F8Z4rUT6rDPhabvfnrt27", "name": "Support bot" }
Don't
Nest apps under their pool
Code
GET /v4/buckets/{bucketId}/ai/pools/{poolId}/apps

The list and create URL would differ from the URL that addresses the app, and listing every app of a gateway would need a second list.

Do
Filter by parentPoolId
Code
GET /v4/buckets/{bucketId}/ai/pools?parentPoolId=…
Don't
Nest under a non-owner
Code
GET /v4/buckets/{bucketId}/ai/pools/{poolId}/pools

Associations: resources without an ID

Some resources are just a relationship between two others: a person's role on a pool, a person in a user app, or a consumer linked to an app. They have no ID of their own. They are identified by the pair, so their URL is the association item shape. An association is written one of two ways, and only that way (methods), or not at all:

Code
POST /v4/buckets/{bucketId}/ai/pools/{poolId}/members add someone or change their role GET /v4/buckets/{bucketId}/ai/pools/{poolId}/members/{userId} read it DELETE /v4/buckets/{bucketId}/ai/pools/{poolId}/members/{userId} remove it GET /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId} read-only: a person in a user app PUT /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/budget set a singleton of theirs POST /v4/buckets/{bucketId}/ai/apps/{appId}/consumers link a consumer to an app DELETE /v4/buckets/{bucketId}/ai/apps/{appId}/consumers/{consumerId} unlink it

A link to a consumer is the one association whose key is the ID of a resource that has its own URL: the item is the link, and the consumer is still read, changed and deleted at …/consumers/{consumerId}. It is added like a member, with a POST of the key ({ "consumerId": "…" }).

What belongs to one person in a resource hangs off the association: their credentials (…/users/{userId}/consumers) and their singletons (…/users/{userId}/budget, …/users/{userId}/usage).

Do
Add a member: POST the key
Code
POST /v4/buckets/{bucketId}/ai/pools/{poolId}/members Content-Type: application/json { "userId": "user_Jm5tR8wQz2KcX7vB4nLp9HdS", "role": "admin" }
Do
Set a person's value: PUT
Code
PUT /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/budget Content-Type: application/json { "limits": […] }
Don't
Write one association both ways
Code
PUT /v4/buckets/{bucketId}/ai/pools/{poolId}/members/{userId} Content-Type: application/json { "role": "admin" }

Members are already added and changed with a POST to …/members.

Either way the client knows the key, so the server finds an existing association: the write changes it and returns 200, or creates it and returns 201. Sending it again changes nothing, so there is nothing to deduplicate (see idempotency).

Credentials: listed under what holds them

A consumer is one credential, and its API keys are the versions of that credential: a new key replaces the others when the credential is rolled. Credentials are the one kind of resource with an ID that is nested. They are listed and created under what holds them, and addressed by their ID in the container:

Code
GET POST /v4/buckets/{bucketId}/consumers the bucket's consumers, AI apps' included POST /v4/buckets/{bucketId}/ai/apps/{appId}/consumers link one to an app; the app lists them GET DELETE /v4/buckets/{bucketId}/ai/apps/{appId}/consumers/{consumerId} read or remove the link GET POST /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/consumers a person's consumers GET PATCH DELETE /v4/buckets/{bucketId}/consumers/{consumerId} any consumer GET POST /v4/buckets/{bucketId}/consumers/{consumerId}/api-keys its keys; a POST rolls GET PATCH DELETE /v4/buckets/{bucketId}/api-keys/{apiKeyId} any key

What holds a credential is not one of its properties: an app lists its consumers in its consumers (with include[]=consumers), but a consumer does not name its app. Consumers and keys are then the same resources for every product, and a person's consumers are theirs without an ownerId. A key is still addressed by its ID alone, so a leaked key is revoked without looking up its consumer.

An app's consumers are consumers in the bucket, linked to the app by their ID: a POST of { "consumerId": "…" } to …/ai/apps/{appId}/consumers links one, and a DELETE on …/ai/apps/{appId}/consumers/{consumerId} unlinks it. An app has a few, one per client or environment, so the app returns them with include[]=consumers. A person's consumers and the bucket's have a list of their own, which pages.

Do
Create, then link to the app
Code
POST /v4/buckets/{bucketId}/consumers?include[]=apiKey Content-Type: application/json { "name": "support-bot-production" }
Code
POST /v4/buckets/{bucketId}/ai/apps/{appId}/consumers Content-Type: application/json { "consumerId": "csmr_brw0leTAVw478VFKD4F6OqVI" }
Don't
Name the holder in the body
Code
POST /v4/buckets/{bucketId}/consumers Content-Type: application/json { "appId": "config_Kx7gDiGsr1c2tOOUW5kpmXZn", "description": "Production backend" }

Every credential would carry a property for each kind of holder.

Do
Address a key by its ID
Code
DELETE /v4/buckets/{bucketId}/api-keys/{apiKeyId}
Don't
Address it under its consumer
Code
DELETE /v4/buckets/{bucketId}/consumers/{consumerId}/api-keys/{apiKeyId}

Revoking a leaked key would need its consumer first.

Containers: accounts, projects and buckets

V4-URL-6

Accounts, projects and buckets are the three containers of v4, all addressed by ID at the root. Every path starts with one. Projects and buckets are listed and created in their account, /v4/accounts/{accountId}/projects and /v4/accounts/{accountId}/buckets, and addressed at the root by their ID, /v4/projects/{projectId} and /v4/buckets/{bucketId}. Everything in them is addressed under that URL: what a project deploys under /v4/projects/{projectId}, gateway data under /v4/buckets/{bucketId}. A project or bucket the caller cannot see, one in another account included, is a 404, as if it did not exist.

Lintv4-path-shapeReviewShared rule: URL-4a
Account/v4/accounts/{accountId}
Lists and creates its projects at /projects and its buckets at /buckets, and lists and adds its members at /members, invited by email.
Project/v4/projects/{projectId}
One gateway. Listed and created in its account, read at this URL by its ID; what it deploys, and who works on it, is under this URL. Each of its environment types gets a bucket, listed in its buckets; an AI Gateway keeps its data in the production one.
Members/members
The people with a role on the project, by userId.
Environments/environments
Production, a preview for each Git branch, and each person's working copy.
Deployments/deployments
One build rolled out to an environment, by environmentId. Created, then read until it is done.
Bucket/v4/buckets/{bucketId}
One per environment type of a project, plus custom buckets. Listed and created in its account, read at this URL by its ID; everything in it is under this URL.
AI providers/ai/providers
Zuplo's built-in providers, read-only, and the custom providers defined here. No keys.
Models/models
Read-only: the models a provider offers, with list prices.
AI provider assignments/ai/provider-assignments
The providers the gateway routes to: a label, models, and the environment variable that holds the key.
AI pools/ai/pools
AI user apps/ai/user-apps
AI Gateway budget/ai/budget-rules
On the production bucket: the gateway's own budget rules, read and set as one list.
Consumers/consumers
Every consumer, by ID. The bucket's own are listed here; an app's and a person's under them.
API keys/api-keys
Every key, by ID. A consumer's keys are listed and rolled under it.

The bucket is the container for gateway data. Today's APIs reach the same bucket in two ways: /v1/buckets/{bucketId}/… and the older /v1/{feature}/{bucketId}/… (/v1/api-keys/{bucketId}/…). The per-feature form exists because there used to be one bucket per feature (API keys, monetization). Now there is one bucket per environment type of a project.

Do
Resources under the bucket
Code
/v4/buckets/{bucketId}/ai/pools
Don't
The per-feature form of v1
Code
/v4/{feature}/{bucketId}/…
Do
Create in the account
Code
POST /v4/accounts/{accountId}/buckets
Code
GET /v4/buckets/{bucketId}

The account owns the new bucket, so it is created there. Once it has an ID, it is read at the root.

Don't
Nest it in the account
Code
GET /v4/accounts/{accountId}/buckets/{bucketId}
Code
GET /v4/accounts/{accountId}/buckets/{bucketId}/ai/pools

The bucket ID alone identifies it, so the account is one level of nesting too many.

Don't
List without the account
Code
GET /v4/buckets

A sign-in can use several accounts, so the list would span all of them.

Do
A project's contents under it
Code
GET /v4/projects/{projectId}/environments
Don't
Nest them in the account
Code
GET /v4/accounts/{accountId}/projects/{projectId}/environments
Code
GET /v4/accounts/{accountId}/environments?projectId=…

The first is one level of nesting too many, and the second makes every environment of every project one list.

/v4/buckets/{bucketId}/… is the conceptually correct form, and re-rooting inside it keeps it short. A product's namespace names the collections in the bucket (/v4/buckets/{bucketId}/ai/pools), never the bucket.

Take the bucket from its project

Customers start from their project, not from a bucket ID. A project's buckets hold the bucket of each of its environment types, production first, so the response that creates a project, and every read of it, has the ID. An AI Gateway project has one bucket:

GET/v4/projects/{projectId}
Create custom buckets in the account

Every project has a bucket for each environment type, which the environments of that type share. POST/v4/accounts/{accountId}/buckets creates a custom bucket that is not tied to an environment. Its projectId and environmentType are null. Consumers and their API keys can live in one, so several projects can share them. An AI Gateway always uses the production bucket.

The AI Gateway uses the production bucket

An AI Gateway keeps all of its data in the project's production bucket.

Accounts and projects are addressed by ID

{accountId} (a UUID) and {projectId} (prj_…), like everything else. A name can be changed, or reused after a delete, and an ID cannot. The name is a property that clients show, and GET /v4/accounts?name=… finds an account's ID, but a name is never a path segment.

Projects are created in the account

A project is listed and created in its account (/v4/accounts/{accountId}/projects) and read at the root by its ID (/v4/projects/{projectId}), like a bucket. What is in it, its members, environments and deployments, is under that URL: /v4/projects/{projectId}/environments. It never becomes a path segment in front of a bucket.

Collection names and namespaces

V4-URL-3 · V4-URL-7

Collections are plural nouns in kebab-case. A collection name means one resource type across all of v4, so a product's collections live in the product's namespace (…/ai/pools), never behind a product prefix. Cross-product collections have no namespace. Namespaces and singletons are registered words, and there are no action verbs.

Lintv4-path-shapev4-canonical-urlReview
Do
Namespace product collections
Code
/v4/buckets/{bucketId}/ai/providers /v4/buckets/{bucketId}/ai/apps/{appId} /v4/accounts/{accountId}/buckets /v4/buckets/{bucketId}/api-keys/{apiKeyId}

Cross-product collections stay bare.

Don't
Prefixed, generic or singular
Code
/v4/buckets/{bucketId}/ai-providers /v4/buckets/{bucketId}/providers /v4/buckets/{bucketId}/ai/pool

v4 will hold everything Zuplo does, and apps, providers and models mean different things in different products. The namespace says which product a collection belongs to, so the name itself stays short. Association collections (members) are scoped by their parent and may repeat across parents, because they are always reached through it.

A namespace belongs to its collection

A namespace is not a resource. It is part of the collection's name: ai/pools is one collection, and /v4/buckets/{bucketId}/ai/pools/{poolId} has the item shape. A namespace is always followed by a collection, and a collection under a namespaced owner is in the owner's namespace. The namespace is written once, and a child never names one:

Do
Members inherit the namespace
Code
/v4/buckets/{bucketId}/ai/pools/{poolId}/members
Don't
Repeat the owner's namespace
Code
/v4/buckets/{bucketId}/ai/pools/{poolId}/ai/members
Do
Bare owner, namespaced child
Code
/v4/buckets/{bucketId}/ai/pools
Don't
Namespace a cross-product owner
Code
/v4/ai/buckets/{bucketId}/pools

A namespace never reuses a cross-product name: there is no ai/consumers next to consumers. An AI app's consumers are consumers like any other, listed under the app.

Namespaces are registered words: adding one means changing NAMESPACES in lint/lib.js and this table in the same pull request.

NamespaceProduct
aiAI Gateway

SDK methods and MCP tools have no namespaces, so operationIds keep the namespace as their first word after the verb: list-ai-pools, get-ai-model (see operationIds).

The v4 resource names so far:

CollectionItem URLCreated under
accounts/v4/accounts/{accountId}/v4/accounts (list only: the accounts you can use)
projects/v4/projects/{projectId}/v4/accounts/{accountId}/projects
buckets/v4/buckets/{bucketId}/v4/accounts/{accountId}/buckets (custom buckets; each environment type of a project gets one with the project)
environments/v4/projects/{projectId}/environments/{environmentId}/v4/projects/{projectId}/environments
deployments/v4/projects/{projectId}/deployments/{deploymentId}/v4/projects/{projectId}/deployments (environmentId in the body; created, then read until it is done)
ai/providers/v4/buckets/{bucketId}/ai/providers/{providerId} (built-in providers are read-only)/v4/buckets/{bucketId}/ai/providers (custom providers only)
models (provider)/v4/buckets/{bucketId}/ai/providers/{providerId}/models/{modelId} (read-only: a model is reached through its provider)/v4/buckets/{bucketId}/ai/providers/{providerId}/models (list only: Zuplo's catalog for a built-in provider, a custom provider's own models)
ai/provider-assignments/v4/buckets/{bucketId}/ai/provider-assignments/{assignmentId}/v4/buckets/{bucketId}/ai/provider-assignments (providerId in the body)
ai/pools/v4/buckets/{bucketId}/ai/pools/{poolId}/v4/buckets/{bucketId}/ai/pools
ai/apps/v4/buckets/{bucketId}/ai/apps/{appId}/v4/buckets/{bucketId}/ai/apps (poolId in the body)
ai/user-apps/v4/buckets/{bucketId}/ai/user-apps/{userAppId}/v4/buckets/{bucketId}/ai/user-apps
consumers/v4/buckets/{bucketId}/consumers/{consumerId}/v4/buckets/{bucketId}/consumers, /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/consumers (credential: under what holds it). Linked to an app at /v4/buckets/{bucketId}/ai/apps/{appId}/consumers (association: links)
api-keys/v4/buckets/{bucketId}/api-keys/{apiKeyId}/v4/buckets/{bucketId}/consumers/{consumerId}/api-keys (credential: a POST rolls)
members (pool)/v4/buckets/{bucketId}/ai/pools/{poolId}/members/{userId}/v4/buckets/{bucketId}/ai/pools/{poolId}/members (association: adds or updates)
members (project)/v4/projects/{projectId}/members/{userId}/v4/projects/{projectId}/members (association: adds or updates)
members (account)/v4/accounts/{accountId}/members/{userId}/v4/accounts/{accountId}/members (association: adds by email, or updates)
users (user app)/v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId} (association: read-only)/v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users (list only: everyone who can use the user app, each with their budget with include[]=budget)

Singletons, and no actions

One kind of last segment is not a collection: a singleton, of which a parent (a resource or the container) has exactly one. Singletons are registered words: adding one means changing lint/lib.js and this page in the same pull request. That keeps a singleton from colliding with an ID or a collection name. A product's singleton is registered with its namespace, like a collection, and under an owner in that namespace it is written without one.

SingletonURLMethods
usage/v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/usageGET
budget/v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/budgetGET PUT
ai/budget-rules/v4/buckets/{bucketId}/ai/budget-rules (a list)GET PUT
secret/v4/buckets/{bucketId}/api-keys/{apiKeyId}/secret (only the person who holds a personal consumer)GET

A singleton can be a list, named in the plural: the gateway's ai/budget-rules. The list is the body. GET returns the array, and PUT replaces it with the complete array, [] included. Its items have no ID and no URL, so it is not a collection and is not paginated. A list that is one property among others, such as a pool's budgetRules, stays a property.

Do
PUT the list as the body
Code
PUT /v4/buckets/{bucketId}/ai/budget-rules Content-Type: application/json [ { "budgetBy": "app", "limits": [ { "meter": "cost", "period": "daily", "action": "warn", "value": 800 }, { "meter": "cost", "period": "daily", "action": "block", "value": 1000 } ] } ]
Don't
Wrap the list in an object
Code
PUT /v4/buckets/{bucketId}/ai/budget Content-Type: application/json { "budgetRules": [{ "budgetBy": "app", "limits": […] }] }

The object around the list holds nothing else.

Singleton sub-resource
budget

Exactly one per parent. GET, and PUT/PATCH if writable. It lives and dies with its parent, so there is no POST and no DELETE: a person's budget is reset with a PUT of { "limits": [] }.

/v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/budget
Action verb
None in v4

No URL ends in a verb, so a client never learns a vocabulary of actions. Lint reports a verb as a collection that is not a plural noun.

An operation that feels like an action is a create, a field change or a delete. Find the resource it creates or the field it changes:

Do
Roll by creating a new key
Code
POST /v4/buckets/{bucketId}/consumers/{consumerId}/api-keys?previousKeyGracePeriodSeconds=86400 Content-Type: application/json {}

The new key replaces the consumer's other keys: they keep working for a day, then expire.

Don't
A verb on the key
Code
POST /v4/buckets/{bucketId}/api-keys/{apiKeyId}/roll
Do
Add again to resend
Code
POST /v4/accounts/{accountId}/members Content-Type: application/json { "email": "[email protected]", "role": "developer" }

A person is in an account once, so an invited member gets a new email, with a new link and a new expiry.

Don't
A verb on the member
Code
POST /v4/accounts/{accountId}/members/{userId}/resend-invitation
Do
Renaming is a field change
Code
PATCH /v4/buckets/{bucketId}/ai/apps/{appId} Content-Type: application/json { "name": "Checkout assistant" }

PATCH it.

Don't
A verb for a field change
Code
POST /v4/buckets/{bucketId}/ai/apps/{appId}/rename
Do
Link by adding to a collection
Code
POST /v4/buckets/{bucketId}/ai/apps/{appId}/consumers Content-Type: application/json { "consumerId": "csmr_brw0leTAVw478VFKD4F6OqVI" }

The link is an association: DELETE on …/ai/apps/{appId}/consumers/{consumerId} removes it.

Don't
A link operation
Code
POST /v4/buckets/{bucketId}/ai/apps/{appId}/consumers/link Content-Type: application/json { "consumerId": "csmr_brw0leTAVw478VFKD4F6OqVI" }

If an operation is none of these, that is a design question for decisions, not a new URL shape.

Lint
Fails CI on any finding
v4-path-shapev4-canonical-urlv4-method-shape
Review
Checked by people in the pull request

Which container a resource belongs in (V4-URL-6), and whether a collection belongs in a product's namespace or is cross-product (V4-URL-7).

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