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

PrefixTypeWhat it is
sk-secretsession-equivalent, belongs on a server
pk-publishableorg-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

  1. Go to platform.hanzo.ai
  2. Open API Keys and choose Create new secret key
  3. Name it, and set its permissions, budget, rate and expiry if it needs them
  4. 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:

EntryThe key may
product:trainread and write /v1/train
read:billingonly read /v1/billing (GET, HEAD, OPTIONS)
read:*only read, anywhere: a read-only key
model:zen5call only that model
project:acmeact 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"}'
  • budget caps what the key spends on models, in cents: month per calendar month (UTC) and total over its life; 0 is no cap. Past either, the key's model requests are refused 402 with code key_budget, naming the budget, while your other keys keep working. The listing shows spend beside it. The budget is checked when a request starts, so requests already running can carry a key past it by their own cost.
  • rate caps 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 refused 401 saying it expired. A revoked key is refused 401 saying 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-key

Every service answers on one host, api.hanzo.ai, under /v1/<service> — the same key works across all of them:

ServicePathExample
AI/v1/chat/completions, /v1/models, /v1/embeddingsapi.hanzo.ai/v1/chat/completions
Search/v1/searchapi.hanzo.ai/v1/search
Secrets/v1/kmsapi.hanzo.ai/v1/kms/secrets
Deploys/v1/platformapi.hanzo.ai/v1/platform/projects
Events/v1/eventapi.hanzo.ai/v1/event/overview
Observability/v1/o11yapi.hanzo.ai/v1/o11y/status?product=cloud
Tasks/v1/tasksapi.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 bundle

A 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

  1. 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.
  2. Keep production keys in KMS. Never in source, never in a committed .env.
  3. 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.
  4. One identity per service. Blast radius, and audit logs you can read.
  5. Watch spend. /v1/billing/usage or 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: Opaque

creationPolicy: 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.
Was this page useful?
Last updated Oct 15, 2026