Skip to main content

Authentication & API Keys

Everything needed to authenticate against the Travila platform: choosing a key type, scopes, rate limiting, rotation, and troubleshooting.

Choosing an authentication method

There are two ways to authenticate an application. Which you use depends on where your code runs.

MethodCredentialUse it from
Secret keyX-API-Key: sk_* + X-On-Behalf-OfYour backend
Publishable key + user JWTX-API-Key: pk_* + Authorization: Bearer <user-jwt>Client apps (iOS, web)

Secret keys authenticate your backend directly. Publishable keys are safe to embed in client apps but must always be paired with a user JWT — see Create a Publishable Key for the OIDC configuration that makes that work.

A dashboard session is not an API credential

A JWT from signing in to the admin dashboard is for administering your account — provisioning tenants and issuing keys. Don't use one to call the platform API from an application: it is tied to a human's session, expires on its own schedule, carries whatever privileges that person holds, and cannot be scoped, rate-limited, rotated, or revoked the way an API key can.

Issue a key instead, and use one of the two methods above.

warning

The full key value is only returned once at creation time. Store it securely — it cannot be retrieved again.

Request format

All endpoints are reached through the API gateway using REST-style paths:

POST /api/v1/<domain>/<service>/<method>
Content-Type: application/json

Request and response bodies use protojson — the JSON representation of the underlying Protocol Buffer messages.

Using API Keys in Requests

Pass your API key in the X-API-Key header. All endpoints use POST with a JSON body.

curl -X POST https://api.travila.ai/api/v1/llm/gateway/list-threads \
-H "X-API-Key: sk_your_key_here" \
-H "X-On-Behalf-Of: user_123" \
-H "Content-Type: application/json" \
-d '{}'

Acting as a user — X-On-Behalf-Of

Required on most endpoints, not optional

A secret key identifies your tenant, not a user. Most endpoints operate on a specific user's data and reject a request that doesn't identify one:

{ "code": "UNAUTHENTICATED", "message": "authenticated user_id is required" }

With an sk_* key, X-On-Behalf-Of is the only way to supply that user. If your very first call returns the 401 above, this header is what's missing — not your key.

The key must also carry the users:impersonate scope, or the request fails with 403 insufficient_scope. Ask for that scope when you request your key.

Which endpoints need it:

