Zuplo API v4
AI Gateway

Mapping from api.zuplo.com

What the portal and Agent Z call today (management-api → gateway-service and tenant-api), and the v4 operation that replaces each call. This is the work list for the backend, and for moving the portal onto v4.

Accounts and projects by ID

Today's URLs name the account and the project (/v1/accounts/{accountName}/projects/{projectName}). v4 addresses both by ID: {accountId} and {projectId} (prj_…). Accounts have an ID today (a UUID, the id in /v1/accounts), and v4 shows it unchanged. Every ID in v4 is the one the service mints, with its prefix or without one (question 33): projects keep tenant-api's prj_, and pools, apps and user apps keep gateway-service's config_ (question 1). Responses carry accountId and projectId instead of accountName and projectName, and buckets are filtered by projectId.

Todayv4
GET /v1/accountsGET /v4/accounts (?name=… finds one by name)
GET /v1/accounts/{accountName}/projects/{projectName}GET /v4/projects/{projectId}

Configurations → pools, apps, user apps

Todayv4
GET /v1/buckets/{bucketId}/configurations (root), PATCH …/configurations/{rootId}GET/PUT /v4/buckets/{bucketId}/ai/budget-rules (the rules, as the body)
GET /v1/buckets/{bucketId}/configurations/teamsGET /v4/buckets/{bucketId}/ai/pools
GET /v1/buckets/{bucketId}/configurations/appsGET /v4/buckets/{bucketId}/ai/apps (?poolId= for one pool)
GET /v1/buckets/{bucketId}/configurations/user-appsGET /v4/buckets/{bucketId}/ai/user-apps
POST /v1/buckets/{bucketId}/configurations with metadata.typePOST /v4/buckets/{bucketId}/ai/pools, POST /v4/buckets/{bucketId}/ai/apps (the user app is created with the gateway)
GET/PATCH/DELETE /v1/buckets/{bucketId}/configurations/{configurationId}/v4/buckets/{bucketId}/ai/pools/{poolId}, /v4/buckets/{bucketId}/ai/apps/{appId}, GET/PATCH /v4/buckets/{bucketId}/ai/user-apps/{userAppId} (never deleted)
GET /v1/buckets/{bucketId}/configurations/{configurationId}/usageGET /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/usage (per person); app and pool usage are phase 2

Payload changes:

Today (metadata)v4
labelname
parentIdparentPoolId (pools), poolId (apps)
inboundPolicyChain[] { name, options, enabled } of apps and user appspolicies[] { type, enabled, …options }, in the same order. type is the entry's policy type without ai-gateway- (ai-gateway-metering → metering, internal-only); any other declared policy is { type: "custom", name }
metering options.budgetRules[].meters[], throwOnFailuremetering budgetRules[].limits[], failOnError
model filtering and model override: one of allowList/blockList, default/force per capabilityboth fields, the unused one null
semantic cache expirationSecondsTtlsemantic-cache ttlSeconds
smart router classifier.app, smartRoutingEnabled, intents[].idsmart-router classifier, routingEnabled, intents[].name
DLP partialMask.keepLastdlp maskKeepLast
Akamai firewall api-keyakamai-firewall apiKey
classifierPrompt as an array of linesone string
credentials in options (apiKey, classifier.app.apiKey)write-only, never returned; omitted on a write, the stored one is kept
deprecated options (metering limits, smart router classifierAppID, classifierModel, classifierAppApiKey, classifier.chatCompletions, classifier.jev, adapter), semantic cache onUnknownShape, DLP enginenot exposed
metadata.budgetRules on poolsAiPool.budgetRules
the pool's inboundPolicyChain (its template), each entry's permissions.{canEdit,canRemove}, inheritPolicyTemplatepolicyTemplate[] { policy, canEdit, canRemove }, inheritPolicyTemplate
personal budget rule (budgetBy: expression on request.user.sub)unchanged, in the user app's metering policy
the root configuration's metadata.budgetRulesthe body of …/ai/budget-rules (AiBudgetRules)
consumer.apiKeys[] inline, with plaintext keythe app's consumers (consumers in the bucket, linked with POST /v4/buckets/{bucketId}/ai/apps/{appId}/consumers { consumerId }), and their keys (masked), secret only on create
type, version, deploymentName, $schema, bucketId inside metadata, children, deletednot exposed
createdOn, updatedOnunchanged

