Zuplo API v4
Designing Resources

Naming and Data

One casing per context, one ID format, one timestamp format, and a type for every value. A v4 payload looks the same whichever product it comes from.

Casing

Path segments
user-apps
kebab-caseIncorrect: userAppsIncorrect: user_apps
Path parameters
{apiKeyId}
camelCase + IdIncorrect: {api_key_id}Incorrect: {key}
Query parameters · NAME-4
parentPoolId
camelCaseIncorrect: parent_pool_id
JSON properties · NAME-1
baseUrl
camelCaseIncorrect: baseURLIncorrect: base_url

Acronyms are words: apiKey, id.

Enum values · NAME-3
azure-ai
kebab-caseIncorrect: AZURE_AIIncorrect: azureAi
operationIds
list-ai-pools
kebab-caseIncorrect: listAiPools
Schema names · V4-NAME-1
CreateAiPoolRequest
PascalCaseIncorrect: CreateAIPoolRequestIncorrect: create_ai_pool_request

Acronyms are words here too: Ai, Api.

V4-NAME-1

Schema, parameter and response names are PascalCase, and an acronym is written as a word, whatever its length: AiPool, ApiKey, AiGatewaySettings.

Lintv4-schema-names

Component names become type names in SDKs, MCP tool schemas and Terraform. One spelling means nobody has to remember whether a two-letter acronym is capitalized, and a name reads the same as the property it types (apiKey is an ApiKey).

Do
Acronyms written as words
YAMLCode
AiSmartRouterPolicyInput: … ApiKeyWithSecret: …
Don't
Acronyms in capitals
YAMLCode
AISmartRouterPolicyInput: … APIKeyWithSecret: …

Header components are the exception: they are named after the header they define (RetryAfter, WWWAuthenticate).

Enums are extensible

New values can appear in responses, and clients must handle values they do not know. Every enum description says so.

Identifiers

Resources are identified by opaque IDs, exactly as the service that owns the resource mints them. v4 never adds, renames or strips a prefix, so an ID in v4 is the same string as in the service, its logs and its database. Most carry the service's prefix (prj_, bckt_, csmr_), and some have none: accounts are UUIDs, environments and deployments are plain strings, and a built-in AI provider is its name (Anthropic). IDs are strings, and clients never parse them: the URL and the …Id property say what an ID is. People have one too: their Zuplo user ID, user_… (V4-DATA-5). AI pools, apps and user apps share gateway-service's config_ (question 1, question 33).

no prefix
Account (a UUID)
5f0c7c2e-8d3a-4b1e-9f2a-6c1d0e7b3a49
user_
User
user_Jm5tR8wQz2KcX7vB4nLp9HdS
prj_
Project
prj_Hn4QzW7cVb2LpX9sTe5RgK1m
no prefix
Environment (a UUID)
4f8b6c1e-2d7a-4e3b-9c5f-8a1d2e6b7c90
no prefix
Deployment (a UUID)
a3c9e5d1-6b2f-4a8e-b7d4-1f0e9c8b2a65
bckt_
Bucket
bckt_Y40ypGODCDP3S7iZxNwCAbIe
no prefix
AI provider (built-in)
Anthropic
aiprov_
AI provider (custom)
aiprov_9fKq2LmX7tRz4VwB
aip_
AI provider assignment
aip_6eljoh
config_
AI pool, app or user app
config_Kx7gDiGsr1c2tOOUW5kpmXZn
csmr_
Consumer
csmr_brw0leTAVw478VFKD4F6OqVI
key_
API key (the record, not the secret)
key_XKrbtT8H72PH0gQpblaXfstB
zpka_
API key secret (the credential itself)
zpka_xxxx…xxxx
Do
id, and {noun}Id references
JSONCode
{ "id": "config_Kx7gDiGs…", "poolId": "config_IM1F8Z4r…" }
Don't
Misnamed or numeric IDs
JSONCode
{ "appId": 4182, "pool": "IM1F8Z4r…" }

People are their user ID

V4-DATA-5