EndpointsX-On-Behalf-Of with sk_*
LLM (/api/v1/llm/gateway/*)Required
Storage (/api/v1/storage/gateway/*)Required
Scheduler (/api/v1/scheduler/*)Required
End User (/api/v1/enduser/*)Required
Notifications — user-facing (inbox, preferences, push devices)Required
Notifications — admin (/api/v1/notifications/manage/*)Not needed
Webhooks (/api/v1/webhooks/*)Not needed — tenant-scoped

Publishable keys (pk_*) never need it: the user comes from the accompanying JWT.

Every sk_* example in these docs includes the header for this reason.

curl -X POST https://api.travila.ai/api/v1/llm/gateway/send-message \
-H "X-API-Key: sk_your_key_here" \
-H "X-On-Behalf-Of: user_123" \
-H "Content-Type: application/json" \
-d '{
"conversation_key": "conv_abc",
"user_message": {
"role": "ROLE_USER",
"content": [{ "type": "CONTENT_PART_TYPE_TEXT", "content": "Hello" }]
}
}'

Managing API Keys

API keys are issued and managed from your account dashboard — creation, listing, rotation, and revocation all live there. Key management is an account-administration operation, not a platform API: it requires a dashboard sign-in, so there is no public endpoint for it and no API key can manage another API key.

Ask your account admin if you need a key issued, a scope added, or an existing key rotated.

warning

The full key value is shown once, at creation time. Copy it then — it cannot be retrieved again afterwards.

Key types

TypePrefixUse it fromPairs with
Secretsk_*Your backendX-On-Behalf-Of, when acting for a user
Publishablepk_*Client apps (iOS, web)A user JWT, always

Every key carries a name, a description, a set of scopes, and an optional expiry. Each also has a short prefix (for example sk_2hfK) shown in the dashboard, so you can identify a key in logs without handling the full value.

Create a Publishable Key

Publishable keys are designed for client-side apps (iOS, web). They must always be paired with a user JWT, and require OIDC configuration so the platform can validate that JWT.

When you create one, you supply your identity provider's OIDC details. That config tells the platform how to validate the user JWTs presented alongside this key:

FieldDescription
issuerExpected iss claim in the JWT
jwksUrlURL to fetch the public keys for JWT signature verification
audienceExpected aud claim in the JWT
userIdClaim(Optional) JWT claim to extract the user ID from. Defaults to sub
requiredClaims(Optional) Map of claim names to expected values. Supports dot notation for nested claims (e.g., firebase.tenant)
OIDC Provider Examples

The platform supports any OIDC-compliant provider. Common configurations:

ProviderissuerjwksUrl
Firebasehttps://securetoken.google.com/<project-id>https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com
Auth0https://<tenant>.auth0.com/https://<tenant>.auth0.com/.well-known/jwks.json
Oktahttps://<org>.okta.com/oauth2/defaulthttps://<org>.okta.com/oauth2/default/v1/keys
AWS Cognitohttps://cognito-idp.<region>.amazonaws.com/<user-pool-id>https://cognito-idp.<region>.amazonaws.com/<user-pool-id>/.well-known/jwks.json
Keycloakhttps://<host>/realms/<realm>https://<host>/realms/<realm>/protocol/openid-connect/certs

See Example: Mobile App for a client using one end to end.

Scopes and Permissions

Scopes control what operations an API key can perform. They are assigned when the key is issued, and can be changed later from the dashboard.

Available Scopes

ScopeDescription
*Full access (all scopes)
conversations:readRead conversations and threads
conversations:writeSend messages, create threads
plans:readRead coaching plans
plans:writeCreate and modify coaching plans
users:readRead user profiles
users:impersonateAct on behalf of users via X-On-Behalf-Of
billing:readRead billing information
billing:writeModify billing settings
invoices:readRead invoices
invoices:writeModify invoices

Scope Matching Rules

  • Exact match: conversations:read grants only read access to conversations
  • Wildcard: * grants access to all scopes
  • Prefix wildcard: users:* grants all user-related scopes (users:read, users:impersonate, etc.)

Publishable Key Restrictions

Publishable keys (pk_*) automatically have the following scopes blocked for security:

  • api_keys:* — Cannot manage API keys
  • webhooks:* — Cannot manage webhooks
  • tenants:* — Cannot manage tenants
  • billing:* — Cannot access billing
  • system:* — Cannot access system operations
  • users:impersonate — Cannot impersonate users
  • * — Cannot have wildcard access

Rate Limiting

In progress — do not rely on this yet

Per-key rate limiting is not currently enforced. Keys are issued without a limit unless one is requested explicitly, so in practice no key is throttled today.

Treat the platform as unmetered for now: build your own client-side throttling and backoff rather than depending on the API to push back. Do not design around a specific limit — when enforcement is switched on, the limits and the rollout will be announced first.

Requesting a limit

If you want a ceiling applied to a key, set requests_per_minute when the key is created or updated:

{
"rate_limit": {
"requests_per_minute": 1000
}
}
FieldStatus
requests_per_minuteThe only field that is applied. 0 (or omitted) = no limit.
requests_per_hourAccepted by the API but ignored — no hourly window is applied.
burst_sizeAccepted by the API but ignored — no burst shaping is applied.

The last two exist in the request schema and will not error, but setting them has no effect. They are reserved for the full implementation.

Response headers

When a key does carry a limit, responses include:

HeaderDescription
X-RateLimit-LimitMaximum requests in the current window
X-RateLimit-RemainingRequests remaining in the current window
X-RateLimit-ResetUnix timestamp when the window resets
Retry-AfterSeconds until the next request is allowed (429 responses only)

A throttled request returns HTTP 429 (Too Many Requests). With no limit configured — the default — these headers are absent and 429 does not occur.

Key Rotation

Rotate a key from the dashboard. Rotation is zero-downtime: it issues a replacement key and sets the old one to expire after a grace period you choose — 24 hours by default, up to 720 hours (30 days). Both keys authenticate during that window, giving your consumers time to switch over.

Once every consumer is on the new key, the old one expires on its own; revoke it early from the dashboard if you want it dead sooner.

caution

Publishable keys (pk_*) cannot be rotated this way. Issue a new one, migrate your clients, then revoke the old key.

Best Practices

  • Rotate regularly — establish a rotation schedule (e.g., every 90 days)
  • Use key names with dates — e.g., production-backend-2026-03 for easy tracking
  • Drain before revoking — give consumers the full grace period to pick up the new key, and confirm from your own side that nothing still holds the previous one
  • Set an expiry — give keys an expiration date as a safety net, so a forgotten key does not live forever

Code Examples

Responses are async

send-message returns a runId immediately; the assistant reply is produced in the background. Retrieve it by polling conversation-state — see Async Generation for the polling recipe.

cURL

# List threads
curl -X POST https://api.travila.ai/api/v1/llm/gateway/list-threads \
-H "X-API-Key: sk_your_key_here" \
-H "Content-Type: application/json" \
-d '{}'

# Send a message (with user impersonation)
curl -X POST https://api.travila.ai/api/v1/llm/gateway/send-message \
-H "X-API-Key: sk_your_key_here" \
-H "X-On-Behalf-Of: user_123" \
-H "Content-Type: application/json" \
-d '{
"conversation_key": "conv_abc",
"user_message": {
"role": "ROLE_USER",
"content": [{ "type": "CONTENT_PART_TYPE_TEXT", "content": "Hello" }]
}
}'

Python

import requests

BASE_URL = "https://api.travila.ai"
API_KEY = "sk_your_key_here"

headers = {
"X-API-Key": API_KEY,
"Content-Type": "application/json",
}

# List threads
response = requests.post(
f"{BASE_URL}/api/v1/llm/gateway/list-threads",
headers=headers,
json={},
)
print(response.json())

# Send a message on behalf of a user
response = requests.post(
f"{BASE_URL}/api/v1/llm/gateway/send-message",
headers={**headers, "X-On-Behalf-Of": "user_123"},
json={
"conversation_key": "conv_abc",
"user_message": {
"role": "ROLE_USER",
"content": [{"type": "CONTENT_PART_TYPE_TEXT", "content": "Hello"}],
},
},
)
print(response.json())

Go

package main

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)

const (
baseURL = "https://api.travila.ai"
apiKey = "sk_your_key_here"
userID = "user_123" // the user this call acts for
)

func main() {
// List threads
body, _ := json.Marshal(map[string]any{})
req, _ := http.NewRequest("POST",
baseURL+"/api/v1/llm/gateway/list-threads",
bytes.NewReader(body))
req.Header.Set("X-API-Key", apiKey)
req.Header.Set("X-On-Behalf-Of", userID)
req.Header.Set("Content-Type", "application/json")

resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()

respBody, _ := io.ReadAll(resp.Body)
fmt.Println("Status:", resp.Status)
fmt.Println("Body:", string(respBody))
}

Node.js

const BASE_URL = "https://api.travila.ai";
const API_KEY = "sk_your_key_here";

// List threads
const response = await fetch(
`${BASE_URL}/api/v1/llm/gateway/list-threads`,
{
method: "POST",
headers: {
"X-API-Key": API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
}
);
const data = await response.json();
console.log(data);

// Send a message on behalf of a user
const msgResponse = await fetch(
`${BASE_URL}/api/v1/llm/gateway/send-message`,
{
method: "POST",
headers: {
"X-API-Key": API_KEY,
"X-On-Behalf-Of": "user_123",
"Content-Type": "application/json",
},
body: JSON.stringify({
conversation_key: "conv_abc",
user_message: {
role: "ROLE_USER",
content: [{ type: "CONTENT_PART_TYPE_TEXT", content: "Hello" }],
},
}),
}
);
const msgData = await msgResponse.json();
console.log(msgData);

Error Responses and Troubleshooting

When authentication fails, the API returns an error with a denial_reason field indicating the cause.

Authentication Errors

ErrorHTTP StatusCauseFix
missing_credentials401No API key or JWT in requestAdd X-API-Key header
api_key_not_found401Key doesn't existVerify the key value is correct
api_key_expired401Key has passed its expiration dateCreate a new key
api_key_disabled401Key is disabledContact your admin to re-enable
api_key_revoked401Key has been revokedCreate a new key — revocation is permanent
api_key_invalid401Generic invalid keyVerify the key format (sk_* or pk_*)
insufficient_scope403Key lacks the required scopeUpdate the key's permissions or create a new key with the needed scope
rate_limited429Key hit its rate limitWait for the reset window or increase the rate limit

Publishable Key Errors

ErrorHTTP StatusCauseFix
publishable_key_requires_jwt401pk_* key used without a user JWTAdd Authorization: Bearer <jwt> header
jwt_expired401User JWT has expiredRefresh the JWT token
jwt_malformed401JWT is malformedVerify the JWT structure
jwt_invalid_signature401JWT signature verification failedEnsure the JWT was issued by the correct provider
jwt_invalid_issuer401JWT issuer doesn't match expected valueCheck the OIDC issuer config on the publishable key
jwt_invalid_audience401JWT audience doesn't matchCheck the OIDC audience config on the publishable key
user_id_claim_not_found401JWT missing the user ID claimEnsure your JWT includes the sub claim (or the configured userIdClaim)

Server Errors

ErrorHTTP StatusCauseFix
unkey_error500Internal key verification errorRetry the request; if persistent, contact support
misconfigured_publishable_key500Publishable key missing OIDC metadataRecreate the key with publishableConfig.oidc
oidc_not_configured500OIDC validator not initializedContact platform support

Quick Reference

Headers

HeaderRequiredDescription
X-API-KeyYesYour API key (sk_* or pk_*)
Content-TypeYesAlways application/json
AuthorizationFor pk_* keysBearer <user-jwt> — required with publishable keys
X-On-Behalf-OfOptionalUser ID to impersonate (requires users:impersonate scope)

Request Format

All API endpoints are reached through the API gateway:

POST /api/v1/<domain>/<service>/<method>
Content-Type: application/json

Request and response bodies use protojson encoding (JSON representation of Protocol Buffer messages).