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.
| Method | Credential | Use it from |
|---|---|---|
| Secret key | X-API-Key: sk_* + X-On-Behalf-Of | Your backend |
| Publishable key + user JWT | X-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 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.
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
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:
| Endpoints | X-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.
The full key value is shown once, at creation time. Copy it then — it cannot be retrieved again afterwards.
Key types
| Type | Prefix | Use it from | Pairs with |
|---|---|---|---|
| Secret | sk_* | Your backend | X-On-Behalf-Of, when acting for a user |
| Publishable | pk_* | 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:
| Field | Description |
|---|---|
issuer | Expected iss claim in the JWT |
jwksUrl | URL to fetch the public keys for JWT signature verification |
audience | Expected 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) |
The platform supports any OIDC-compliant provider. Common configurations:
| Provider | issuer | jwksUrl |
|---|---|---|
| Firebase | https://securetoken.google.com/<project-id> | https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com |
| Auth0 | https://<tenant>.auth0.com/ | https://<tenant>.auth0.com/.well-known/jwks.json |
| Okta | https://<org>.okta.com/oauth2/default | https://<org>.okta.com/oauth2/default/v1/keys |
| AWS Cognito | https://cognito-idp.<region>.amazonaws.com/<user-pool-id> | https://cognito-idp.<region>.amazonaws.com/<user-pool-id>/.well-known/jwks.json |
| Keycloak | https://<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
| Scope | Description |
|---|---|
* | Full access (all scopes) |
conversations:read | Read conversations and threads |
conversations:write | Send messages, create threads |
plans:read | Read coaching plans |
plans:write | Create and modify coaching plans |
users:read | Read user profiles |
users:impersonate | Act on behalf of users via X-On-Behalf-Of |
billing:read | Read billing information |
billing:write | Modify billing settings |
invoices:read | Read invoices |
invoices:write | Modify invoices |
Scope Matching Rules
- Exact match:
conversations:readgrants 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 keyswebhooks:*— Cannot manage webhookstenants:*— Cannot manage tenantsbilling:*— Cannot access billingsystem:*— Cannot access system operationsusers:impersonate— Cannot impersonate users*— Cannot have wildcard access
Rate Limiting
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
}
}
| Field | Status |
|---|---|
requests_per_minute | The only field that is applied. 0 (or omitted) = no limit. |
requests_per_hour | Accepted by the API but ignored — no hourly window is applied. |
burst_size | Accepted 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:
| Header | Description |
|---|---|
X-RateLimit-Limit | Maximum requests in the current window |
X-RateLimit-Remaining | Requests remaining in the current window |
X-RateLimit-Reset | Unix timestamp when the window resets |
Retry-After | Seconds 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.
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-03for 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
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
| Error | HTTP Status | Cause | Fix |
|---|---|---|---|
missing_credentials | 401 | No API key or JWT in request | Add X-API-Key header |
api_key_not_found | 401 | Key doesn't exist | Verify the key value is correct |
api_key_expired | 401 | Key has passed its expiration date | Create a new key |
api_key_disabled | 401 | Key is disabled | Contact your admin to re-enable |
api_key_revoked | 401 | Key has been revoked | Create a new key — revocation is permanent |
api_key_invalid | 401 | Generic invalid key | Verify the key format (sk_* or pk_*) |
insufficient_scope | 403 | Key lacks the required scope | Update the key's permissions or create a new key with the needed scope |
rate_limited | 429 | Key hit its rate limit | Wait for the reset window or increase the rate limit |
Publishable Key Errors
| Error | HTTP Status | Cause | Fix |
|---|---|---|---|
publishable_key_requires_jwt | 401 | pk_* key used without a user JWT | Add Authorization: Bearer <jwt> header |
jwt_expired | 401 | User JWT has expired | Refresh the JWT token |
jwt_malformed | 401 | JWT is malformed | Verify the JWT structure |
jwt_invalid_signature | 401 | JWT signature verification failed | Ensure the JWT was issued by the correct provider |
jwt_invalid_issuer | 401 | JWT issuer doesn't match expected value | Check the OIDC issuer config on the publishable key |
jwt_invalid_audience | 401 | JWT audience doesn't match | Check the OIDC audience config on the publishable key |
user_id_claim_not_found | 401 | JWT missing the user ID claim | Ensure your JWT includes the sub claim (or the configured userIdClaim) |
Server Errors
| Error | HTTP Status | Cause | Fix |
|---|---|---|---|
unkey_error | 500 | Internal key verification error | Retry the request; if persistent, contact support |
misconfigured_publishable_key | 500 | Publishable key missing OIDC metadata | Recreate the key with publishableConfig.oidc |
oidc_not_configured | 500 | OIDC validator not initialized | Contact platform support |
Quick Reference
Headers
| Header | Required | Description |
|---|---|---|
X-API-Key | Yes | Your API key (sk_* or pk_*) |
Content-Type | Yes | Always application/json |
Authorization | For pk_* keys | Bearer <user-jwt> — required with publishable keys |
X-On-Behalf-Of | Optional | User 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).
Related
- Example: Mobile App — A client app using a publishable key end to end
- Quickstart — Make your first authenticated call