People are always identified by their Zuplo user ID, user_…. A field or parameter that names a person (userId, ownerId, …UserId) references the shared UserId schema. A person's sub is a read-only property, never an identifier.

Lintv4-user-ids

The user ID is the id of the person's user record in tenant-api. Zuplo assigns it when a person is first added to an account, before they ever sign in, so a client can add them to pools and projects in the next request (question 5). The sub, the subject the identity provider assigned them (auth0|…, samlp|…), exists only after their first sign-in. Account members carry it as sub, null until then, and the members list filters by it (?sub=).

Do
The user ID, named for the person
JSONCode
{ "userId": "user_Jm5tR8wQz2KcX7vB4nLp9HdS", "ownerId": "user_Hb3Wq7nTz5LcY1xK8vRm2PsF" }
Do
The sub, read-only, on the member
JSONCode
{ "userId": "user_Jm5tR8wQz2KcX7vB4nLp9HdS", "sub": "auth0|65f1c2d9…", "status": "active" }
Don't
The sub as an identifier
Code
GET /v4/buckets/{bucketId}/ai/pools/{poolId}/members/auth0%7C65f1c2d9…

A person who has not signed in yet has no sub, so nothing could be granted to them until they did.

One exception: developer portal users

The managers of a consumer sign in to your developer portal, not to Zuplo, so they have no userId. A manager is their email, with their portal sub (DeveloperPortalUserSub) once they have signed in (question 18). A sub always references UserSub or DeveloperPortalUserSub, so it never reads like a Zuplo user's ID.

Timestamps

Every timestamp is an RFC 3339 UTC string with format: date-time (DATA-1), and its name ends in On (NAME-2). It also works the other way round: a property ending in On is always a timestamp.

Do
…On names, RFC 3339 in UTC
JSONCode
{ "createdOn": "2026-10-07T09:14:02.117Z", "expiresOn": null }
Don't
…At or …Time names, epochs
JSONCode
{ "createdAt": 1759828442, "expiryTime": "07/10/2026 11:14" }

Every response has the same shape

Every property of a response is always present (DATA-2). In the spec, every response property is required, and optional values are nullable (type: [string, "null"]).

Do
A missing value is null
JSONCode
{ "id": "key_XKrbtT8H…", "description": null, "expiresOn": null }
Don't
A missing value is omitted
JSONCode
{ "id": "key_XKrbtT8H…" }

Responses are open: new properties may be added at any time (non-breaking), and clients ignore properties they do not know.

Everything is typed

V4-DATA-1

No untyped JSON anywhere: types everywhere, typed maps, and discriminated unions.

Lintv4-typed-schemas

Today's AI Gateway "configuration" stores everything in an untyped metadata JSON object. It cannot be validated, documented, or turned into MCP tools or Terraform.

Do
A typed variant per kind
JSONCode
{ "policies": [ { "type": "model-override", "enabled": true, "completions": { "default": "openai1/gpt-4o-mini", "force": null }, "embeddings": null }, { "type": "metering", "enabled": true, "budgetRules": [{ "budgetBy": "app", … }], "failOnError": false } ] }

type says which policy it is, and the schema for that type says what its options are.

Don't
Internal format passthrough
JSONCode
{ "metadata": { "inboundPolicyChain": [ { "name": "ai-gateway-metering-inbound", "options": { … } } ] } }
Every schema declares a type

Every object declares its properties. additionalProperties: true and {} are never used, and neither is an object without properties: a request that has nothing to set has no body.

Maps have a typed value schema

A map (deploymentModels) declares the schema of its values and, in requests, maxProperties.

Unions have a discriminator

