Zuplo API v4
Reference

Decisions and Open Questions

What is decided, where v4 deliberately differs from the shared guidelines, and what still needs a decision before the AI Gateway part of v4 leaves preview.

Decided on 2026-10-06

From the meeting "Discuss (Scoping document) Expose AI Gateway APIs via dev.zuplo.com" (recording, scoping document):

01v4 is one canonical API for all of Zuplo

The portal is its first and main consumer, followed by the CLI, the MCP server and generated tooling.

02Resources, not a generic configuration

The /configurations blob with a type inside becomes typed resources (pools, apps, user apps), and every payload is validated against a schema.

03Human-first design

It should read like a good REST API to a developer. That is also what agents handle best.

04The bucket is the container

buckets/{bucketId} is the conceptually right parent. The /{namespace}/{bucketId} shape only existed because there used to be several buckets per project. URLs must stay short enough to read in Zudoku.

05API keys and OAuth

Both are needed: keys for automation, OAuth for the interactive setup wizard.

06OpenFGA runs in the service, not at the gateway

The edge does not have the information to decide in one check.

07Phase 1 is the AI Gateway setup surface

Providers and models, pools, pool members, apps, user apps, app and personal API keys. Provider assignments were an internal detail; since question 36 they are a resource of their own. Analytics waits (push or pull is still open).

08Spec first

An OpenAPI document in Zudoku is reviewed before any code is written.

Where v4 differs from the shared guidelines

v4 builds on the shared guidelines. These are the places where it deliberately chooses differently, and why. Most narrow a shared rule. Seven contradict one: VER-1, URL-4, URL-4a, DATA-3, SPEC-1, ROB-1 and ROB-2.

Versioning
SharedPer subsystem (VER-1)v4One /v4 for everything
“Point people to v4 and that's everything.”
URL shapes
SharedFlat, or namespace-scoped; a parent is a filter on the flat list, and only a child without its own ID is nested (URL-4)v4Flat inside its container (bucket, project or account): a parent is a filter (…/ai/apps?poolId=…), and only associations and credentials (an app's consumers, a consumer's keys) are nested
Short, one canonical URL, and every request names the bucket it touches.
Accounts
SharedAccount-rooted, by-name paths are legacy and never extended (URL-4a)v4Accounts are a container, addressed by ID: /v4/accounts/{accountId}/buckets
An account holds the projects, buckets and people, and an ID, unlike a name, never changes.
IDs
SharedPrefixed opaque IDs (DATA-3)v4Opaque, exactly as the owning service mints them: prefixed (prj_…, bckt_…) or not (an account is a UUID)
One ID per resource in v4, the services and their logs, and no translation layer.
/me routes
SharedAllowed as singletonsv4None: the caller passes their own userId (…/users/{userId}/usage)
One URL for people, admins and API keys.
PUT
SharedReplace or upsert anywhere (METH-1)v4Singletons (a person's budget), and associations that are set rather than added; a membership is added with a POST to its collection
One way to update a resource, and one way to write each association.
Actions
SharedBare kebab verbs (URL-3)v4None: a create, a field change or a delete
Every URL names a thing. Rolling a key is creating its replacement.
Pagination
SharedBounded lists may skip it (COLL-2)v4Every list paginates
One shape, and adding it later is breaking.
Idempotency-Key
SharedCreating or charging POSTs (ROB-1)v4None: a client looks a create up before retrying it
A header the server ignores promises a safety it does not give, and v4 will not store and replay responses.
ETag / If-Match
SharedLost-update-sensitive resources (ROB-2)v4None: the last write wins
A header the server ignores promises a safety it does not give, and v4 will not version resources.
Documented errors
Shared401 and default (ERR-6)v4Everything the operation can return
Agents and SDKs plan error handling from the spec.
Validation status
SharedNot specifiedv4400 for all validation; no 422
One code to handle.
Schemas
SharedOne schema with readOnly/writeOnly (ROB-4)v4Separate request and response schemas
Clean SDK and MCP tool inputs.
operationIds
SharedVerb vocabulary (NAME-5)v4Derived from method and URL shape
Predictable tool names.
Spec source
SharedGenerated from code (SPEC-1)v4Design-first, hand-written
The contract leads; services follow.
Grandfathering
SharedIgnore files (SCOPE-4)v4None
v4 starts clean.

Open questions

These need a decision before the AI Gateway part of v4 leaves preview. Each has a proposal, marked once it is decided.

Q1ID prefixes

gateway-service mints config_… for pools, apps and user apps alike.

Decided on 2026-11-05

Keep config_. Pools, apps and user apps share gateway-service's prefix, so gateway-service changes nothing and there is no translation layer. It is the one named exception to "the prefix tells you what an ID is" (identifiers): the URL and the …Id property (poolId, appId, userAppId) say which of the three an ID is. Since question 33 this is the rule for every ID, not an exception: v4 shows each ID as its service mints it.

Alternative

gateway-service mints typed prefixes (pool_, app_, uapp_) for new rows and keeps accepting config_ IDs forever. IDs are opaque, so nothing in the contract changes, but gateway-service mints a prefix per type and old and new rows differ.

Q2Product namespaces for collection names

/v4/buckets/{bucketId}/ai/apps rather than …/apps or …/ai-apps.

Decided on 2026-11-05

An ai namespace segment. v4 will hold every product, and apps, providers and models are generic (MCP apps, identity providers, …), so they need a product qualifier from the start: a rename later is breaking. A namespace keeps the collection names themselves short, and children inherit it (/v4/buckets/{bucketId}/ai/pools/{poolId}/members). operationIds keep the namespace as a word (create-ai-app), so they stay unambiguous MCP tool names.

Alternative

An ai- prefix on every collection (…/ai-pools/{poolId}/ai-apps): no extra URL concept, but the prefix repeats on every product collection.

Q3Policies

An app's behavior is its policy chain: authentication, model routing, budgets, guardrails, caching and tracing, in the order they run. Phase 1 had flattened two of them into properties (models and budgetRules) and left the rest out.

Decided on 2026-10-15

Everything an app does is a policy, as it is in the gateway (Moritz and Josh, 2026-10-14, recording). Apps and user apps have policies: an ordered list of the AI Gateway's policies, a union discriminated by type with one typed variant per policy (auth, internal-only, metering, model-filtering, model-override, fallback-model, load-balancing, smart-router, semantic-cache, prompt-injection, dlp, akamai-firewall, opik-tracing, galileo-tracing), plus custom for a policy declared in the project. Budgets are the metering policy's budgetRules, and model routing is the routing policies; there are no models or personalLimits properties. A pool has a policyTemplate that new apps copy, as in the portal. Budgets that are not an app's own, a pool's and the gateway's, stay properties of the pool and the gateway settings (since 2026-11-03 the gateway's is at …/ai/budget-rules, question 32). Credentials in policy options are write-only. Deprecated options are not exposed.

