Zuplo API v4
AI Gateway

Set Up an AI Gateway in Six Calls

This is the flow behind the AI Gateway setup wizard, where an agent asks you a few questions and configures everything. It is also a tour of the v4 conventions: short URLs and typed bodies.

All calls use Authorization: Bearer $TOKEN, either a Zuplo API key or the OAuth token of the signed-in user.

To use every feature (guardrails, caching, tracing, gateway-wide budgets, sub-pools, key rotation), follow the end-to-end tutorial.

Calls name the account and the bucket by ID. If you only know your account's name, look up its ID (a UUID) first:

TerminalCode
curl "https://api.zuplo.com/v4/accounts?name=acme-corp" \ -H "Authorization: Bearer $TOKEN"

1. Create the project

TerminalCode
curl -X POST "https://api.zuplo.com/v4/accounts/5f0c7c2e-8d3a-4b1e-9f2a-6c1d0e7b3a49/projects" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "acme-ai-gateway", "type": "ai-gateway" }'
JSONCode
{ "id": "prj_Hn4QzW7cVb2LpX9sTe5RgK1m", "accountId": "5f0c7c2e-8d3a-4b1e-9f2a-6c1d0e7b3a49", "name": "acme-ai-gateway", "type": "ai-gateway", "buckets": [], "createdOn": "2026-07-31T01:52:37.366Z", "updatedOn": "2026-07-31T01:52:37.366Z" }

An AI Gateway project has one bucket, its production bucket. It is created right after the response, so buckets is usually still empty. List the account's buckets with the project's ID until it is there:

TerminalCode
curl "https://api.zuplo.com/v4/accounts/5f0c7c2e-8d3a-4b1e-9f2a-6c1d0e7b3a49/buckets?projectId=prj_Hn4QzW7cVb2LpX9sTe5RgK1m" \ -H "Authorization: Bearer $TOKEN"

Its id is the bucket ID. Everything the gateway has lives in this bucket, and every other call in this guide is under its URL:

TerminalCode
BUCKET=https://api.zuplo.com/v4/buckets/bckt_Y40ypGODCDP3S7iZxNwCAbIe

For a project that already exists, the same list returns its bucket. The gateway deploys in the background while you carry on. Its production environment has the gateway's url:

TerminalCode
curl "https://api.zuplo.com/v4/projects/prj_Hn4QzW7cVb2LpX9sTe5RgK1m/environments?type=production" \ -H "Authorization: Bearer $TOKEN"

The environment has no status for an AI Gateway (it is null). To know when the gateway is deployed, read the environment's newest deployment until its status is succeeded:

TerminalCode
curl "https://api.zuplo.com/v4/projects/prj_Hn4QzW7cVb2LpX9sTe5RgK1m/deployments?environmentId=4f8b6c1e-2d7a-4e3b-9c5f-8a1d2e6b7c90&limit=1" \ -H "Authorization: Bearer $TOKEN"

A succeeded deployment does not guarantee that every edge serves it yet. Only a request to the gateway's url shows what it serves.

2. Assign a provider

Providers are Zuplo's built-in ones (and any custom ones you define). List them, and the models one offers with their prices:

TerminalCode
curl "$BUCKET/ai/providers?type=anthropic" \ -H "Authorization: Bearer $TOKEN"
TerminalCode
curl "$BUCKET/ai/providers/Anthropic/models" \ -H "Authorization: Bearer $TOKEN"

The API never takes the provider's key. Add it to the project as an environment variable of its production environment, ANTHROPIC_API_KEY here, then assign the provider to the gateway:

TerminalCode
curl -X POST "$BUCKET/ai/provider-assignments" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "providerId": "Anthropic", "label": "anthropic", "apiKeyEnvironmentVariable": "ANTHROPIC_API_KEY", "models": [{ "name": "claude-sonnet-4-5" }, { "name": "claude-haiku-4-5" }] }'