A oneOf/anyOf has a discriminator, so clients can tell the variants apart. There are two exceptions: the nullable reference oneOf: [$ref, {type: null}], and exactly one of several properties, a oneOf of required lists next to the properties it names (for example a model filter's allowList or blockList). There, the property that is set tells the variants apart.

No raw internals

An app's policies are a union discriminated by type, with typed options, not an inboundPolicyChain of raw policy names and untyped options.

One exception: consumer metadata

A consumer's metadata values are any JSON (ConsumerMetadataValue), as in v1. It is the customer's data, passed to their own code as request.user.data, and Zuplo never reads it. No other schema accepts any JSON value (question 19).

Do
A typed, bounded map
YAMLCode
deploymentModels: type: [object, "null"] maxProperties: 100 additionalProperties: type: string maxLength: 255
Don't
An open bag of anything
YAMLCode
metadata: type: object additionalProperties: true

Requests and responses are different schemas

V4-DATA-4

A resource has one response schema (AiApp) and one request schema per write (CreateAiAppRequest, UpdateAiAppRequest). Nested request objects are named …Input (BudgetRuleInput).

Lintv4-request-schemasv4-response-schemasShared rule: ROB-4

Request schemas never contain server-owned fields (id, createdOn), so SDK method signatures and MCP tool input schemas only ever show what a client can actually send. This is stricter than ROB-4, which allows a single schema with readOnly/writeOnly markers.

Request schemas (*Request, *Input)Response schemas
Unknown propertiesrejected: additionalProperties: falseallowed (open for extension)
Requiredwhat the client must send (nothing for PATCH)every property
StringsmaxLength (or an enum or a date format)unbounded
ArraysmaxItemsunbounded
MapsmaxPropertiesunbounded
readOnly / writeOnlyno readOnlyno writeOnly

Scalar components such as enums (ProviderType) are shared by both.

Do
A closed request schema
YAMLCode
CreateAiAppRequest: type: object additionalProperties: false required: [name] properties: name: { type: string, maxLength: 128 }
Don't
One schema with readOnly
YAMLCode
AiApp: type: object properties: id: { type: string, readOnly: true } name: { type: string }

Secrets

V4-DATA-3

Secrets are write-only on input, revealed once on output (…WithSecret), and masked afterwards. The one exception is a person's own consumers in a user app: the person can read their keys' secrets again from a singleton that nobody else can read.

Lintv4-response-schemasReview
Create
POST201

Creating a key, which is also how a key is rolled. The response is an ApiKeyWithSecret with the secret, exactly once.

Every other response
GET200

Only the maskedKey: zpka_xxxx…xxxx.

A person's own consumer
GET200

…/api-keys/{apiKeyId}/secret returns the key's secret to the person who holds the consumer, with Cache-Control: no-store. Everyone else gets 403, admins included. The secret belongs to a key, not to the consumer, which can have several keys during a roll's grace period.

A person's consumer is theirs, and a user app admin can create it for them. The admin sees the first key's secret once, in the response, and the person reads it when they need it. Only the keys of personal consumers have this singleton: the keys of an app's or the bucket's consumers are seen once, by whoever creates them.

Client secrets are never returned

Secrets sent by the client are request-only (writeOnly) and never come back. A provider's API key is not even sent: a provider assignment names the environment variable that holds it.

Never in URLs or errors

Secrets never appear in URLs, query strings or error details.

Money, units and durations

V4-DATA-2

Money is a JSON number in US dollars (major units), and names carry their unit: inputPerMillionTokens, previousKeyGracePeriodSeconds.

Review
Do
Price per million tokens
JSONCode
{ "inputPerMillionTokens": 0.15 }
Don't
Price per token
JSONCode
{ "inputPerToken": 0.00000015 }

Unreadable.

Do
The unit in the name
JSONCode
{ "fallbackTimeoutSeconds": 30 }
Don't
A duration without a unit
JSONCode
{ "fallbackTimeout": 30000 }

The property name or description states the unit ("value is in US dollars for cost"). If a schema ever needs another currency, it gets an explicit currency field.

Booleans

Do
Named for the positive state
JSONCode
{ "inheritPolicyTemplate": true }
Don't
Named for the negative state
JSONCode
{ "noInheritance": false }

The default is the safe or common case.

Lint
Fails CI on any finding
v4-property-namesv4-schema-namesv4-user-idsv4-enum-valuesv4-typed-schemasv4-request-schemasv4-response-schemas
Review
Checked by people in the pull request

That an ID is the one the owning service mints, units and money (V4-DATA-2), and the handling of secrets (V4-DATA-3).

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