Alternative

One curated field per feature (models, budgetRules, semanticCache): shorter, but it hides the order policies run in, and every new policy needs a new field.

Q4Gateway-wide settings

The root configuration (gateway-level budget rules) has no v4 resource.

Decided on 2026-10-07

A singleton, GET/PATCH /v4/buckets/{bucketId}/ai/gateway, on the project's production bucket. A project has exactly one AI Gateway, and it keeps its data in that bucket, so its settings have no ID and are never created or deleted. The singleton shape already covers the URL, and ai/gateway is now a registered singleton in the ai namespace. Since 2026-11-03 its budget is a singleton of its own (question 32), and since 2026-11-05 the gateway singleton is gone: nothing was left in it.

Decided on 2026-10-15

Its first field is the gateway's own budget, budgetRules: an AI Gateway project has a budget of its own, on top of its pools' and apps' budgets. It is the root configuration's metadata.budgetRules today, which the portal edits under Settings → Usage Limits. Since 2026-11-03 it is at …/ai/budget-rules (question 32).

Decided on 2026-11-05

The gateway settings hold settings only. The gateway's URL, and whether it serves a change yet, come from the project's environments and deployments (question 31): an AI Gateway is its project's production environment, with a url, a status and a liveDeploymentId, and a provider change creates a deployment of it. A client that just created the project or changed a provider reads that environment until its status is live. Since 2026-11-06 an AI Gateway environment has no status, and the client reads its newest deployment instead (question 35).

Alternative

The gateway also reports its url and its deployment status (provisioning, deploying, live), so a client can wait on the singleton (until 2026-11-05). It repeats what the production environment already says, in a second place with its own status values.

Q5Inviting people by email

POST …/ai/pools/{poolId}/members requires the person to be in the account already. Inviting new people is an account-level flow. The portal invites with tenant-api's unified members API (/v1/auth/members), which takes an email or a sub and decides itself whether to add the person or invite them, to the account, a project or a pool, in one request.

Reopened on 2026-10-29 (API v4 discussion, recording; Demo Day, recording). People are keyed by their sub, which exists only after their first sign-in, so a script has to invite someone, poll until they accept, and only then add them to pools or projects. Every grant is a separate invitation, and SSO accounts cannot add people without one. A decision meeting with engineering is next.

Decided on 2026-11-05

A person exists as soon as they are added. POST /v4/accounts/{accountId}/members with { email, role } returns a member with a Zuplo-owned userId (usr_…) and status: invited (201, or 200 when the email is already a member, which changes their role). Pools and projects accept that userId in the next request, ai-user on a project included, so automating a new employee across ten resources is eleven requests and no waiting. Signing in (SSO) or accepting the one invitation email (without SSO) makes the member active and activates everything at once. Accepting stays in the Zuplo portal.

  • userId is the Zuplo user ID everywhere (V4-DATA-5). sub is a read-only property of an account member, null until their first sign-in, and a filter (?sub=).
  • There is no invitation resource. Resending the email is adding the member again (question 30), and DELETE …/members/{userId} removes a member and cancels a pending invitation.
  • The rules of today's invitations stay, on the member: who can add people, roles without RBAC, a seven-day link (invitationExpiresOn), and seats, which an invited member does not hold.

The backend gets there with a ledger of pending grants keyed by email, or by keying everything by an internal user ID. The contract is the same either way. Since question 33 the user ID is tenant-api's user_…, the id it already mints for every user record next to the sub, not a new usr_…. Adding someone before their first sign-in means creating that record, or a pending one, when they are added. Mapping from today's API lists what tenant-api has to change.

Alternative

Invitations as their own resource in the account (…/invitations/{invitationId}, inv_…), with an optional role on one pool (in the contract until 2026-11-05): the person had no userId until they accepted, so automation still had to invite, wait for acceptance, then grant every project and every pool but one.

Alternative

A POST to /v4/accounts/{accountId}/members with an email adds a pending member, with people still keyed by their sub: one collection for people in the account, but a pending member has no userId until they accept, and members are keyed by it.

Alternative

Invitations grant an account role only, and adding someone new to a pool is two steps: invite them, then add them to the pool once they accept. Simpler, but the admin has to come back after the person accepts, which the portal does not ask of them today.

Q6Listing all apps of a gateway

Apps were listed and created under their pool (…/ai/pools/{poolId}/apps), so the URL that listed and created an app differed from the one that addressed it, and one list across pools would have been a second list parent.

Decided on 2026-10-15

One collection: GET/POST /v4/buckets/{bucketId}/ai/apps next to the apps' item URLs. poolId is required when an app is created and filters the list. The owner of any resource with an ID is a property and a filter, never a path segment (the owner is a property).

Q7Change propagation

Provider and key changes redeploy the gateway, and descriptions currently say "within a few seconds".

Decided on 2026-11-05

Wait on the newest deployment. Adding or changing a provider creates a deployment (source.type zuplo) of the project's production environment (question 31), and nothing links the change to it: a client that needs to wait lists GET /v4/projects/{projectId}/deployments?environmentId=…, newest first, and reads the newest zuplo deployment created after its change until it has succeeded or failed. Pools, apps, policies and keys apply through the configuration cache without a deployment. They are not tracked, and apply within about a minute.

Alternative

