Authentication
How a request proves who it is — the two Hanzo key types, which one may ship in a browser, and how to create, limit, edit and revoke keys.
Every request to the Hanzo platform is authenticated with a bearer token. There are two kinds of key, they are not interchangeable, and the difference is what each one resolves to when the edge reads it.
The two types
| Prefix | Type | What it is |
|---|---|---|
sk- | secret | session-equivalent, belongs on a server |
pk- | publishable | org-identifying, safe in a browser bundle |
sk- belongs on a server and pk- may ship in a browser bundle. A key spelled any other way is refused at the edge — never guessed at, never
defaulted to the safer-sounding one.
What each one resolves to
A secret key resolves to you. The edge looks it up and gets your user
row, which makes it session-equivalent: anything you can do in the console, a
holder of your sk- can do over HTTP. That is why it belongs on a server and
nowhere else.
A publishable key resolves to your organization and stops there — it
never yields a principal, so it cannot read anything. It identifies; it does not
authenticate. That is the property that makes it safe to ship in a browser
bundle, and it is enforced at the edge rather than promised: a pk- presented
as a bearer credential is refused outright, no matter which endpoint it arrives
at. One pk- covers events, product insights and error capture, which is why
you do not need a separate key or a separate DSN for each.
A publishable key minted alongside a project names that project rather than the organization, so beacons from two sites attribute separately. It is the same spelling and the same rules.
Every key is its own credential
You hold as many keys as you like, of either type, and each is its own row in IAM with its own name, permissions, budget, rate and expiry. Creating a key never touches another, so a new key for a new service leaves every running integration working, and revoking one stops only that one.
A key belongs to the organization it was created in and names the person who created it. A member sees and manages their own keys; an admin of the organization sees and can revoke every key it holds. Only the person a key belongs to can edit it, and an admin's revoke of someone else's key is recorded in the organization's audit trail.
Asking for a type that does not exist is an error, not a default. The API will
not hand you a secret key because you asked for something it did not recognize —
handing an sk- to someone who wanted a browser key is exactly the accident the
refusal exists to prevent.
Creating keys
In the console
- Go to platform.hanzo.ai
- Open API Keys and choose Create new secret key
- Name it, and set its permissions, budget, rate and expiry if it needs them
- Copy the secret immediately — it is shown once and never again
A publishable key is the exception: you can read it back at any time, because it is public by construction and useless to you if you cannot.
Over the API
Keys are one resource, /v1/account/keys, and the type is a field on it. The HTTP
method says what you mean, and a key is addressed by its id.
# Create a key. The response carries the secret, once, in `key`.
curl -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer sk-your-key" \
-H "Content-Type: application/json" \
-d '{"name":"production","type":"secret"}'
# List the keys you may see. No secret material comes back — a secret key is
# named by its prefix (`sk-live-1a2b…`); a publishable key by its full value.
curl https://api.hanzo.ai/v1/account/keys -H "Authorization: Bearer sk-your-key"
# Change a key's name or policy. Its secret is unchanged and keeps working.
curl -X PATCH https://api.hanzo.ai/v1/account/keys/KEY_ID \
-H "Authorization: Bearer sk-your-key" \
-H "Content-Type: application/json" \
-d '{"name":"production-eu"}'
# Revoke exactly one key. It stays listed as revoked, with who and when.
curl -X DELETE https://api.hanzo.ai/v1/account/keys/KEY_ID \
-H "Authorization: Bearer sk-your-key"Each listed key carries its id, name, type, prefix, status (active,
expired or revoked), creator, created, and when set its expires,
limit, budget and rate, what it has spent (spend) and when it was last
used.
Omitting type means secret. An organization holds at most 1,000 live keys.
Limiting what a key may reach
A key reaches whatever its holder reaches. For a key that lives in one deployed service that is more authority than the service needs, so a key can be narrowed:
curl -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer sk-your-key" \
-H "Content-Type: application/json" \
-d '{"name":"trainer","limit":["product:train","read:billing","model:zen5"]}'The entries are kind:name, and kind:* is the whole kind:
| Entry | The key may |
|---|---|
product:train | read and write /v1/train |
read:billing | only read /v1/billing (GET, HEAD, OPTIONS) |
read:* | only read, anywhere: a read-only key |
model:zen5 | call only that model |
project:acme | act only in that project |
A key naming no product: or read: entry may reach every service; naming any
of them, it reaches only those, and a request anywhere else is refused 403
saying what the key may not do. The services are the ones GET /v1 lists. A kind
outside this set is refused when you create the key rather than stored. A limit nothing consults
would be worse than no limit: it would read as a restriction everywhere you
look and be one nowhere.
A limit only ever narrows. It cannot grant a key reach its holder does not
have, so the worst a mistake costs is a call that should have worked. That is
also why an omitted limit means unrestricted: every key issued before limits
existed carries none, and a default that restricted would have revoked them all.
Restriction is per kind. A key limited to a project is not thereby limited to zero models — naming one kind says nothing about the others, so adding a limit later cannot silently switch off something that was working.
The limit belongs to the credential, not the person, which has two consequences worth stating plainly. Your session and your deploy key can carry different limits at the same time. And an admin's limited key is still limited: a restriction you could shed by being an admin would not be one.
GET /v1/account/keys reports each key's limit, so you can see what a key reaches
without minting a new one to find out, and PATCH changes it in place.
A request made with a limited key — one with a limit, a budget or a rate — may revoke keys but not create or edit them, so no key can mint a wider one. For the same reason a limited key cannot log in to the container registry: the registry does not enforce limits, so only an unlimited key works there.
Budget, rate and expiry
curl -X POST https://api.hanzo.ai/v1/account/keys \
-H "Authorization: Bearer sk-your-key" \
-H "Content-Type: application/json" \
-d '{"name":"batch","budget":{"month":5000,"total":0},"rate":60,"expires":"2027-01-01T00:00:00Z"}'budgetcaps what the key spends on models, in cents:monthper calendar month (UTC) andtotalover its life;0is no cap. Past either, the key's model requests are refused402with codekey_budget, naming the budget, while your other keys keep working. The listing showsspendbeside it. The budget is checked when a request starts, so requests already running can carry a key past it by their own cost.ratecaps the key's requests a minute; past it,429. It applies besides any limit your organization sets, never instead of one.expires(RFC 3339) is when the key stops working; a request with an expired key is refused401saying it expired. A revoked key is refused401saying so.
Two things worth knowing before you automate this. A browser session must echo
an anti-CSRF token — GET /v1/account/csrf, then send it back as X-CSRF-Token —
while a bearer caller does not, because a bearer request cannot be forged by a
cross-site page. And revocation reaches IAM immediately but cloud caches a
resolved key for up to a minute, so a request inside that minute may still be
served.
The full request and response shapes are in the Keys reference, generated from the same source as this page.
Using keys
All Hanzo APIs take the key in the Authorization header with the Bearer
scheme:
Authorization: Bearer sk-your-keyEvery service answers on one host, api.hanzo.ai, under /v1/<service> — the
same key works across all of them:
| Service | Path | Example |
|---|---|---|
| AI | /v1/chat/completions, /v1/models, /v1/embeddings | api.hanzo.ai/v1/chat/completions |
| Search | /v1/search | api.hanzo.ai/v1/search |
| Secrets | /v1/kms | api.hanzo.ai/v1/kms/secrets |
| Deploys | /v1/platform | api.hanzo.ai/v1/platform/projects |
| Events | /v1/event | api.hanzo.ai/v1/event/overview |
| Observability | /v1/o11y | api.hanzo.ai/v1/o11y/status?product=cloud |
| Tasks | /v1/tasks | api.hanzo.ai/v1/tasks/namespaces |
SDK configuration
Every SDK takes the key where it takes any bearer token, and each
already points at https://api.hanzo.ai:
import { AiApi, Configuration } from 'hanzoai'
const config = new Configuration({ accessToken: process.env.HANZO_API_KEY })
const ai = new AiApi(config)import os
from hanzoai.cloud import ApiClient, Configuration
client = ApiClient(Configuration(access_token=os.environ["HANZO_API_KEY"]))cfg := hanzoai.NewConfiguration()
cfg.AddDefaultHeader("Authorization", "Bearer "+os.Getenv("HANZO_API_KEY"))
client := hanzoai.NewAPIClient(cfg)let mut cfg = hanzo_client::apis::configuration::Configuration::new();
cfg.bearer_access_token = std::env::var("HANZO_API_KEY").ok();ApiClient client = new ApiClient();
client.setBearerToken(System.getenv("HANZO_API_KEY"));@hanzo/ai is the exception worth knowing, and it is an exception on purpose:
it names the field after the key type, so which key you are holding is a fact
the compiler checks rather than a comment.
new Hanzo({ secretKey: process.env.HANZO_API_KEY }) // a server
new Hanzo({ publishableKey: 'pk-...' }) // a browser bundleA third-party HTTP client written against the chat-completions shape works too —
point its base URL at https://api.hanzo.ai/v1 and give it the same key.
Calling from a browser
The API answers a cross-origin request only from an origin it can trust:
Hanzo's own hosts, and an https host your org has attached to a site and
verified over DNS — POST /v1/projects/{slug}/domains, then
POST /v1/projects/{slug}/domains/{host}/verify. Any other origin, localhost
included, gets no CORS headers, so the browser will not read the answer. A
secret key belongs on your server either way; a page carries only the
publishable key.
Spend and usage
Spend for the calling credential comes from the billing and event APIs — same host, same key:
# Current balance
curl https://api.hanzo.ai/v1/billing/balance \
-H "Authorization: Bearer sk-your-key"
# Itemized metered usage
curl https://api.hanzo.ai/v1/billing/usage \
-H "Authorization: Bearer sk-your-key"{ "balance": 0, "holds": 0, "available": 0 }{
"count": 2000,
"usage": [
{
"amount": 24,
"createdAt": "2026-07-25T21:59:35Z",
"metadata": { "model": "zen-image" },
"transactionId": "use_daff3c10f561635114c7a78e8d0eea5d"
}
]
}For rolled-up request, token and spend counts across a range, use GET https://api.hanzo.ai/v1/event/overview?range=30d — see
Events.
Rotation
- Rotate on a schedule. Ninety days is a reasonable default for a secret key. A publishable key is public anyway; rotate it when you want to cut off a site, not on a calendar.
- Keep production keys in KMS. Never in source, never
in a committed
.env. - Create before you revoke. Create the replacement, deploy it, watch it work, then revoke the old key by its id. The two run side by side, so the cut over needs no downtime.
- One identity per service. Blast radius, and audit logs you can read.
- Watch spend.
/v1/billing/usageor the console. An unfamiliar spend pattern is what a leaked key looks like from the inside.
KMS integration
A KMSSecret syncs secrets out of Hanzo KMS into a Kubernetes Secret your pods
already read. It authenticates as a machine identity, names a scope in KMS, and
names the Secret it manages:
apiVersion: secrets.lux.network/v1alpha1
kind: KMSSecret
metadata:
name: api-kms-sync
namespace: acme
spec:
hostAPI: https://kms.hanzo.ai
resyncInterval: 60
authentication:
universalAuth:
credentialsRef:
secretName: acme-iam-creds
secretNamespace: acme
secretsScope:
projectSlug: acme
envSlug: prod
secretsPath: /hanzo
keys:
- HANZO_API_KEY
managedSecretReference:
creationPolicy: Orphan
secretName: api-secrets
secretNamespace: acme
secretType: OpaquecreationPolicy: Orphan means deleting the CR leaves the Secret in place, so
removing the sync does not take a running workload down with it.
Revoking
From the console: API Keys, then the revoke button on the key's row. Over the
API: DELETE /v1/account/keys/{id}. IAM stops the credential immediately, calls
made with it fail 401 within a minute, and every other key keeps working. The
key stays listed as revoked, with who revoked it and when.
Rules
- Never commit a key. Environment variables or KMS.
- Never put a secret key in a browser. It resolves to you. Use a publishable key, which resolves to your org and cannot read.
- Always use HTTPS. Every Hanzo endpoint enforces TLS; plain HTTP is rejected.
- Ask for the type you need. An unrecognized type is refused, so a typo fails loudly instead of quietly handing you the more powerful key.