label is the routing prefix, and models are the provider's models the gateway serves. The variable is not checked now: if it is missing, requests to these models fail until you set it.

The response is the assignment, along with a Location: /v4/buckets/bckt_Y40ypGODCDP3S7iZxNwCAbIe/ai/provider-assignments/aip_6eljoh header. If the connection drops, retrying is safe here: labels are unique, so an assignment that was already created makes the retry fail with 409 instead of creating a second one.

To see what you can route to, read the assignment's models in the response (or GET $BUCKET/ai/provider-assignments/{assignmentId}). Clients name a model in requests by the label, anthropic/claude-sonnet-4-5, and policies name it the same way: that is the model's ref. So the label cannot be changed later.

3. Create a pool

TerminalCode
curl -X POST "$BUCKET/ai/pools" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Engineering", "budgetRules": [ { "budgetBy": "app", "limits": [ { "meter": "cost", "period": "monthly", "action": "block", "value": 500 } ] } ], "policyTemplate": [ { "policy": { "type": "auth" } }, { "policy": { "type": "model-filtering", "completions": { "allowList": [ "anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-4-5" ] } }, "canEdit": true }, { "policy": { "type": "metering" }, "canEdit": true } ] }'

budgetRules is the pool's own budget: $500 a month for all of its apps together. policyTemplate is what every new app in the pool starts with: authentication, which apps cannot change, and model filtering and metering, which they can.

4. Create an app in the pool

TerminalCode
curl -X POST "$BUCKET/ai/apps" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "poolId": "config_IM1F8Z4rUT6rDPhabvfnrt27", "name": "Support bot" }'

The app starts with a copy of the pool's policy template. The response includes its policies and its baseUrl, the URL its clients call.

5. Issue an API key

An app's keys belong to its consumers, one for each client or environment. Create the production consumer in the bucket with its first key:

TerminalCode
curl -X POST "$BUCKET/consumers?include[]=apiKey" \ --globoff \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "support-bot-production", "description": "Support bot production" }'
JSONCode
{ "id": "csmr_brw0leTAVw478VFKD4F6OqVI", "bucketId": "bckt_Y40ypGODCDP3S7iZxNwCAbIe", "name": "support-bot-production", "description": "Support bot production", "tags": {}, "metadata": {}, "createdOn": "2026-10-07T09:20:11.002Z", "updatedOn": "2026-10-07T09:20:11.002Z", "apiKey": { "id": "key_XKrbtT8H72PH0gQpblaXfstB", "consumerId": "csmr_brw0leTAVw478VFKD4F6OqVI", "description": null, "maskedKey": "zpka_xxxx…xxxx", "expiresOn": null, "createdOn": "2026-10-07T09:20:11.002Z", "updatedOn": "2026-10-07T09:20:11.002Z", "secret": "zpka_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx_xxxxxxxx" } }

apiKey.secret appears only here. If the response is lost, a retry fails with 409 because the name is taken: roll the key with a POST to $BUCKET/consumers/csmr_brw0leTAVw478VFKD4F6OqVI/api-keys to get a new secret, which is also how you rotate it later.

Then link the consumer to the app, so its key calls the app. Sending it again changes nothing:

TerminalCode
curl -X PUT "$BUCKET/ai/apps/config_Kx7gDiGsr1c2tOOUW5kpmXZn/consumers/csmr_brw0leTAVw478VFKD4F6OqVI" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{}'

The app now lists the consumer in its consumers.

6. Call the gateway

TerminalCode
curl -X POST "https://acme-ai-gateway-main-b8e2f1a.zuplo.app/a/Kx7gDiGsr1c2tOOUW5kpmXZn/v1/chat/completions" \ -H "Authorization: Bearer zpka_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx_xxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "model": "anthropic/claude-haiku-4-5", "messages": [{ "role": "user", "content": "Hello" }] }'

Changing things later

To give the app a budget of its own, read it, then write. policies is replaced as a whole, so send the complete list:

TerminalCode
curl "$BUCKET/ai/apps/config_Kx7gDiGsr1c2tOOUW5kpmXZn" \ -H "Authorization: Bearer $TOKEN" curl -X PATCH "$BUCKET/ai/apps/config_Kx7gDiGsr1c2tOOUW5kpmXZn" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "policies": [ { "type": "auth" }, { "type": "model-filtering", "completions": { "allowList": [ "anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-4-5" ] } }, { "type": "metering", "budgetRules": [ { "budgetBy": "app", "limits": [ { "meter": "requests", "period": "daily", "action": "block", "value": 50000 } ] } ] } ] }'

v4 does not support If-Match yet, so the last write wins: a change someone made to the app's policies between your read and your write is overwritten.

Personal keys for your team

TerminalCode
# Find the gateway's user app: every gateway has exactly one curl "$BUCKET/ai/user-apps" \ -H "Authorization: Bearer $TOKEN" # Give everyone a default of $20 per day: the metering policy's per-person rule curl -X PATCH "$BUCKET/ai/user-apps/config_UsTroIp2e0K8mWYGj1GT8U13" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "policies": [ { "type": "auth" }, { "type": "metering", "budgetRules": [ { "budgetBy": "expression", "expression": "request.user.sub", "limits": [ { "meter": "cost", "period": "daily", "action": "block", "value": 20 } ] } ] } ] }' # Each person creates a consumer with its first key (for example for Claude Code), # with their own userId in the path. A user app admin sends the same request for # anyone, and the person reads the key with GET $BUCKET/api-keys/{apiKeyId}/secret curl -X POST "$BUCKET/ai/user-apps/config_UsTroIp2e0K8mWYGj1GT8U13/users/user_Jm5tR8wQz2KcX7vB4nLp9HdS/consumers?include[]=apiKey" \ --globoff \ -H "Authorization: Bearer $THEIR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "description": "Claude Code on my laptop" }' # Give one person a higher daily budget: idempotent, safe to retry curl -X PUT "$BUCKET/ai/user-apps/config_UsTroIp2e0K8mWYGj1GT8U13/users/user_Jm5tR8wQz2KcX7vB4nLp9HdS/budget" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "limits": [ { "meter": "cost", "period": "daily", "action": "block", "value": 50 } ] }'

Add someone to the pool

TerminalCode
# Look up the person's userId by email curl "https://api.zuplo.com/v4/accounts/5f0c7c2e-8d3a-4b1e-9f2a-6c1d0e7b3a49/[email protected]" \ -H "Authorization: Bearer $TOKEN" # Make them a pool admin: same request, same result, every time curl -X POST "$BUCKET/ai/pools/config_IM1F8Z4rUT6rDPhabvfnrt27/members" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "userId": "user_Jm5tR8wQz2KcX7vB4nLp9HdS", "role": "admin" }' # Not in the account yet? Add them by email: the response has their userId right # away, with status "invited", and Zuplo emails them a link. curl -X POST "https://api.zuplo.com/v4/accounts/5f0c7c2e-8d3a-4b1e-9f2a-6c1d0e7b3a49/members" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "email": "[email protected]" }' # Add that userId to the pool in the next request, without waiting. Raj can use # the pool once he signs in. A pool admin works on every account; member needs # role-based access control. curl -X POST "$BUCKET/ai/pools/config_IM1F8Z4rUT6rDPhabvfnrt27/members" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "userId": "user_Rq4Ys9LmB2xT7nKc5WvH8dJe", "role": "admin" }'

Give someone personal keys only

An account member with the project role ai-user can use the user app with their own keys and sees nothing else in the project. An invited member works too: the role takes effect when they sign in.

TerminalCode
curl -X POST "https://api.zuplo.com/v4/projects/prj_Hn4QzW7cVb2LpX9sTe5RgK1m/members" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "userId": "user_Jm5tR8wQz2KcX7vB4nLp9HdS", "role": "ai-user" }'
Last modified on