A read-only deploymentId on the provider, the deployment that rolls out its latest change: no guessing when two changes land close together, but one more property that only a provider has.

Alternative

Track configuration-cache changes too, with a version a client waits on: every change can be waited on, but it needs new work in gateway-service.

Alternative

The gateway's status (deploying, live) on the gateway settings (question 4, until 2026-11-05): one place to look, but it repeats what the production environment already says.

Q8Declaring OAuth

The spec declares an http bearer scheme. Declaring the oauth2 flows (authorization code with PKCE, device code for the CLI) would let MCP clients and agents discover the login.

Decided on 2026-11-05

Keep the http bearer scheme until the final dev.zuplo.com OAuth endpoints exist, then declare oauth2 with the authorization code flow and PKCE. The device code flow waits for OpenAPI 3.2: 3.1 has no device flow. MCP clients do not need the spec to find the login: they discover it through the API's protected resource metadata (RFC 9728).

Alternative

Declare oauth2 now, with the planned endpoint URLs: SDKs and docs pick it up early, but the URLs may change before launch.

Alternative

Never declare it in the spec, and rely on the protected resource metadata alone: nothing to keep in sync, but SDK generators and docs cannot show the login.

Q9Idempotency-Key

v4 does not support it, so a create is the one request a client cannot retry blindly. The shared guidelines (ROB-1) require it on creating and charging POSTs.

Decided on 2026-11-05

Never: v4 has no Idempotency-Key, and ROB-1 does not apply to it (V4-IDEM-2). After a create fails without a response, a client looks the resource up before it retries: by its name where that is unique, otherwise as the newest item of the list. Associations and singletons carry their key and are safe to retry as they are (V4-IDEM-4).

Alternative

Leave preview without it, and add it later, optional, to every POST once the backend can store and replay responses: no breaking change, but a promise to build that storage.

Alternative

Require it before v4 leaves preview: every create is safe to retry, but preview waits for the storage.

Q10Offset or cursor pagination

v4 follows the shared decision (offset, cursor only for append-only collections), and the portal's pages and totals depend on it.

Decided on 2026-11-05

Offset for every collection, and cursor only for append-only collections, such as build logs, request logs and events (question 31). A collection's pagination is chosen when it is introduced and never switched, because switching is breaking.

Alternative

Cursor everywhere: one shape for every list, but the portal loses its page numbers and totals.

Alternative

Keep it open, and revisit only if a v4 collection needs to be large: no decision now, but every new collection asks again.

Q11Where re-rooting starts

Resources with an ID are addressed without their owner. The question is whether the container stays in their URL.

Decided on 2026-10-07

From the v4 design review on 2026-10-07 (recording): everything in a bucket stays under it, and re-rooting happens inside it: /v4/buckets/{bucketId}/ai/providers/{providerId}, /v4/buckets/{bucketId}/ai/apps/{appId}. Scoping by the token alone would be implicit: an API key belongs to one account, but an OAuth token can reach several, so the same request would behave differently. The bucket is a hard boundary, nothing is listed across buckets, and a resource from another bucket is 404. Buckets themselves are listed, created and read in their account, /v4/accounts/{accountId}/buckets/{bucketId}, so a create and the reads after it share one parent. Since question 34, a bucket is listed and created in its account and read at the root, /v4/buckets/{bucketId}.

Alternative

Re-root at /v4 (/v4/ai/apps/{appId}): two segments shorter, but a URL does not say which bucket it touches, and a client needs the bucket ID for creates and lists only.

Q12Writing associations

Pool members and budget overrides were added with PUT …/{userId}. In the API design review on 2026-10-08 (recording), that read as unusual next to every other create, which is a POST to a collection.

Decided on 2026-10-15

A membership is added: a POST to the association collection, with the key (userId) in the body, adds the person or changes their role (201 with Location when added, 200 when changed). A budget override is set: everyone in the user app already has limits, and the override replaces them, so it is a PUT on …/budget-overrides/{userId} with the complete limits and no PATCH (Moritz and Josh, 2026-10-14, recording). Each association is written exactly one of the two ways. See associations. Since 2026-10-27 the override is a person's budget, a singleton that is still set with a PUT (question 21).

Alternative

POST to the collection for both (decided 2026-10-14, then replaced): every create is a POST, but "set a budget override" reads as a POST that updates.

Alternative

PUT on every association item, members included: one style for all associations, but adding someone to a pool would be the only create that is not a POST.

Q13API keys

App keys and personal keys were created under their app or user app (…/ai/apps/{appId}/api-keys, …/ai/user-apps/{userAppId}/api-keys), the one place where an owner was still a path segment, and the API key service's consumers and their keys had no v4 resource. Each kind of key had its own URLs.

Decided on 2026-10-27

Every key belongs to a consumer, the key service's credential, and every consumer is listed and created under what holds it: an app (…/ai/apps/{appId}/consumers, one per client or environment), a person in a user app (…/ai/user-apps/{userAppId}/users/{userId}/consumers, for example one per laptop), or the bucket (/v4/buckets/{bucketId}/consumers, for API key authentication on any gateway). Since 2026-10-28 an app returns its consumers instead of listing them (question 23), and they are consumers in the bucket that are linked to the app (question 25). What holds a consumer is not one of its properties: consumers and keys carry no appId, userAppId or ownerId. A consumer's keys are listed and rolled at …/consumers/{consumerId}/api-keys. Consumers and keys are addressed by their ID in the bucket (…/consumers/{consumerId}, …/api-keys/{apiKeyId}), so a leaked key is revoked without looking up its consumer. Credentials are the one kind of resource with an ID that is nested (credentials), and …/users/{userId}/{credentials} is the one path five segments deep.

Alternative

One collection for every key, /v4/buckets/{bucketId}/api-keys, with exactly one of appId, userAppId with ownerId, or consumerId (decided 2026-10-26, then replaced). It needed no exception to V4-URL-4, but it hid that every key belongs to a consumer, and it gave a person's laptops one owner, so rolling one key expired the others.

Alternative

