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.
Code
Code
Anatomy of a v4 URL
- 1VersionEvery path, the whole API, on https://api.zuplo.com
- 2ContainerA bucket, a project or an account: every path starts with one
- 3IDcamelCase, ends in Id
- 4NamespaceThe product, a registered word
- 5CollectionPlural, kebab-case
- 6IDcamelCase, ends in Id
- 7Sub-collectionAssociations (no ID of their own) and credentials, in the parent's namespace
Every path starts with /v4/ on https://api.zuplo.com. Literal segments are
kebab-case. Path parameters are camelCase and end in Id.
Code
Code
Code
Code
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.
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.
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:
- List and create
/v4/buckets/{bucketId}/ai/poolslist and create pools in a bucket - By ID, in the container
/v4/buckets/{bucketId}/ai/pools/{poolId}a pool - List and create
/v4/buckets/{bucketId}/ai/apps?poolId={poolId}list the pool's apps; create one with its poolId - By ID, in the container
/v4/buckets/{bucketId}/ai/apps/{appId}an app - By ID, in the container
/v4/buckets/{bucketId}/consumers/{consumerId}a consumer (a credential) - List and create
/v4/buckets/{bucketId}/ai/apps/{appId}/consumerslink it to the app by its ID; the app lists it - List and create
/v4/buckets/{bucketId}/consumers/{consumerId}/api-keyslist its keys; roll it - 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}.
Code
Code
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/{collection}/v4/buckets/{bucketId}/ai/pools/{collection}/{id}/v4/buckets/{bucketId}/ai/apps/{appId}/{collection}/{id}/{collection}/v4/buckets/{bucketId}/ai/pools/{poolId}/membersAssociations, 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.
/{collection}/{id}/{singleton}/v4/buckets/{bucketId}/api-keys/{apiKeyId}/secretOr directly in the container: /v4/buckets/{bucketId}/ai/budget-rules
/{collection}/{id}/{collection}/{key}/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}/{collection}/{id}/users/{userId}/{credentials|singleton}/v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/consumersWhat 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}.
/{container}/{id}/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}/environmentsThat 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
Code
Code
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.
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 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.
POST/v4/buckets/{bucketId}/ai/apps with poolId
/v4/buckets/{bucketId}/ai/apps?poolId=…/v4/buckets/{bucketId}/ai/providers/v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/consumersA credential is listed and created under what holds it. See credentials.
Code
Code
Code
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.
Code
Code
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
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).
Code
Code
Code
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
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.
Code
Code
Code
Every credential would carry a property for each kind of holder.
Code
Code
Revoking a leaked key would need its consumer first.
Containers: accounts, projects and buckets
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.
/v4/accounts/{accountId}/v4/projects/{projectId}/members/environments/deployments/v4/buckets/{bucketId}/ai/providers/models/ai/provider-assignments/ai/pools/ai/user-apps/ai/budget-rules/consumers/api-keysThe 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.
Code
Code
Code
Code
The account owns the new bucket, so it is created there. Once it has an ID, it is read at the root.
Code
Code
The bucket ID alone identifies it, so the account is one level of nesting too many.
Code
A sign-in can use several accounts, so the list would span all of them.
Code
Code
Code
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.
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:
/v4/projects/{projectId}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.
An AI Gateway keeps all of its data in the project's production bucket.
{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.
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
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.
Code
Cross-product collections stay bare.
Code
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:
Code
Code
Code
Code
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.
| Namespace | Product |
|---|---|
ai | AI 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:
| Collection | Item URL | Created 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.
| Singleton | URL | Methods |
|---|---|---|
usage | /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/usage | GET |
budget | /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/budget | GET 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.
Code
Code
The object around the list holds nothing else.
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}/budgetNo 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:
Code
The new key replaces the consumer's other keys: they keep working for a day, then expire.
Code
Code
A person is in an account once, so an invited member gets a new email, with a new link and a new expiry.
Code
Code
PATCH it.
Code
Code
The link is an association: DELETE on
…/ai/apps/{appId}/consumers/{consumerId} removes it.
Code
If an operation is none of these, that is a design question for decisions, not a new URL shape.
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).