Zuplo API v4
Request Handling

Authentication and Authorization

One header for every caller. Who you are is checked at the gateway. What you may do is decided by the service that owns the resource.

One header, two kinds of tokens

AUTH-1 · AUTH-2

Every v4 request authenticates with Authorization: Bearer <token>. The spec declares a single bearerAuth scheme and applies it to every operation.

Lintv4-securityv4-parameters
Automation
Zuplo API key

For CI, Terraform, scripts and backend services.

Code
Authorization: Bearer zpka_0f1e…
Interactive clients
OAuth 2.0 access token

For the portal, the CLI after zuplo login, and an agent you signed in to (the AI Gateway setup wizard).

Code
Authorization: Bearer eyJhbGciOi…

Both reach the same operations with the same permissions model.

Do
The Authorization header
Code
GET /v4/buckets/{bucketId}/ai/apps/{appId} Authorization: Bearer zpka_0f1e…
Don't
Another header, or the URL
Code
GET /v4/buckets/{bucketId}/ai/apps/{appId} X-Api-Key: zpka_0f1e…
Code
GET /v4/buckets/{bucketId}/ai/apps/{appId}?apiKey=zpka_0f1e…

Prerequisite

API-key callers on v4 need OpenFGA to know about API-key principals (consumers), not only users. This is the long-lead item from the scoping document. Until it lands, api.zuplo.com accepts the OAuth access token of a Zuplo sign-in only, as the portal sends it.

Authorization happens in the service

At the gateway
401Who are you?

Zuplo auth policies check the credential.

In the service
403404What may you do?

The service that owns the resource decides, through OpenFGA. 404 when the caller has no access to the account at all (ERR-5).

The gateway does not make OpenFGA checks for v4 routes. With re-rooted URLs, a path like /v4/buckets/{bucketId}/ai/apps/{appId} names the bucket, but not the pool the app is in, and only the service knows whether the app really is in that bucket. So only the service can decide in one check, and it answers 404 for a resource that is not in the container in the path. This follows the 2026-10-06 decision to stop doing OpenFGA at the edge.

Personal resources without /me

Operations that are about the caller ("my personal keys", "my usage") use the same URLs as everyone else's, with the caller's own userId.

Do
One URL, scoped by permission
Code
GET /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/user_Jm5tR8wQ…/usage
Code
GET /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/user_Jm5tR8wQ…/consumers

With your own userId, returns your usage and your consumers. User app admins can pass anyone's userId.

Don't
A separate route for the caller
Code
GET /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/me/consumers
Code
GET /v4/buckets/{bucketId}/ai/user-apps/{userAppId}/users/me/usage

There are no /users/me/... routes. The same URL works for a person, an admin and an API key.

Rate limits and correlation

Do
IETF RateLimit headers (ROB-3)
Code
HTTP/1.1 429 Too Many Requests Retry-After: 30 RateLimit: … RateLimit-Policy: …
Don't
Vendor X- headers
Code
HTTP/1.1 429 Too Many Requests X-RateLimit-Limit: 100 X-RateLimit-Remaining: 0
Limited per caller

A throttled request gets 429 with Retry-After. The limits are stated once, in the spec's info.description.

Every response has a request-id

Every response has a request-id header, and every error repeats it in trace.requestId (ROB-5).

Lint
Fails CI on any finding
v4-securityv4-parameters

Bearer-only security schemes, declared on every operation. No Authorization, X-* or zuplo-user parameters, and no cookies.

Review
Checked by people in the pull request

Where authorization happens.

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