Members and budgets

Todayv4
GET /v1/buckets/{bucketId}/configurations/{configurationId}/membersGET /v4/buckets/{bucketId}/ai/pools/{poolId}/members
POST …/members { sub, role }POST /v4/buckets/{bucketId}/ai/pools/{poolId}/members { userId, role }, which also changes an existing member's role
GET/PUT/PATCH/DELETE …/members/{sub}GET/DELETE /v4/buckets/{bucketId}/ai/pools/{poolId}/members/{userId}
GET /v1/auth/members?entity=ai-team&entityId={poolId}GET /v4/buckets/{bucketId}/ai/pools/{poolId}/members
POST /v1/auth/members { user, entity: "ai-team", entityId, role } for an account memberPOST /v4/buckets/{bucketId}/ai/pools/{poolId}/members { userId, role }
PUT /v1/auth/members { user, entity: "ai-team", entityId, role }POST /v4/buckets/{bucketId}/ai/pools/{poolId}/members { userId, role }, or DELETE …/members/{userId} for role: null
GET/PUT /v1/buckets/{bucketId}/configurations/{configurationId}/budget-overrides/{sub}GET/PUT /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/budget (PUT { limits } sets or replaces)
DELETE …/budget-overrides/{sub}PUT /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/budget { "limits": [] }
none (no v1 route lists a user app's overrides)GET /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users?hasBudgetOverride=true&include[]=budget, each person with their budget

People are identified by their Zuplo user ID (user_…) in a field named userId (V4-DATA-5), not by their sub as today. GET /v4/accounts/{accountId}/members?email= (or ?sub=) finds someone's userId. /v1/auth/members takes an email or a sub as user and decides itself whether to add the person or invite them. In v4, someone new is added to the account by email first, which returns their userId at once, and pools and projects take that userId in the next request (next section).

Account members and invitations

The portal invites people with /v1/auth/members and /v1/auth/invites (unified members API), and resends, revokes and accepts invitations with the account-member-invites routes. Both write the same AccountMemberInvite rows. v4 has no invitation resource: adding someone to the account by email creates a member with status: invited and a userId right away (question 5).

Todayv4
POST /v1/auth/members { user: email, entity: "account", entityId, role }POST /v4/accounts/{accountId}/members { email, role }
POST /v1/auth/members { user: email, entity: "ai-team", entityId: poolId, role } for someone not in the accountPOST /v4/accounts/{accountId}/members { email }, then POST /v4/buckets/{bucketId}/ai/pools/{poolId}/members { userId, role } with the userId it returned
POST /v1/auth/members { user: sub, entity: "project", entityId, role } for an account memberPOST /v4/projects/{projectId}/members { userId, role }
POST /v1/auth/members { user: email, entity: "project", … } for someone not in the accountPOST /v4/accounts/{accountId}/members { email }, then POST /v4/projects/{projectId}/members { userId, role }, ai-user included
POST /v1/accounts/{accountName}/account-member-invites { userEmail, metadata }POST /v4/accounts/{accountId}/members { email, role }
GET /v1/auth/invites?entity=account&entityId={accountName}, GET /v1/accounts/{accountName}/account-member-invitesGET /v4/accounts/{accountId}/members: invited people are members with status: invited
GET /v1/auth/invites?entity=ai-team&entityId={poolId}GET /v4/buckets/{bucketId}/ai/pools/{poolId}/members: invited people are pool members already
PATCH /v1/accounts/{accountName}/account-member-invites/{inviteId}/$resendPOST /v4/accounts/{accountId}/members with the same email
DELETE /v1/accounts/{accountName}/account-member-invites/{inviteId}DELETE /v4/accounts/{accountId}/members/{userId}
GET/POST /v1/account-member-invites/{code} (the invitee reads and accepts)not in v4: accepting stays in the portal

The behavior is the same in both, except where the last column says what has to change in tenant-api before v4 can serve it:

BehaviorToday (tenant-api)v4
How a person is identifiedTheir sub, which exists only after their first sign-inA Zuplo user ID (user_…), assigned when they are added. tenant-api keeps a ledger of pending grants keyed by email and writes them at the first sign-in, or keys users by an internal ID and maps the sub to it then. The contract is the same either way.
Grants for someone who has not signed inOne invitation per grant (metadata.account, .project, .team), so one project or one poolAny number, on pools and projects, by userId. They take effect at the first sign-in.
Who adds peopleAccount admins, also when a project or pool add needs a new account memberAccount admins
Who listsAccount members (/v1/auth/invites). The legacy list needs an admin.Account members, invited and active in one list
Someone who uses Zuplo but is not in the accountInvited, never added directlyAdded as invited, active once they accept
SSO accountsCannot add anyone without an invitationThe first SSO sign-in makes an invited member active, with nothing to accept
An account memberentity: "account": 409. A project or pool: added directly, no invitation.200: their role is changed
Default account rolemember with RBAC, admin without. /v1/auth/members with entity: "account" requires role.member with RBAC, admin without
Adding the same address againRefused with 400 while the first invitation is unexpired. After it expires, a second row is created next to it.Changes the role, and an invited member gets a new link and invitationExpiresOn
Sending it again$resend: same ID and link, the seven days restartAdd the member again
Upper and lower case in the emailIgnored when looking up the person (User.email is citext) and when accepting, but not by the check for an earlier invitation, so [email protected] and [email protected] can each get oneIgnored everywhere
ExpirySeven days after the email is handed off; $resend restarts themSeven days (invitationExpiresOn); adding them again restarts them
Accepting after expiryThe accept route does not check expiration. Only the portal's invite page refuses an expired link.The link stops working
Expired invitationsHidden by /v1/auth/invites, but kept until revoked or acceptedThe member stays invited, with a past invitationExpiresOn, until removed or added again
List orderNewest firstOldest first, the default order
SeatsPending invitations hold none. Checked when inviting and again when accepting.Same: invited members hold none
Pool deleted before they acceptAccepting fails after the account and project roles are written. The invitation stays, so every retry fails the same way.The pool membership goes with the pool; they still become active
Removing someone who has not acceptedRevoke the invitationDELETE …/members/{userId}, which also removes their pool and project roles
Status codesNon-admin role without RBAC: 403 (/v1/auth/members), 400 (legacy). Member limit: 403 (/v1/auth/members), 400 (legacy and accept).400 for a role the plan does not allow, 409 for the member limit
Response200 { email, role, result: "invited" }, without the invitation's ID201 with the member, its userId and its Location, or 200 for an existing member

Providers and models

Todayv4
Todayv4
------
GET /v1/buckets/{bucketId}/providersGET /v4/buckets/{bucketId}/ai/providers (built-in and custom)
POST /v1/buckets/{bucketId}/providers (custom provider)POST /v4/buckets/{bucketId}/ai/providers { name, baseUrl, models }
PATCH/DELETE /v1/buckets/{bucketId}/providers/{providerId}PATCH/DELETE /v4/buckets/{bucketId}/ai/providers/{providerId} (custom only; built-in is 409)
GET /v1/buckets/{bucketId}/providers/{providerId}/models, …/models/{modelId}GET /v4/buckets/{bucketId}/ai/providers/{providerId}/models, …/models/{modelId}
POST /v1/buckets/{bucketId}/providers/{providerId}/models, PATCH/DELETE …/models/{modelId}PATCH /v4/buckets/{bucketId}/ai/providers/{providerId} { models } (replaces a custom provider's models)
GET /v1/buckets/{bucketId}/provider-assignments, GET /v1/accounts/{account}/projects/{project}/ai-provider-assignmentsGET /v4/buckets/{bucketId}/ai/provider-assignments
POST /v1/accounts/{account}/projects/{project}/ai-provider-assignmentsPOST /v4/buckets/{bucketId}/ai/provider-assignments { providerId, label, models, apiKeyEnvironmentVariable }
GET/PATCH/DELETE …/ai-provider-assignments/{assignmentId}GET/PATCH/DELETE /v4/buckets/{bucketId}/ai/provider-assignments/{assignmentId}
PATCH /v1/buckets/{bucketId}/provider-assignments/{assignmentId}/models/{modelId} (negotiated price)PATCH /v4/buckets/{bucketId}/ai/provider-assignments/{assignmentId} { models: [{ name, negotiatedPricing }] }

An assignment serves the models in its models and nothing else (decision 36). Requests and policies name a model <label>/<model>, as in v1, so a label cannot be changed yet: a change answers 501 until something rewrites the policies that name it. The bucket determines the project, so v4 finds the assignment's project from the bucket instead of the account and project names in the URL.

Payload changes: a provider's type is kebab-case (OpenAI → openai, BedrockMantle → bedrock-mantle, AzureAI → azure-ai), and its id stays the backend's (BedrockMantle, aiprov_…). An assignment's provider and customProviderName become one providerId, envVarName becomes apiKeyEnvironmentVariable, endpointUrl becomes baseUrl, and modelSelections ({ completions, embeddings }) becomes models, one list of names. metadata as a JSON string is gone. Model prices move from per token (inputCostPerToken) to per million tokens (pricing.inputPerMillionTokens), and an assignment's overriddenTokenRateCard becomes the model's negotiatedPricing. The dialect enum google_predict is not exposed.

API keys

Todayv4
GET /v1/api-keys/{bucketId}/consumers (app consumers)GET /v4/buckets/{bucketId}/ai/apps/{appId} (consumers)
POST /v1/api-keys/{bucketId}/consumers (app consumers)POST /v4/buckets/{bucketId}/consumers, then POST /v4/buckets/{bucketId}/ai/apps/{appId}/consumers { consumerId }
GET /v1/api-keys/{bucketId}/consumers/{consumerId}/keysGET /v4/buckets/{bucketId}/consumers/{consumerId}/api-keys
POST /v1/api-keys/{bucketId}/consumers/{consumerId}/roll-key { expiresInSeconds }POST /v4/buckets/{bucketId}/consumers/{consumerId}/api-keys?previousKeyGracePeriodSeconds=
GET/POST /v1/api-keys/{bucketId}/users/me/consumersGET/POST /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/consumers, with the caller's own userId
GET/PATCH/DELETE /v1/api-keys/{bucketId}/users/me/consumers/{consumerId}GET/PATCH/DELETE /v4/buckets/{bucketId}/consumers/{consumerId}
GET /v1/api-keys/{bucketId}/users/me/consumers/{consumerId} (the key in full)GET /v4/buckets/{bucketId}/consumers/{consumerId}/api-keys, then GET /v4/buckets/{bucketId}/api-keys/{apiKeyId}/secret (the person only)
GET …/users/me/consumers/{consumerId}/keys, DELETE …/keys/{keyId}GET /v4/buckets/{bucketId}/consumers/{consumerId}/api-keys, DELETE /v4/buckets/{bucketId}/api-keys/{apiKeyId}
POST …/users/me/consumers/{consumerId}/roll-keyPOST /v4/buckets/{bucketId}/consumers/{consumerId}/api-keys?previousKeyGracePeriodSeconds=
POST /v1/api-keys/{bucketId}/consumers { personalOwnerSub, configurationId } (admin)POST /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/consumers
GET /v1/api-keys/{bucketId}/consumers?ownerSub=… (admin)GET /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/consumers
GET /v1/api-keys/{bucketId}/users/me/usageGET /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/{userId}/usage, with the caller's own userId

A consumer is one credential, as in the key service: an app holds one for each client or environment, and a person one per device. v4 creates them under the app or the person, the app returns its consumers in consumers with include[]=consumers, and consumers and keys carry no appId, userAppId or ownerId. There is no roll endpoint: a POST to a consumer's keys rolls, which is the v1 roll-key behavior. Its other keys expire immediately, or after previousKeyGracePeriodSeconds (a query parameter). Creating an app no longer creates a key implicitly. The caller creates a consumer with include[]=apiKey and gets the key's secret once.

Consumers of any gateway

The public key-bucket API on dev.zuplo.com/v1 manages the consumers of API key authentication. Its key buckets are the v4 buckets, and its consumers and keys move into them.

Todayv4
GET/POST /v1/accounts/{accountName}/key-bucketsGET/POST /v4/accounts/{accountId}/buckets
GET …/key-buckets/{bucketName}GET /v4/buckets/{bucketId} (by ID)
GET/POST …/key-buckets/{bucketName}/consumersGET/POST /v4/buckets/{bucketId}/consumers (?name=… finds one by name)
GET/PATCH/DELETE …/consumers/{consumerName}GET/PATCH/DELETE /v4/buckets/{bucketId}/consumers/{consumerId} (by ID)
POST …/consumers?with-api-key=truePOST /v4/buckets/{bucketId}/consumers?include[]=apiKey
GET …/consumers/{consumerName}/keysGET /v4/buckets/{bucketId}/consumers/{consumerId}/api-keys
POST …/consumers/{consumerName}/keys (adds a key)none: a POST to …/consumers/{consumerId}/api-keys rolls (question 15)
GET/PATCH/DELETE …/consumers/{consumerName}/keys/{keyId}GET/PATCH/DELETE /v4/buckets/{bucketId}/api-keys/{apiKeyId}
POST …/consumers/{consumerName}/roll-key { expiresOn }POST /v4/buckets/{bucketId}/consumers/{consumerId}/api-keys?previousKeyGracePeriodSeconds=
GET/POST …/consumers/{consumerName}/managers { email, sub }the consumer's managers (GET /v4/buckets/{bucketId}/consumers/{consumerId}), set with PATCH …/consumers/{consumerId} { managers: [{ email }] }
DELETE …/consumers/{consumerName}/managers/{managerId}PATCH /v4/buckets/{bucketId}/consumers/{consumerId} with the other managers

Payload changes: a consumer's keys are not embedded (include-api-keys, apiKeys[]). List them at …/consumers/{consumerId}/api-keys. A consumer's managers are part of it, managers: [{ email, sub }], set on create and replaced as a whole with a PATCH (question 18). email identifies a manager, so managers added by email alone are in v4, with sub (from your developer portal's identity provider) null until they sign in. Managers have no eusr_… ID and no URLs of their own. metadata takes any JSON object of up to 3 KB (3,072 bytes), as in v1 (question 19). Keys are always masked (maskedKey), and the secret is returned once, when the key is created. keys/$bulk, a key value chosen by the client, tag.{name}=… filters and key-format=visible are not in phase 1, so the developer portal keeps using v1 to show a key (question 16), and neither are updating and deleting a bucket.

Environments and deployments

A v1 "deployment" is an environment: one per Git branch, plus a working copy per person, named by its deploymentName. v4 calls it an environment and gives it an ID. A v4 deployment is one build of an environment, today's deployment version (buildId), and it is created, then read until it is done (open question 31). Both are in the project, which v4 makes a container like a bucket: what it deploys, and its members, are under /v4/projects/{projectId}.

Todayv4
GET /v1/accounts/{accountName}/projects/{projectName}/deploymentsGET /v4/projects/{projectId}/environments
GET /v1/environments?accountName&projectName (self-hosted, without working copies)GET /v4/projects/{projectId}/environments, with working copies
GET /v1/deployments/{deploymentName}, GET /v1/environments/{environmentName}GET /v4/projects/{projectId}/environments/{environmentId} (…/environments?branch= finds a branch's)
DELETE /v1/deployments/{deploymentName}DELETE /v4/projects/{projectId}/environments/{environmentId} (preview only)
Created on the first deploy of a branchPOST /v4/projects/{projectId}/environments { branch }, or the first push
GET /v1/deployments/{deploymentName}/versionsGET /v4/projects/{projectId}/deployments?environmentId=…
GET /v1/deployments/{deploymentName}/versions/{buildId}GET /v4/projects/{projectId}/deployments/{deploymentId}
POST /v1/deployments/source-url { accountName, projectName, environment, sha }, PUT to uploadUrlPOST /v4/projects/{projectId}/deployments { environmentId, source: { type: "upload", commitSha } }, PUT to upload.url
GET /v1/accounts/{accountName}/projects/{projectName}/deployment-status/{statusId}GET /v4/projects/{projectId}/deployments/{deploymentId}
POST /v1/deployments/{deploymentName}/deploy, POST /v1/deployment-versions/{id}/redeploy { type: "artifact" } or { type: "rebuild" }POST /v4/projects/{projectId}/deployments { environmentId, source: { type: "deployment", deploymentId, rebuild } }

Payload changes:

Todayv4
name (deploymentName) as the identifierid, tenant-api's UUID for the environment (Deployment.id); name stays, shown only
branchName, label, urlParameterbranch, null for a working copy
environmentType PRODUCTION / PREVIEW / WORKING_COPY, or production / preview / developmenttype production / preview / working-copy
state (IS_STARTING, STARTED, ERRORED, DELETE_SCHEDULED, …), set by the last stage that reportedstatus provisioning / deploying / live / deleting, from the environment's deployments; null for an AI Gateway, whose deployments say where a change is
userSub (deployer-app@zuplo for every non-working copy)userId, the person of a working copy, null otherwise
isProtectedhasCustomDomain
deploymentVersion (the latest build, inline)liveDeploymentId; the builds are deployments
A build's id (buildId) and traceId (statusId)One deployment id, the build's id (DeploymentVersion.id, a UUID)
A build's SUCCESS / ERROR / IN_PROGRESS, or its stages' in-progress / success / failed / skippedstatus pending / running / succeeded / failed
sourceType (github, cli, …), sha, commitMessage, commitAuthor, commitTimestampsource.type (git, upload, deployment, zuplo), source.commit { sha, message, authorName, committedOn }
{ "status": "request enqueued" } on delete and redeploy204 for a delete; the new deployment for a redeploy
loggingId, environmentTarget, deploymentSecret, stages, build steps, pullRequest*not exposed

Where v1 and v4 deliberately differ

Both versions read and write the same rows, and each keeps its own behavior:

Behaviorv1 (unchanged)v4
Creating an appCreates a consumer named after the app, with a keyCreates neither: link a consumer to it
Deleting an appDeletes the app's consumers and their keysUnlinks its consumers, which stay in the bucket
Rolling a keyExpires the keys that have no expiryExpires every other key at the end of the grace period, in one transaction
Addressing a consumerBy nameBy ID
Deleting a provider assignment, or one of its models, that a policy usesAllowed409

During the preview

These answer 501 Not Implemented until the change in requiredChanges ships:

OperationWaits for
GET/POST /v4/accounts/{accountId}/members, GET …/members/{userId}account memberships with a status and a start, and people with a user ID before they sign in (tenant-api T1–T4, with the OpenFGA account_membership type)
POST /v4/projects/{projectId}/deployments with source.type: deployment, rebuild: false and a deployment that is not the environment's live onerolling an environment back to an earlier deployment's artifact: the deployer can only push the artifact the environment runs (deployer, T6). Send rebuild: true instead.
POST/PATCH /v4/buckets/{bucketId}/ai/provider-assignments… whose models include no model of a kind the provider offersan assignment that selects no model of a kind, where none means every model of that kind today (tenant-api, T8)
PATCH /v4/buckets/{bucketId}/ai/provider-assignments/{assignmentId} that changes labelrenaming a label, and the policies that name its models <label>/<model> with it (tenant-api, T7)

The plan item IDs in requiredChanges (T1, T7, …) can change while v4 is a preview.

Not in v4 phase 1

  • Analytics (/v2/analytics/ai-gateway…) and request logs: phase 2, once push vs pull is decided.
  • Audit logs (/v1/audit-logs/{bucketId}/…): phase 2. They are append-only, so they use cursor pagination.
  • …/configurations/{configurationId}/resolved and user-app/ensure: internal.
  • Build logs (/v1/deployments/{deploymentName}/build-logs/{buildId}) and request logs: phase 2. They are append-only, so they use cursor pagination.
  • Environment variables (…/environment-variables, …/branches/{branchName}/variables), custom domains and changing a project's default branch ($update-default-branch).
  • Self-hosted environments (POST /v1/environments) and the configuration a self-hosted runtime reads (…/configurations, …/local-configurations).
Last modified on