Consumers nested all the way down (…/consumers/{consumerId}/api-keys/{apiKeyId}, and …/{apiKeyId}/secret): reads like v1, but the secret is five segments deep under a consumer's key, and revoking a key needs its consumer first.

Q14ETag and If-Match

v4 does not support them, so the last write wins when two people (or agents) edit the same resource. The shared guidelines (ROB-2) require them for lost-update-sensitive resources, and most v4 resources are rich configuration.

Decided on 2026-11-05

Never: v4 has no ETag or If-Match, the last write wins, and ROB-2 does not apply to it (V4-IDEM-3). A client sends only the fields it changes in a PATCH, and reads an array right before it replaces it.

Alternative

Leave preview without them, and add an ETag to every single-resource response and an optional If-Match to every PATCH, PUT and DELETE (412 when it no longer matches) once the services can version a resource: no breaking change, but a promise to build versioning.

Alternative

Require them before v4 leaves preview: no lost updates, but preview waits for versioning in every service.

Q15A consumer is one credential

AI keys had one valid key per app or person: creating a key replaced the current one. A v1 consumer can hold several keys, and roll-key expires every key without an expiry and adds a new one.

Decided on 2026-10-27

A consumer is one credential, and its keys are the versions of it. Outside a roll's grace period it has one working key. A POST to …/consumers/{consumerId}/api-keys rolls: the consumer's other keys expire right away, or after previousKeyGracePeriodSeconds (v1's roll-key, as a create), and a retry replaces the key it issued. An app or a person that needs several credentials has several consumers, so rolling one never touches the others. A v1 consumer in the bucket that holds several unrelated keys today loses them on its first roll.

Alternative

Several keys per owner, and a POST that adds one unless previousKeyGracePeriodSeconds is sent (decided 2026-10-26, then replaced). Rolling one of a person's keys expired all of them.

Q16Consumer features v4 does not have yet

Consumers come from the v1 key-bucket API (/v1/accounts/{accountName}/key-buckets/{bucketName}/consumers). It can also return a key's secret again from a retrievable bucket (key-format=visible), which is how the developer portal shows consumers their keys. It accepts a key value chosen by the client and creates keys in bulk (keys/$bulk), invites a manager by email before they have a sub, and filters consumers by tag (tag.{name}=…).

Decided on 2026-11-05

Leave them out of phase 1. The keys of a bucket's consumers show their secret once (V4-DATA-3), so the developer portal keeps using v1 to show a key. Key imports and a tag filter wait until a client needs them. Managers added by email alone are part of the consumer since question 18: they are developer portal users, not the Zuplo users that account members (question 5) are.

Alternative

Retrievable keys in phase 1 (GET …/api-keys/{apiKeyId}/secret for the bucket's consumers too), so the developer portal can move to v4: imports and tags still wait, and a second exception to V4-DATA-3.

Alternative

Full v1 parity before preview ends: retrievable keys, key imports and the tag filter. Nothing stays on v1, but three features without a v4 client yet.

Q17Personal keys issued by an admin

A user app admin must be able to issue a key for every person in the user app, and everyone must be able to see and create their own keys. Every key's secret was shown once, to whoever created it, so a person could not use a key an admin issued for them unless the admin handed the secret over.

Decided on 2026-10-26

A person's own consumers are theirs to list, create, change and delete, with their keys, and the user app's admins (people who can edit it) can do the same for anyone who can use it. An admin issues a key with one POST …/ai/user-apps/{userAppId}/users/{userId}/consumers per person, with include[]=apiKey (questions 22 and 28). There is no bulk create. The person reads a key's secret with GET …/api-keys/{apiKeyId}/secret, after finding the key in GET …/consumers/{consumerId}/api-keys. Nobody else can read this singleton, admins included. It is the one exception to V4-DATA-3: the keys of app and bucket consumers still show their secret once.

Alternative

The secret of a consumer's current key at GET …/consumers/{consumerId}/secret as well (decided 2026-10-26, then dropped 2026-11-03). A consumer can have several keys during a roll's grace period, so the secret belongs to a key: the person reads the one they mean at its own URL.

Alternative

Keep "shown once" for every key, and have the admin hand each secret to its owner, for example through device management. People could never see their own key.

Q18Consumer managers

A consumer's managers are the people who see its keys in your developer portal. They sign in to the developer portal, so their sub comes from the portal's identity provider, not from Zuplo's, and they are not account members. The Zuplo Portal adds managers by email alone, and by default the developer portal shows a consumer's keys to anyone who signs in with a manager's email. In v1 a manager has an ID (eusr_…) and may have no sub yet.

v4 keys a manager by userId, through the shared UserId schema (V4-DATA-5). A manager without a sub cannot be listed, read or removed, and userId reads like an account member's.

Decided on 2026-11-05

Managers are part of the consumer: managers: [{ email, sub }] on Consumer, set on create and replaced as a whole with a PATCH ([] removes them all), as v1's create body already allows. email identifies a manager, and their sub is null until they first sign in to the developer portal. A consumer's managers are only read and changed with it, so they have no URLs of their own, and the …/consumers/{consumerId}/managers endpoints are gone. The list filter is ?managerEmail=.

  • Only the bucket's own consumers have managers (question 13): managers on a consumer linked to an app, or on a person's consumer, is a 400, and linking a consumer that has managers to an app is a 409.
  • The sub of a developer portal user has its own schema, DeveloperPortalUserSub, so UserId keeps meaning a Zuplo user, and V4-DATA-5 and its lint name the exception.
Alternative

Keep managers as an association, keyed by email (…/consumers/{consumerId}/managers/{email}). Every manager fits, but an email in the URL ends up in logs, and the key has to be compared case-insensitively.

Alternative

Keep managers keyed by sub (in the contract until 2026-11-05), and add manager invitations for people without one. Two resources for one list of people.

Q19Consumer metadata

A consumer's metadata is what the API key authentication policy passes to the gateway as request.user.data. In v1 it is any JSON object of up to 3 KB, read by the customer's own policies and handlers. v4 types it as a map of strings (V4-DATA-1), so a consumer whose metadata has numbers, booleans, arrays or nested objects cannot be returned as v4 describes it, and v4 cannot write it.

Decided on 2026-11-05

Any JSON value in metadata, as in v1, and a named exception to V4-DATA-1: it is the customer's own data, passed through to their own code as request.user.data, and Zuplo never reads it. The limit is v1's 3 KB (3,072 bytes as JSON; raised from 1 KB on 2026-11-06 so every v1 consumer can be written back through v4). The values are ConsumerMetadataValue, the one schema that accepts any JSON, and lint reports any other (V4-DATA-1).

Alternative

Strings, numbers and booleans as values. Still typed, and enough for flat metadata, but nested values cannot move to v4.

Alternative

Strings only, as the contract said until 2026-11-05. Every consumer with other values has to change, along with the code that reads them.

Q20Who manages app and bucket consumers

Every key belongs to a consumer, and every consumer to what holds it (question 13). The contract only says who manages a person's consumers: the person and the user app's admins (question 17). It does not say who can list, create, change or delete an app's consumers, the bucket's own consumers, or their keys.

Decided on 2026-11-05

Project editors only. Since question 25, an app's consumer is an ordinary consumer in the bucket, linked to the app. The people who can change the bucket (for a project's bucket, project admins, developers on a preview or development bucket, and account admins; for a custom bucket, account admins) list, create, change, link, unlink and delete every consumer in it, linked or not, and roll and revoke its keys. The members of a pool, its admins included, read the consumers linked to the pool's apps (include[]=consumers) and their masked keys, and cannot create, link, change, roll or delete them (403). A key follows its consumer. A person's consumers in a user app stay with the person and the user app's admins (question 17).

Alternative

A consumer follows what holds it, and pool admins also create consumers, link them to their pool's apps and manage the linked ones, but link only a consumer with no keys yet, so they cannot take over an existing credential. A pool admin could roll an app's key, at the cost of a rule about keys on every link.

Alternative

Pool admins link any unlinked consumer and then manage it. Simplest for them, but a pool admin could link a customer's consumer and roll it, taking over its credential.

Q21Everything about a person in a user app

A person's budget override was at …/ai/user-apps/{userAppId}/budget-overrides/{userId}, their usage at …/ai/user-apps/{userAppId}/usage?userId=…, and their consumers at …/ai/user-apps/{userAppId}/users/{userId}/consumers: three shapes for one person.

Decided on 2026-10-27

Everything about one person is under …/ai/user-apps/{userAppId}/users/{userId}, a read-only association of the person and the user app:

  • …/users/{userId}/consumers: their credentials,
  • …/users/{userId}/budget: their own limits, a singleton. GET returns limits: [] when the defaults apply, PUT sets them (user app admins only), and PUT with [] puts the person back on the defaults. There is no DELETE.
  • …/users/{userId}/usage: their usage, a singleton. It no longer defaults to the caller: people pass their own userId.

GET …/ai/user-apps/{userAppId}/users lists the people who can use the user app, each with their budget (read-only, set at …/users/{userId}/budget; since 2026-11-02 only with include[]=budget, question 28), and ?hasBudgetOverride=true replaces the list of budget overrides. It is also the list an admin goes through to issue everyone a key (question 17).

Alternative

…/users/{userId}/budget with GET, PUT and DELETE, and 404 when the person has no limits of their own: closer to the override it replaces, but it would be the one singleton that can be deleted.

Alternative

Keep GET …/budget-overrides as a list next to the singletons. The list needs no users resource, but it would be a nested list without items, which only associations and credentials may be.

Q22A consumer's first key

A consumer got its first key in the same request from initialApiKey in its body, with the key's description and expiresOn. v1 asks for it with a query flag, POST …/consumers?with-api-key=true.

Decided on 2026-10-28

withApiKey=true on any consumer create issues the consumer's first key, without a description or expiry, and returns it with its secret in apiKey. The body is the consumer alone, as in every other create. A key that needs a description or an expiry gets it with PATCH …/api-keys/{apiKeyId}. Since 2026-11-02 it is include[]=apiKey (question 28).

Alternative

initialApiKey in the body (until 2026-10-28): one request also sets the key's description and expiry, but the consumer's body holds a second resource.

Q23An app's consumers

An app's consumers were a paginated list at GET …/ai/apps/{appId}/consumers, so showing an app with its consumers took two requests.

Decided on 2026-10-28

The app returns its consumers, oldest first, in a read-only consumers array with the same schema as …/consumers/{consumerId}, on get and list. An app has a few, one per client or environment. …/ai/apps/{appId}/consumers only creates them, with a POST, and each is changed and deleted at …/consumers/{consumerId}. A person's consumers and the bucket's keep a list of their own. Since 2026-11-02 the array is null unless the request has include[]=consumers (question 28). Since question 25, the app's consumers are the consumers linked to it.

Alternative

Keep the list next to the array (until 2026-10-28): it pages, but it is a second way to read the same consumers.

Q24The grace period of a roll

A roll took previousKeyGracePeriodSeconds in the body, next to the new key's description and expiresOn.

Decided on 2026-10-28

previousKeyGracePeriodSeconds is a query parameter of POST …/consumers/{consumerId}/api-keys. The body is the new key alone: the grace period is about the keys it replaces. Like withApiKey (question 22), what a create does to other resources is in the query (bodies).

Alternative

In the body (until 2026-10-28): one place for every input, but the body mixes the new key with what happens to the old ones.

Q25Linking a consumer to an app

An app's consumers were created under the app (POST …/ai/apps/{appId}/consumers with a description) and got a generated name. They were a kind of consumer of their own, next to the bucket's.

Decided on 2026-11-02

An app's consumer is a normal consumer in the bucket, linked to the app by its ID (decided 2026-10-28). Create it with POST …/consumers (include[]=apiKey for its first key), then link it with a POST of { "consumerId": "…" } to …/ai/apps/{appId}/consumers (201 when linked, 200 when it already was). DELETE …/ai/apps/{appId}/consumers/{consumerId} unlinks it, and GET on the same URL reads the link. This is how a pool member is added (question 12), so every association in v4 is added the same way, and the body says what is linked (Demo Day and #p-api-v4 on 2026-10-29 and 2026-10-30, recording).

  • A consumer is linked to at most one app: linking one that another app holds, or a person's consumer, fails with 409.
  • Unlinking a consumer, or deleting the app, leaves the consumer and its keys in the bucket. Their keys no longer call the app. Delete the consumer to revoke them.
  • An app's consumers are in the bucket's consumer list, with a name the client chooses, unique in the bucket.

The link is an association whose key is the consumer's ID, the one association item for a resource that has its own URL (associations).

Alternative

PUT …/ai/apps/{appId}/consumers/{consumerId} with the body {} (2026-10-28 until 2026-11-02): the client knows both IDs, but it was the one association not added with a POST, and its body was the one object without properties.

Alternative

A link operation, POST …/ai/apps/{appId}/consumers/link: says what it does, but v4 has no action verbs (question 30).

Q26Accounts and projects by ID

Accounts were addressed by name (/v4/accounts/{accountName}), the one exception to "everything is addressed by ID", and buckets named their project by projectName. Raised at Demo Day on 2026-10-29 (recording): accounts and projects already have IDs.

Decided on 2026-11-02

Accounts and projects are addressed by ID, {accountId} (acct_…) and {projectId} (proj_…), and responses carry accountId and projectId. A name can change or be reused, and an ID cannot. /v4/accounts lists the accounts the caller can use, with a name filter to find an account's ID, and /v4/accounts/{accountId} reads one. Projects stay in their account (/v4/accounts/{accountId}/projects/{projectId}); since question 34 they are listed and created there and read at /v4/projects/{projectId}. Since question 31, a project is also a container, like a bucket: its members, environments and deployments are under /v4/projects/{projectId}. Since question 33 the IDs are the ones tenant-api mints: an account ID is a UUID, and a project ID is prj_….

Alternative

Keep {accountName}: prettier URLs, but the one identifier that is not an ID.

Alternative

Make projects a container (/v4/projects/{projectId}/…), as buckets are: shorter URLs for what is in a project, but v4 has little in a project yet besides its members, and they are an association like a pool's.

Q27Project access

v4 had no way to give someone access to a project: only a pool grant did, as a side effect (API v4 discussion on 2026-10-29, recording).

Decided on 2026-11-02

Project members are an association on the project, written like pool members: a POST with { userId, role } adds the person or changes their role, and DELETE …/members/{userId} removes it. The roles are the project roles of today's RBAC, admin, developer, member and, on AI Gateway projects, ai-user, which gives personal keys and nothing else. Like pool members, it takes an account member. Someone who is not in the account yet is added to it first, and their userId works on the project right away (question 5). They were at /v4/accounts/{accountId}/projects/{projectId}/members; since question 31 they are at /v4/projects/{projectId}/members.

Q28One way to ask for more in a response

A consumer's first key came with withApiKey=true, the one flag EXP-1 allowed, and an app's consumers and each person's budget were always in the response. Nate, reviewing on 2026-10-29: a flag per extra means a new query parameter for each one; Stripe's include[] is one parameter for all of them.

Decided on 2026-11-02

include[] is the one query parameter that adds to a response (V4-COLL-2). Its values are the names of response properties, as an enum, and each one is always present and null unless it was asked for, so responses keep one shape. On a create, asking for something the new resource does not have yet creates it.

  • include[]=apiKey on a consumer create issues its first key (was withApiKey).
  • include[]=consumers on an app returns its linked consumers (were always there, question 23).
  • include[]=budget on the people in a user app returns each person's limits (were always there, question 21).

There are no include*, with* or expand[] flags. Lint checks that every value is a nullable property of every success response.

Alternative

Keep with{Resource} on creates and always return small related data: no parameter to learn for the common case, but every new extra is a new flag, and every read pays for lookups most clients do not need.

Alternative

Stripe-style expand[] on …Id references, as EXP-1 reserved: it replaces an ID with the object, but it cannot add a property that has no ID, such as a new key.

Q29A provider's models

Superseded by question 36: a provider's models are listed at …/ai/providers/{providerId}/models, and a provider assignment selects them.

A built-in provider served every model the catalog knows for its type, listed read-only in catalogModels, and models held only custom ones. Whether a client sends the catalog models or gets them by default was open since Demo Day on 2026-10-29 (recording).

Decided on 2026-11-02

The client sends them. GET /v4/buckets/{bucketId}/ai/catalog-models?providerType=… lists the catalog: each model of a provider type, with its kind, status and prices. Creating a provider requires models, the models it serves: catalog models by name (their prices come from the catalog, and pricing overrides them), and any other model with its own kind and pricing. The provider's models list both, with a source (catalog or custom), and only they are routed to it. catalogModels is gone. A PATCH replaces models as a whole.

Alternative

Default to the whole catalog: one less field, but a new catalog model is routable on every gateway the day Zuplo adds it, and nobody chose it.

Q30Operations that are not CRUD

In #p-api-v4 on 2026-10-29 and 2026-10-30, Nate proposed a link operation (…/consumers/link), and Josh argued that a real API needs some operations, such as resending an invitation email, and that Google's guidelines have $operations for them. Josh and Moritz agreed to be as resource-oriented as makes sense and to discuss the cases that are not obvious.

Decided on 2026-11-02

v4 stays resource-oriented: no URL ends in a verb (no actions). An operation is a create, a field change or a delete of a resource the client can name:

  • Resending an invitation is adding the member again: an invited member gets a new email, with a new link and expiry (question 5).
  • Linking a consumer to an app is adding it to the app's consumers (question 25).
  • Rolling a key is creating its replacement (question 24).
  • Cancelling or confirming something is a PATCH of its status, or creating a cancellation or confirmation when it has data of its own.

An operation that fits none of these comes here as an open question before it gets a URL. The lint rule that reports a verb as a collection that is not a plural noun stays as it is.

Alternative

Allow operations as bare kebab-case verbs (POST …/{id}/resend, shared URL-3) where CRUD does not fit, decided case by case: shorter to explain for things like restarting a deployment, but every verb is a word clients, SDKs and MCP tools have to learn, and the line between "fits" and "does not fit" moves with each review.

Q31Environments and deployments

v1 calls an environment a "deployment": /v1/accounts/{accountName}/projects/{projectName}/deployments lists one per Git branch, by its deploymentName, and each is redeployed in place. Its builds are a history without a public URL, and one build has several IDs: a buildId, and the statusId the CLI polls at …/deployment-status/{statusId}. Uploading source (POST /v1/deployments/source-url), redeploying (POST /v1/deployments/{deploymentName}/deploy) and deleting each answer with something different, and only an upload gives a client something to poll.

Decided on 2026-11-05

A project is a container, like a bucket: it is listed, created and read in its account (/v4/accounts/{accountId}/projects/{projectId}; since question 34, read at the root), and what it deploys, and who works on it, is under /v4/projects/{projectId}. That is the alternative question 26 left for when a project holds more than its members. Project members move there too (/v4/projects/{projectId}/members): everything in a container is under its URL, never under its parent's. A person's role on the container is an association directly in it, keyed by their userId, the way a pool member is under the pool.

In the project, two resources with an ID:

  • Environments (envt_…, at /v4/projects/{projectId}/environments/{environmentId}): the production environment of the default branch, a preview environment for each other branch, or a person's working copy. A POST with { branch } creates a branch's environment before its first deployment, as the first push to the branch does, and a DELETE removes a preview environment. Its name, today's deploymentName, is shown but is not an identifier.
  • Deployments (dpl_…, at /v4/projects/{projectId}/deployments/{deploymentId}): one build rolled out to one environment, the first create-then-poll resource in v4 (methods). environmentId is a property and a filter, and the list is newest first. One ID replaces the buildId and the statusId.

Since question 33 both IDs are tenant-api's UUIDs: an environment's is its Deployment.id, a deployment's its DeploymentVersion.id.

There are no actions (question 30). A POST to …/deployments deploys a source: an upload, whose response carries a signed upload URL once, or an earlier deployment of the same environment, which redeploys or rolls back (rebuild: true builds its source again). Git pushes and AI Gateway changes create deployments themselves. An environment serves its liveDeploymentId, the last deployment that succeeded.

Not in this proposal: build and request logs (append-only, so cursor pagination), environment variables, custom domains, cancelling a deployment, changing a project's default branch, and self-hosted environments.

Alternative

Environments and deployments in the account (/v4/accounts/{accountId}/environments?projectId=…), with the project as a property and a filter, as an app has its pool (first draft of this question). Projects and their members stay where they are, but every environment of every project is one list, and a project's environments are one filter away instead of under the project.

Alternative

Nest them in the account's project (/v4/accounts/{accountId}/projects/{projectId}/environments/{environmentId}): reads as "under the project" without a new container, but it is one level of nesting more than any other resource with an ID, and only associations and credentials are nested (V4-URL-4).

Alternative

Keep v1's meaning: one "deployment" per branch, redeployed in place. One resource fewer, but the builds and the status of a rollout have no URL, and the word means the environment here and one rollout almost everywhere else.

Alternative

Nest deployments under their environment (/v4/projects/{projectId}/environments/{environmentId}/deployments): reads like a history, but a deployment has an ID of its own, so it is addressed in its container, with its environment as a filter.

Q32The gateway's budget

The gateway's budget was the gateway's budgetRules, changed with PATCH /v4/buckets/{bucketId}/ai/gateway and { "budgetRules": […] }. It was the gateway's only writable field, so the object around it held nothing else. The API review on 2026-10-28 (recording) put budget rules at the bucket level, not on a gateway resource.

Decided on 2026-11-03

The gateway's budget is a singleton of its own, /v4/buckets/{bucketId}/ai/budget-rules, and its body is the list of rules itself: GET returns the array, and PUT replaces it with the complete array ([] removes the budget, 201 when the gateway had none and now has one, 200 otherwise). ai/budget-rules is a registered singleton in the ai namespace, not a collection: the rules have no IDs and are always written together, so the list is not paginated. Pools and the metering policy keep budgetRules as a property, next to their other fields. GET …/ai/gateway kept the gateway's url and status until 2026-11-05, when question 4 moved both to the production environment and its deployments. Nothing was left in it, so …/ai/gateway is removed.

Alternative

Keep GET/PATCH …/ai/gateway with budgetRules (decided 2026-10-15, then replaced): one object that later gateway-wide settings could join, but a PATCH whose only field is a list that is replaced as a whole anyway.

Alternative

A collection of rules with IDs (…/ai/budget-rules/{budgetRuleId}): one rule could change on its own, but the rules are set together, as on the portal's Usage Limits page, and budget rules have no ID anywhere else in v4.

Alternative

A …/ai/budget singleton, { "budgetRules": […] }, set with a PUT like a person's …/users/{userId}/budget: an object body can gain fields later, but it wraps a list in an object that holds nothing else.

Q33IDs as the backend mints them

v4 showed IDs with prefixes of its own: acct_… for accounts, proj_… for projects, usr_… for people, envt_… for environments, dpl_… for deployments and prov_… for AI providers. None of them exists in the backend. tenant-api mints prj_… for projects and user_… for its user records, and an account's id is a UUID. Question 1 had already kept gateway-service's config_ for the same reason.

Decided on 2026-11-05

Every ID in v4 is the one the service that owns the resource mints, unchanged. v4 never adds, renames or strips a prefix (identifiers):

  • An account ID is tenant-api's UUID (5f0c7c2e-…).
  • A project ID is prj_…, and a user ID is user_…, both as tenant-api mints them.
  • Environments and deployments have tenant-api's UUIDs: an environment's is its Deployment.id, a deployment's its DeploymentVersion.id (today's buildId). An AI provider assignment's ID is tenant-api's aip_…. An AI provider's ID is a built-in provider's gateway-service name (BedrockRuntime) or a custom provider's aiprov_… (question 36).
  • Buckets (bckt_…), consumers (csmr_…), API keys (key_…, secrets zpka_…) and pools, apps and user apps (config_…) keep their prefixes.

