A complete walkthrough of creating your first tenant, minting an API key, and verifying the setup through all three datastores.
This walkthrough creates a tenant, mints a key, makes a test call, and explains exactly what happened in each datastore.
Prerequisites: The Docker Compose stack is running (docker compose ... up -d). See Quick Start if you haven't started it yet.
A tenant is the fairshare identity — it holds the weight, quota, and group assignment.
TOKEN=dev-admin-token
curl -s -X POST http://localhost:9090/api/v1/tenants \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "my-team",
"weight": 200,
"tokens_per_minute": 500000
}' | jq .
Expected response:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "my-team",
"fairshare_group": "default",
"weight": 200,
"tokens_per_minute": 500000,
"max_in_flight": null,
"created_at": "2026-01-15T10:00:00Z",
"updated_at": "2026-01-15T10:00:00Z"
}
Save the id:
TID=$(curl -s -X POST http://localhost:9090/api/v1/tenants \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"my-team","weight":200,"tokens_per_minute":500000}' \
| jq -r .id)
echo $TID
What happened: The Management API wrote a row to tenants in Postgres, then synced a ResolvedKey skeleton to Redis and published an invalidation to all pods.
SECRET=$(curl -s -X POST "http://localhost:9090/api/v1/tenants/$TID/keys" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"prod"}' \
| jq -r .secret)
echo $SECRET
The secret looks like sk_a1b2c3d4e5f6... (48 hex characters after the prefix). Store it now — only the SHA-256 hash is kept in Postgres. If you lose it, delete the key and create a new one.
What happened: The Management API:
sha256(secret) as key_hash in api_keys (Postgres).ResolvedKey JSON (tenant details + weight + quota) at obleth:key:{hash} in Redis.obleth:invalidate so all pods' moka caches are primed.KEY_HASH=$(printf '%s' "$SECRET" | sha256sum | awk '{print $1}')
docker exec -it obleth-redis-1 redis-cli GET "obleth:key:$KEY_HASH"
You should see the JSON ResolvedKey stored there. The data plane will cache this in moka for 5 minutes after first lookup.
curl -s http://localhost/v1/chat/completions \
-H "Authorization: Bearer $SECRET" \
-H "Content-Type: application/json" \
-d '{
"model": "mock-model",
"messages": [{"role": "user", "content": "Hello from my-team"}],
"max_tokens": 32
}' | jq .
Expected: a valid OpenAI-style response from the mock backend.
What happened on the data plane:
bearer() extracted the token, hash_api_key() hashed it, moka lookup → Redis lookup returned the ResolvedKey.Fast).usage field, bucket adjusted.UsageRecord sent to the async channel → ClickHouse.docker exec -it obleth-clickhouse-1 clickhouse-client \
--user obleth --password obleth \
--query "SELECT tenant_id, model, admission, input_tokens, output_tokens, total_ms \
FROM obleth.usage ORDER BY ts_ms DESC LIMIT 5"
You should see your test request with admission='fast'.
curl -s http://localhost:9090/api/v1/audit \
-H "Authorization: Bearer $TOKEN" | jq .
You'll see the create_tenant and create_api_key entries with timestamps.
| Step | Where |
|---|---|
| Tenant + key creation | Postgres (tenants, api_keys tables) |
| Key hot-cache | Redis (obleth:key:{hash}) |
| Token budget | Redis (obleth:budget:{tenant_id}) |
| Usage record | ClickHouse (usage) |
| Audit trail | Postgres (audit_log) |