API Reference
Base URL: https://api.behest.app
All request and response bodies are JSON. All endpoints that require authentication accept a Bearer token in the Authorization header.
Authentication Methods
| Method | Header | Used by |
|---|---|---|
| Dashboard session | Authorization: Bearer <session-token> | Dashboard BFF (Next.js API routes proxy to behest-auth using a service JWT) |
| API key | Authorization: Bearer behest_sk_live_... | POST /auth/v1/auth/mint only |
| Admin secret | X-Admin-Secret: <secret> | Admin kill-switch and admin provider endpoints |
Routes marked dashboard-only require a dashboard session token — they are not intended for direct use by end-user applications.
Auth
POST /auth/v1/auth/mint
Exchange an API key for a short-lived RS256 JWT.
Authentication: API key in Authorization: Bearer header.
Request body:
{
user_id: string; // required, 1-255 chars, not a reserved identifier
role?: "user" | "dashboard-service" | "admin"; // default: role on the API key
ttl?: number; // seconds, 60-86400, default 3600
tier?: string; // optional tier name; injected as a JWT claim
}Response 200:
{
access_token: string; // RS256 JWT
token_type: "Bearer";
project_id: string; // UUID
expires_in: number; // seconds
}Errors:
| Status | Condition |
|---|---|
400 | Missing or invalid user_id, invalid role, ttl out of range, reserved user_id |
401 | API key missing, invalid format, not found, revoked, or project suspended |
curl -X POST https://api.behest.app/auth/v1/auth/mint \
-H "Content-Type: application/json" \
-H "Authorization: Bearer behest_sk_live_abc123..." \
-d '{"user_id": "user-123", "ttl": 3600}'const resp = await fetch("https://api.behest.app/auth/v1/auth/mint", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({ user_id: userId, ttl: 3600 }),
});
const { access_token, expires_in } = await resp.json();GET /.well-known/jwks.json
Return the RSA public key used to sign Behest JWTs in JWK Set format.
Authentication: None.
Response 200:
{
keys: Array<{
kty: "RSA";
n: string;
e: string;
kid: string;
use: "sig";
alg: "RS256";
}>;
}curl https://api.behest.app/.well-known/jwks.jsonTenants
POST /auth/v1/tenants
Create a new tenant. Dashboard-only.
Authentication: Dashboard session.
Request body:
{
name: string; // required
}Response 201:
{
id: string; // UUID
name: string;
}Errors: 400 if name is missing.
curl -X POST https://api.behest.app/auth/v1/tenants \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <session-token>" \
-d '{"name": "Acme Corp"}'Projects
GET /auth/v1/tenants/:tenantId/projects
List all projects for a tenant. Dashboard-only.
Authentication: Dashboard session.
Response 200:
Array<{
id: string;
tenant_id: string;
name: string;
slug: string;
status: string; // "active" | "suspended"
dns_status: string; // "PENDING" | "READY" | "FAILED"
created_at: string; // ISO 8601
}>;curl https://api.behest.app/auth/v1/tenants/<tenantId>/projects \
-H "Authorization: Bearer <session-token>"POST /auth/v1/tenants/:tenantId/projects
Create a new project. Dashboard-only.
Authentication: Dashboard session.
Request body:
{
name: string; // required
}Response 201:
{
id: string;
tenant_id: string;
name: string;
slug: string; // e.g. "amber-fox-042" — auto-generated
fqdn_dev: string; // "{slug}.dev.internal.behest.ai"
fqdn_prod: string; // "{slug}.behest.app"
dns_status: "PENDING";
}Errors:
| Status | Condition |
|---|---|
400 | name is missing |
403 | Free plan tenant already has 3 active projects |
404 | Tenant not found |
Side effects: Enqueues a DNS provisioning job for the project FQDN. Creates a project_settings row with defaults.
curl -X POST https://api.behest.app/auth/v1/tenants/<tenantId>/projects \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <session-token>" \
-d '{"name": "Support Chatbot"}'GET /auth/v1/projects/:projectId/provisioning
Get DNS provisioning status for a project. Dashboard-only.
Authentication: Dashboard session.
Response 200:
{
dns_status: "PENDING" | "READY" | "FAILED";
dns_last_error: string | null;
fqdn_dev: string;
fqdn_prod: string;
slug: string;
dns_updated_at: string | null; // ISO 8601
}POST /auth/v1/projects/:projectId/provisioning/retry
Retry DNS provisioning after a failure. Dashboard-only. Only valid when dns_status is "FAILED".
Authentication: Dashboard session.
Response 200:
{
dns_status: "PENDING";
}Errors: 400 if dns_status is not "FAILED".
POST /auth/v1/projects/:id/suspend
Suspend a project. Revokes all active API keys and removes DNS records. Dashboard-only.
Authentication: Dashboard session.
Response 200:
{
status: "suspended";
}Idempotent — suspending an already-suspended project returns 200 without side effects.
Project Settings
GET /auth/v1/projects/:projectId/settings
Get all settings for a project. Dashboard-only.
Authentication: Dashboard session.
Response 200:
{
id: string;
project_id: string;
system_prompt: string | null;
memory_window: number; // 0-500, default 50
cors_origins: string[];
cors_allow_credentials: boolean;
rpm_limit: number; // 1-10000, default 60
tokens_per_day: number; // min 1000, default 1000000
pii_mode: "disabled" | "shadow" | "enforce";
pii_entities: Record<string, "MASK" | "REDACT" | "BLOCK">;
sentinel_mode: "disabled" | "shadow" | "enforce";
sentinel_blocklist: string[]; // max 200 terms
memory_enabled: boolean;
retention_days: number | null; // 1-365
store_tool_calls: boolean;
provider_model: string | null; // deployed model
draft_provider_model: string | null; // unsaved model draft
draft_saved_at: string | null;
deployed_at: string | null;
created_at: string;
updated_at: string;
}PUT /auth/v1/projects/:projectId/settings
Update one or more project settings. Changes are saved to PostgreSQL as a draft — they do not affect live traffic until you call the deploy endpoint. Dashboard-only.
Authentication: Dashboard session.
Request body (all fields optional — send only what you want to change):
{
system_prompt?: string; // max 32000 chars
memory_window?: number; // 0-500
cors_origins?: string[]; // each must be "scheme://host" with no trailing slash, or "*"
cors_allow_credentials?: boolean; // cannot be true when cors_origins includes "*"
rpm_limit?: number; // 1-10000
tokens_per_day?: number; // >= 1000
pii_mode?: "disabled" | "shadow" | "enforce";
pii_entities?: Record<string, "MASK" | "REDACT" | "BLOCK">;
sentinel_mode?: "disabled" | "shadow" | "enforce";
sentinel_blocklist?: string[]; // max 200 terms
memory_enabled?: boolean;
retention_days?: number | null; // 1-365 or null
store_tool_calls?: boolean;
provider_model?: string | null; // known model ID or null; saved as draft
}Response 200: Updated settings row (same shape as GET response).
Errors: 400 for validation failures per field. 404 if settings not found.
POST /auth/v1/projects/:projectId/settings/deploy
Push draft settings to live traffic. Updates Redis so Kong picks up the changes immediately. Dashboard-only.
Authentication: Dashboard session.
No request body.
Response 200:
{
deployed: true;
project_id: string;
deployed_at: string; // ISO 8601
}Errors:
| Status | Condition |
|---|---|
404 | Settings not found |
502 | Settings saved to DB but Redis sync failed; retry the deploy |
POST /auth/v1/projects/:projectId/settings/discard-draft
Revert project settings DB row to the last deployed snapshot (read from Redis). Dashboard-only.
Authentication: Dashboard session.
No request body.
Response 200: Reverted settings row.
Errors:
| Status | Code | Condition |
|---|---|---|
409 | NO_DEPLOYED_SNAPSHOT | No deploy has ever been run — nothing to revert to |
Project Model Selection
PUT /auth/v1/projects/:projectId/settings/model
Set the model for a project. Takes effect immediately (syncs to Redis without a deploy step). Dashboard-only.
Authentication: Dashboard session.
Request body:
{
provider_model: string; // required, must be a known model ID
}Response 200:
{
configured: true;
provider_model: string;
provider_type: string; // e.g. "openai", "anthropic"
}Errors:
| Status | Code | Condition |
|---|---|---|
400 | MISSING_MODEL | provider_model is absent or empty |
400 | UNKNOWN_MODEL | Model ID not in the known model registry |
404 | — | Project not found |
422 | PROVIDER_NOT_CONFIGURED | Model belongs to a provider for which no BYOK key is configured |
DELETE /auth/v1/projects/:projectId/settings/model
Reset the project to the platform default model. Dashboard-only.
Authentication: Dashboard session.
Response 204: No body.
Errors: 404 if no model is configured.
PUT /auth/v1/projects/:projectId/settings/model/draft
Auto-save a model selection as a draft (DB only, does not affect live traffic). Dashboard-only.
Authentication: Dashboard session.
Request body:
{
provider_model: string;
}Response 200:
{
draft_provider_model: string | null;
draft_saved_at: string | null;
}If the draft model matches the currently deployed model, the draft is cleared and draft_provider_model is returned as null.
DELETE /auth/v1/projects/:projectId/settings/model/draft
Discard the current model draft. Dashboard-only.
Authentication: Dashboard session.
Response 204: No body.
POST /auth/v1/projects/:projectId/settings/model/publish
Publish a saved model draft to live traffic. Equivalent to deploy but scoped to model selection. Dashboard-only.
Authentication: Dashboard session.
No request body.
Response 200:
{
provider_model: string;
deployed_at: string;
}Errors:
| Status | Code | Condition |
|---|---|---|
409 | NO_DRAFT | No draft exists to publish |
422 | PROVIDER_NOT_CONFIGURED | BYOK key for the draft model's provider was removed since the draft was saved |
API Keys
GET /auth/v1/projects/:projectId/api-keys
List all active (non-revoked) API keys for a project. Plaintext keys are never returned. Dashboard-only.
Authentication: Dashboard session.
Response 200:
Array<{
id: string;
name: string | null;
role: string;
prefix: string; // first 8 chars of the key's SHA-256 lookup hash + "…"
createdAt: string; // ISO 8601
}>;POST /auth/v1/projects/:projectId/api-keys
Create a new API key for a project. The plaintext key is returned exactly once. Dashboard-only.
Authentication: Dashboard session.
Request body:
{
name?: string; // default "default"
}Response 201:
{
id: string;
project_id: string;
api_key: string; // "behest_sk_live_..." — shown once only
message: string; // "Store this key securely. It will not be shown again."
}curl -X POST https://api.behest.app/auth/v1/projects/<projectId>/api-keys \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <session-token>" \
-d '{"name": "production"}'POST /auth/v1/projects/:projectId/api-keys/:keyId/rotate
Rotate an API key — atomically revoke the current key and issue a new one. The new plaintext key is returned once. Dashboard-only.
Authentication: Dashboard session.
No request body.
Response 200:
{
id: string;
api_key: string; // new plaintext key — shown once only
message: "New key generated. Old key is revoked.";
}Errors: 404 if key not found or already revoked.
POST /auth/v1/projects/:projectId/api-keys/:keyId/revoke
Permanently revoke an API key. Cannot be undone. Dashboard-only.
Authentication: Dashboard session.
No request body.
Response 200:
{
message: "API key revoked";
}Errors: 404 if key not found.
POST /auth/v1/api-keys/:keyId/rotate
Simplified rotate — no project ID required in the path. Dashboard-only.
Authentication: Dashboard session.
Response 200: Same as /v1/projects/:projectId/api-keys/:keyId/rotate.
DELETE /auth/v1/api-keys/:keyId
Simplified revoke — no project ID required in the path. Dashboard-only.
Authentication: Dashboard session.
Response 200:
{
message: "API key revoked";
}Providers (BYOK)
GET /auth/v1/tenants/:tenantId/providers
List configured provider keys for a tenant. Never returns ciphertext or plaintext keys. Dashboard-only.
Authentication: Dashboard session.
Query params:
?fields=types— lightweight response returning only a list of configured provider type names (reads from Redis only)
Response 200 (full):
{
providers: Array<{
provider_type: string; // "openai" | "anthropic" | "google" | "openrouter" | "mistral" | "cohere"
key_last4: string; // last 4 chars of the original key
key_set_at: string; // ISO 8601
projects_using_count: number; // how many projects in this tenant use this provider's models
}>;
}Response 200 (?fields=types):
{
provider_types: string[]; // e.g. ["openai", "anthropic"]
}PUT /auth/v1/tenants/:tenantId/providers/:providerType
Add or replace a provider API key. Validates the key against the provider's API before storing. Dashboard-only.
Authentication: Dashboard session.
Supported providerType values: openai, anthropic, google, openrouter, mistral, cohere
Request body:
{
api_key: string; // required — the provider API key
base_url_override?: string; // optional custom API base URL (SSRF-safe validation applied)
}Response 200:
{
configured: true;
provider_type: string;
key_last4: string;
key_set_at: string; // ISO 8601
}Errors:
| Status | Code | Condition |
|---|---|---|
400 | UNSUPPORTED_PROVIDER | Unknown providerType |
400 | INVALID_KEY_FORMAT | Key does not match provider's expected format |
400 | INVALID_BASE_URL | base_url_override fails SSRF safety check |
403 | BYOAK_REQUIRES_PRO | Tenant is on the free plan |
404 | TENANT_NOT_FOUND | Tenant not found |
422 | KEY_VALIDATION_FAILED | Key was rejected by the provider API |
429 | — | Too many requests (internal) |
Note: There is a minimum 500ms response time for this endpoint to mitigate timing attacks on key validation.
curl -X PUT https://api.behest.app/auth/v1/tenants/<tenantId>/providers/openai \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <session-token>" \
-d '{"api_key": "sk-proj-abc123..."}'DELETE /auth/v1/tenants/:tenantId/providers/:providerType
Remove a provider key. Takes effect immediately (Redis cleanup is synchronous). Dashboard-only.
Authentication: Dashboard session.
Response 204: No body.
Errors: 404 if no key is configured for this provider.
POST /auth/v1/tenants/:tenantId/providers/:providerType/validate
Validate a provider key without storing it. Rate-limited to 5 requests per minute per tenant. Dashboard-only.
Authentication: Dashboard session.
Request body:
{
api_key: string;
}Response 200:
{
valid: boolean;
reason?: "invalid_key" | "network_error" | "timeout" | "rate_limited";
}Errors: 429 if rate limit exceeded (5/minute).
GET /auth/v1/tenants/:tenantId/providers/models
List all models available to the tenant (platform models + models from configured BYOK providers). Dashboard-only.
Authentication: Dashboard session.
Response 200:
{
models: Array<{
modelId: string;
displayName: string;
providerSlug: string;
providerDisplayName: string;
capabilities: Record<string, unknown>;
contextWindow: number | null;
maxOutputTokens: number | null;
supportsStreaming: boolean | null;
supportsToolUse: boolean | null;
supportsVision: boolean | null;
}>;
}Project Tiers
Tiers define per-segment overrides for a project's settings. Up to 3 tiers per project.
GET /auth/v1/projects/:projectId/tiers
List all tiers with their resolved (merged) settings. Dashboard-only.
Authentication: Dashboard session.
Response 200:
Array<{
id: string;
project_id: string;
name: string;
sort_order: number;
overrides: Record<string, unknown>;
resolved: Record<string, unknown>; // project defaults merged with tier overrides
created_at: string;
updated_at: string;
}>;POST /auth/v1/projects/:projectId/tiers
Create a tier. Dashboard-only.
Authentication: Dashboard session.
Request body:
{
name: string; // required, unique per project
sort_order?: number; // default 0
overrides?: { // all optional
rpm_limit?: number;
tokens_per_day?: number;
pii_mode?: "disabled" | "shadow" | "enforce";
pii_entities?: Record<string, "MASK" | "REDACT" | "BLOCK">;
sentinel_mode?: "disabled" | "shadow" | "enforce";
sentinel_blocklist?: string[];
memory_enabled?: boolean;
memory_window?: number;
retention_days?: number | null;
store_tool_calls?: boolean;
provider_model?: string;
system_prompt?: string;
};
}Response 201: Created tier (without resolved).
Errors:
| Status | Condition |
|---|---|
400 | Validation failure |
403 | Caller does not own the project |
409 | Maximum 3 tiers already exist, or name already taken |
422 | overrides.provider_model references a provider with no BYOK key |
PUT /auth/v1/projects/:projectId/tiers/:tierId
Update a tier. Dashboard-only.
Authentication: Dashboard session.
Request body: Same shape as POST, all fields optional.
Response 200: Updated tier.
DELETE /auth/v1/projects/:projectId/tiers/:tierId
Delete a tier. Dashboard-only.
Authentication: Dashboard session.
Response 204: No body.
GET /auth/v1/projects/:projectId/tiers/:tierId/resolved
Get the fully resolved settings for a single tier (project defaults merged with tier overrides). Dashboard-only.
Authentication: Dashboard session.
Response 200: Object of resolved setting values.
Memory Configuration
GET /auth/v1/projects/:projectId/memory
Get memory configuration for a project. Dashboard-only.
Authentication: Dashboard session.
Response 200:
{
project_id: string;
memory_enabled: boolean;
memory_window: number;
retention_days: number | null;
system_prompt: string | null;
store_tool_calls: boolean;
}Playground
POST /auth/v1/projects/:projectId/playground
Test the project's draft model configuration against real LLM APIs. Rate-limited to 50 requests per hour per user per project. Dashboard-only.
Authentication: Dashboard session.
Request headers:
X-User-Id: optional user identifier for rate limiting (default:"anonymous")
Request body:
{
messages: Array<{ role: string; content: string }>;
}Response: SSE stream (Content-Type: text/event-stream) forwarding the LiteLLM streaming response.
Errors:
| Status | Condition |
|---|---|
400 | messages is missing or empty |
404 | No draft model configured |
422 | Provider key not configured |
429 | Rate limit exceeded (50/hour) |
Admin: Kill Switches
All kill switch endpoints require X-Admin-Secret header matching the ADMIN_SECRET environment variable.
POST /v1/admin/killswitch/global
Enable or disable the global kill switch.
Authentication: X-Admin-Secret header.
Request body:
{
enabled: boolean;
}Response 200:
{
killswitch: "global";
enabled: boolean;
}POST /v1/admin/killswitch/tenant/:tenantId
Enable or disable a tenant-scoped kill switch.
Authentication: X-Admin-Secret header.
Request body: { enabled: boolean }
Response 200:
{
killswitch: "tenant";
tenantId: string;
enabled: boolean;
}POST /v1/admin/killswitch/project/:projectId
Enable or disable a project-scoped kill switch.
Authentication: X-Admin-Secret header.
Request body: { enabled: boolean }
Response 200:
{
killswitch: "project";
projectId: string;
enabled: boolean;
}GET /v1/admin/killswitch/status
Return the current kill switch state across all scopes.
Authentication: X-Admin-Secret header.
Response 200:
{
global: boolean;
tenants: string[]; // tenant IDs with kill switch enabled
projects: string[]; // project IDs with kill switch enabled
}Admin: Provider Catalog
All routes under /auth/v1/admin/providers require the X-Admin-Secret header. These endpoints manage the global provider catalog (not tenant-specific keys).
GET /auth/v1/admin/providers
List all providers with model counts.
Response 200:
{
providers: Array<{
id: string;
name: string;
slug: string;
display_name: string;
is_active: boolean;
model_count: number;
created_at: string;
updated_at: string;
}>;
}POST /auth/v1/admin/providers
Create a new provider in the catalog.
Request body:
{
name: string; // required, unique
slug: string; // required, unique
displayName: string; // required
modelsEndpointUrl?: string;
modelsEndpointAuthType?: string;
authConfigSchema?: object;
supportedFeatures?: object;
}POST /auth/v1/admin/providers/:id/sync-models
Trigger model discovery for a provider — fetches the provider's models API and updates the catalog.
No request body.
Health
Behest services expose standard health and readiness probes. These are not routed through Kong.
| Endpoint | Service | Description |
|---|---|---|
GET /healthz | All services | Liveness probe — returns 200 { status: "ok" } if the process is alive |
GET /readyz | redis-sync-worker | Readiness probe — returns 200 only when the worker has completed its first sync cycle |