IDs stay opaque strings that clients never parse, so a service that starts prefixing its IDs later changes nothing in the contract. This contradicts the shared DATA-3, which asks for prefixed IDs.

Alternative

Keep v4's own prefixes: every ID says what it is, but the services would have to mint them, or v4 would have to translate every ID in both directions.

Alternative

Drop only dpl_, envt_ and usr_, the ones with no counterpart at all: keeps acct_, proj_ and prov_, which differ from what the backend mints too.

Alternative

No prefixes anywhere, the backend's own included: one format for every ID, but v4 would strip bckt_, csmr_, key_ and config_ from IDs that the services, their logs and today's API show with them.

Q34Projects and buckets at the root

A project and a bucket were listed, created and read in their account (/v4/accounts/{accountId}/projects/{projectId}, /v4/accounts/{accountId}/buckets/{bucketId}), while everything in them was already at /v4/projects/{projectId} and /v4/buckets/{bucketId}. So a container had two URLs that started with its ID: one to read it, and one for what is in it.

Decided on 2026-11-06

Anything addressed by its own ID belongs at the root unless there is a reason to nest it (design review, Moritz and Nick). A project or a bucket is read, changed and deleted at the root by its ID, /v4/projects/{projectId} and /v4/buckets/{bucketId}, the URL everything in it already starts with. Two things keep the account:

  • Creating, because the account owns the new resource: POST/v4/accounts/{accountId}/projects.
  • Listing, because a sign-in's bearer token spans every account the person can use, so a list without the account would cross accounts: GET/v4/accounts/{accountId}/buckets.

A project or bucket the caller cannot see, one in another account than an account-scoped API key's included, is a 404, as if it did not exist. Accounts stay at /v4/accounts/{accountId}. The operationIds do not change.

Alternative

Keep them in the account: a create and the reads after it share one parent (question 11), and a URL in a log names the account. But the account is one segment the ID does not need, and it is the only nested item URL of a resource with an ID.

Q35When an AI Gateway serves a change

The production environment of an AI Gateway reported live once its newest deployment succeeded, and v4 told clients to wait for it (questions 4 and 7). Nothing reliably says that an AI Gateway serves its newest configuration: the portal's flag only looks at the environment, and the only real check is a request to the gateway. An update never takes the gateway offline, but an edge may serve the previous version for a while.

Decided on 2026-11-06

An AI Gateway environment's status is null, and the reason is documented on the property. API Gateway environments keep provisioning / deploying / live / deleting. A client that waits for a new gateway or a provider change reads the environment's newest deployment (GET/v4/projects/{projectId}/deployments, environmentId, newest first) until it has succeeded, and decides from there. A succeeded deployment has rolled out, but does not guarantee every edge serves it yet.

This differs from API Gateway environments, which still report live, so the two kinds of environment do not behave the same.

Alternative

Keep live for AI Gateway environments: one behavior for every environment, but the status claims something nothing checks.

Alternative

An AI Gateway status without live (provisioning, deploying, deployed, deleting): keeps a status to read, but a second set of values for the same property, and deploying and deployed only repeat the newest deployment.

Q36Providers and provider assignments

v4 merged two things into /v4/buckets/{bucketId}/ai/providers, which took a write-only apiKey. Moritz and Nick found on 2026-11-06 that the backend keeps them apart. An AI provider is a catalog entry: one of Zuplo's built-in providers (gateway-service names it, BedrockRuntime) or a custom provider definition (aiprov_…). It has no key. An AI provider assignment (tenant-api, aip_…) is a project's choice to use a provider: a routing label, the selected models and the name of the environment variable that holds the key. Environment variables belong to a project, so an assignment cannot be reused across projects. gateway-service also stores negotiated prices per assignment and model.

Decided on 2026-11-06

Model both, in the bucket's ai namespace (Moritz). An AI Gateway keeps its data in its project's production bucket, so the bucket determines the project, and that bucket is not shared anyway.

  • GET /v4/buckets/{bucketId}/ai/providers lists the built-in providers and the bucket's custom ones. POST defines a custom provider, with its models and without a key. Built-in providers are read-only: PATCH and DELETE answer 409 (methods).
  • GET …/ai/providers/{providerId}/models lists a provider's models with their list prices, and replaces …/ai/catalog-models.
  • …/ai/provider-assignments holds { providerId, label, models, apiKeyEnvironmentVariable }, where each model can carry negotiatedPricing. The variable is read from the bucket's project when the gateway calls the provider; a missing variable fails requests, not the write.
  • Clients select a model in requests as <label>/<model>. Policies, fallbacks and load balancing name it by the assignment's ID instead, <assignmentId>/<model>, so a label can be renamed (Moritz, 2026-11-08) without rewriting them. Today's runtime resolves stored references by label and tenant-api rejects a label change, so both have to change with this. Budget rules name no provider: cost budgets use the assignment's negotiated price where it has one.
  • Deleting an assignment, or removing one of its models, fails with 409 while a policy still names one of its models, as deleting a provider did before. tenant-api does not check this yet.
Alternative

Keep one resource and translate: a POST …/ai/providers with an apiKey creates a custom provider (if needed), an environment variable and an assignment. Fewer calls, but v4 would write secrets that tenant-api keeps elsewhere, and the provider and its id would mean two things.

Reviewed on 2026-11-08: …/models/{modelId} stays (V4-URL-4 nests only collections that have an item URL), a built-in provider's ID is gateway-service's PascalCase name, and the key is read from the production environment.

Changed on 2026-11-09 (Moritz): policies keep naming a model <label>/<model> for now, as requests, the runtime and the v1 portal do, instead of <assignmentId>/<model>. A label cannot be changed until something rewrites the policies that name it, so a label change answers 501 (plan items T7 and R1).

Observed while building this

Request validation ignores format

Zuplo's request validation ignores format (uri, date-time). The gateway checks types, lengths and patterns, and the service must check formats.

Request validation cannot build propertyNames

A request schema with propertyNames fails the gateway build ("Cannot assign to key1 because it is a constant"). Maps in requests bound their size with maxProperties, and the service checks their keys.

Last modified on