Web Management Service β API Design
Architecture Overview
The web management service is a separate service from the Glyphoxa gateway. It acts as the control plane for all administrative operations, wrapping and extending the gatewayβs existing Admin API while adding user authentication, campaign/NPC management, billing, and observability.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Web Management Service β
β β
β ββββββββββββ βββββββββββββ ββββββββββββ βββββββββββββ β
β β Auth API β β Tenant APIβ β NPC API β β Billing β β
β β (own) β β (wraps GW)β β (direct) β β (own) β β
β βββββββ¬βββββ βββββββ¬ββββββ βββββββ¬βββββ βββββββ¬ββββββ β
β β β β β β
β βββββββββββββββΌβββββββββββββββΌββββββββββββββ β
β β β β
β βββββββββ΄βββββββ ββββββ΄ββββββ β
β βGateway Clientβ β Own DB β β
β β (HTTP) β β(Postgres)β β
β βββββββββ¬βββββββ ββββββββββββ β
ββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββ
β
ββββββββββ΄βββββββββ
β Glyphoxa β
β Gateway β
β Admin API β
β (:8081) β
βββββββββββββββββββ
Key design principles:
- Gateway is the source of truth for tenants and sessions β the management service proxies/wraps those calls, never duplicates the data.
- Management service owns users, auth, campaigns, billing, and subscription plans.
- NPC and memory data lives in per-tenant schemas managed by the gateway β the management service connects to the same PostgreSQL cluster but reads/writes through its own store layer (not through the gateway HTTP API).
- All endpoints return JSON with consistent envelope and error formats.
- OpenAPI 3.1 spec is the contract β generated from Go struct tags, used to produce TypeScript client.
Conventions
Base URL
https://manage.glyphoxa.app/api/v1
All paths below are relative to this base.
Authentication
Every endpoint requires authentication unless explicitly marked public.
| Method | Header | Description |
|---|---|---|
| JWT Bearer | Authorization: Bearer <jwt> | Primary auth for all user-facing endpoints |
| API Key | X-API-Key: <key> | Service-to-service and legacy admin access |
JWTs are issued by the management service itself (see Auth API).
Request / Response Format
Request body: Content-Type: application/json (unless file upload, then multipart/form-data).
Success response:
{
"data": { ... },
"meta": {
"page": 1,
"per_page": 25,
"total": 142
}
}
Single-resource responses omit meta. List responses always include pagination metadata.
Error response:
{
"error": {
"code": "tenant_not_found",
"message": "Tenant 'foo' does not exist.",
"details": {}
}
}
Pagination
List endpoints accept:
| Param | Type | Default | Description |
|---|---|---|---|
page | int | 1 | Page number (1-indexed) |
per_page | int | 25 | Items per page (max 100) |
sort | string | varies | Sort field (e.g., created_at, name) |
order | string | desc | Sort direction: asc or desc |
Rate Limiting
Rate limits are per-user (JWT sub claim) or per-API-key. Limits are returned in response headers:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 1711324800
Default tiers:
| Tier | Read | Write | Description |
|---|---|---|---|
| Standard | 60/min | 30/min | Regular users |
| Admin | 120/min | 60/min | tenant_admin and above |
| Super | 300/min | 120/min | super_admin |
| Webhook | 100/min | 100/min | Stripe webhooks |
Role Hierarchy
super_admin > tenant_admin > dm > viewer
Permissions are cumulative β each role inherits all permissions from roles below it.
API Domains
1. Auth & Users
1.1 Social Login β Discord OAuth2
GET /auth/discord
Initiates Discord OAuth2 flow. Redirects browser to Discordβs authorization page.
| Β | Β |
|---|---|
| Auth | public |
| Rate limit | 10/min per IP |
Query parameters:
| Param | Type | Required | Description |
|---|---|---|---|
redirect_uri | string | no | Post-login redirect (default: /dashboard) |
state | string | no | CSRF state (generated if omitted) |
Response: 302 Redirect to https://discord.com/oauth2/authorize?...
Scopes requested: identify, email, guilds.
GET /auth/discord/callback
Handles Discord OAuth2 callback. Exchanges code for tokens, upserts user, issues JWT.
| Β | Β |
|---|---|
| Auth | public |
| Rate limit | 10/min per IP |
Query parameters:
| Param | Type | Required | Description |
|---|---|---|---|
code | string | yes | OAuth2 authorization code |
state | string | yes | CSRF state token |
Response: 302 Redirect to redirect_uri with cookies set.
Sets two cookies:
glyphoxa_accessβ JWT access token (HttpOnly, Secure, SameSite=Strict, 15min)glyphoxa_refreshβ Refresh token (HttpOnly, Secure, SameSite=Strict, 30d)
Error cases:
400β Missing or invalid code/state403β Discord user not linked to any tenant (auto-provision asviewerif tenant can be inferred from guild membership, otherwise reject)
1.2 Social Login β Google OAuth2
GET /auth/google
Initiates Google OAuth2 flow. Same pattern as Discord.
| Β | Β |
|---|---|
| Auth | public |
| Rate limit | 10/min per IP |
Query parameters: Same as Discord.
Response: 302 Redirect to Google authorization endpoint.
Scopes: openid, email, profile.
GET /auth/google/callback
Handles Google OAuth2 callback. Same flow as Discord β exchange, upsert, issue JWT.
| Β | Β |
|---|---|
| Auth | public |
| Rate limit | 10/min per IP |
Query parameters / response / cookies: Same pattern as Discord callback.
1.3 JWT Token Management
POST /auth/token
Exchange credentials for a JWT token pair. Supports multiple grant types for programmatic access (CLI tools, API integrations).
| Β | Β |
|---|---|
| Auth | public |
| Rate limit | 5/min per IP |
Request body:
{
"grant_type": "api_key",
"api_key": "glx_..."
}
Supported grant_type values:
api_keyβ Exchange a management API key for JWT tokensrefresh_tokenβ Exchange a refresh token for new token pair
Response:
{
"data": {
"access_token": "eyJ...",
"refresh_token": "glx_rt_...",
"token_type": "Bearer",
"expires_in": 900,
"user": {
"id": "usr_abc123",
"name": "Luk",
"role": "super_admin",
"tenant_id": "rabenheim"
}
}
}
JWT claims:
{
"sub": "usr_abc123",
"tid": "rabenheim",
"role": "super_admin",
"iss": "glyphoxa-manage",
"iat": 1711324800,
"exp": 1711325700
}
POST /auth/refresh
Refresh an expired access token using a valid refresh token.
| Β | Β |
|---|---|
| Auth | public (refresh token in body or cookie) |
| Rate limit | 10/min per IP |
Request body:
{
"refresh_token": "glx_rt_..."
}
If omitted, reads from glyphoxa_refresh cookie.
Response: Same as POST /auth/token β new access + refresh token pair. Previous refresh token is invalidated (rotation).
POST /auth/revoke
Revoke a refresh token (logout).
| Β | Β |
|---|---|
| Auth | JWT |
| Rate limit | 10/min |
Request body:
{
"refresh_token": "glx_rt_...",
"all": false
}
| Field | Type | Description |
|---|---|---|
refresh_token | string | Specific token to revoke (optional if all=true) |
all | bool | Revoke all refresh tokens for the current user |
Response: 204 No Content
1.4 User CRUD
POST /users
Create a new user within a tenant.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | tenant_admin |
| Rate limit | Write |
Request body:
{
"name": "Hans",
"email": "hans@example.com",
"discord_id": "123456789012345678",
"google_id": "108234567890123456789",
"role": "dm",
"tenant_id": "rabenheim"
}
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Display name |
email | string | no | Email address |
discord_id | string | no | Discord user snowflake (must be unique) |
google_id | string | no | Google sub claim (must be unique) |
role | string | yes | One of: viewer, dm, tenant_admin, super_admin |
tenant_id | string | no | Defaults to callerβs tenant. Only super_admin can set cross-tenant |
Response: 201 Created
{
"data": {
"id": "usr_abc123",
"name": "Hans",
"email": "hans@example.com",
"discord_id": "123456789012345678",
"google_id": null,
"role": "dm",
"tenant_id": "rabenheim",
"created_at": "2026-03-24T10:00:00Z",
"updated_at": "2026-03-24T10:00:00Z"
}
}
Error cases:
400β Missing required fields, invalid role403β Cannot create user with role >= own role (exceptsuper_admin)409βdiscord_idorgoogle_idalready linked to another user
GET /users
List users. Scoped to callerβs tenant unless super_admin.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | tenant_admin |
| Rate limit | Read |
Query parameters:
| Param | Type | Default | Description |
|---|---|---|---|
tenant_id | string | callerβs | Filter by tenant (super_admin only) |
role | string | Β | Filter by role |
search | string | Β | Search by name or email (case-insensitive substring) |
Response: 200 OK β Paginated list of user objects.
GET /users/{user_id}
Get a single user.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | viewer (own profile) / tenant_admin (any in tenant) |
| Rate limit | Read |
Response: 200 OK β User object. 404 if not found or cross-tenant.
PUT /users/{user_id}
Update a userβs profile or role.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | viewer (own name/email) / tenant_admin (role changes) |
| Rate limit | Write |
Request body: Partial update β only include fields to change.
{
"name": "Hans the Brave",
"role": "tenant_admin"
}
| Field | Type | Description |
|---|---|---|
name | string | Display name |
email | string | Email address |
role | string | Role (requires tenant_admin+) |
Constraints:
- Cannot elevate a user to a role >= your own (except
super_admin) - Cannot change your own role
- Cannot change
tenant_id(must delete + recreate)
Response: 200 OK β Updated user object.
DELETE /users/{user_id}
Delete a user.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | tenant_admin |
| Rate limit | Write |
Response: 204 No Content
Constraints:
- Cannot delete yourself
- Cannot delete a user with role >= your own
1.5 User Profile & Preferences
GET /users/me
Get the current userβs profile (convenience alias for GET /users/{self}).
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | viewer |
| Rate limit | Read |
Response: 200 OK β User object with additional fields:
{
"data": {
"id": "usr_abc123",
"name": "Luk",
"email": "luk@example.com",
"discord_id": "123456789012345678",
"role": "super_admin",
"tenant_id": "rabenheim",
"preferences": {
"theme": "dark",
"language": "de",
"notifications": {
"session_start": true,
"session_end": true,
"quota_warning": true
},
"dashboard_layout": "compact"
},
"created_at": "2026-03-24T10:00:00Z",
"updated_at": "2026-03-24T10:00:00Z"
}
}
PATCH /users/me/preferences
Update the current userβs preferences (deep merge).
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | viewer |
| Rate limit | Write |
Request body:
{
"theme": "light",
"notifications": {
"quota_warning": false
}
}
Response: 200 OK β Full preferences object after merge.
2. Tenants
The management service wraps the gatewayβs existing Admin API (POST/GET/PUT/DELETE /api/v1/tenants) and extends it with subscription and provider key management.
2.1 Tenant CRUD
POST /tenants
Create a new tenant. Proxied to gateway with enrichment (subscription plan defaults, schema provisioning).
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | super_admin |
| Rate limit | Write |
Request body:
{
"id": "rabenheim",
"license_tier": "shared",
"bot_token": "MTIzNDU2...",
"guild_ids": ["1234567890"],
"dm_role_id": "9876543210",
"monthly_session_hours": 20,
"plan_id": "plan_adventurer",
"display_name": "Die Chroniken von Rabenheim",
"contact_email": "luk@example.com"
}
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Tenant ID (lowercase, alphanumeric + underscore, max 63 chars) |
license_tier | string | yes | shared or dedicated |
bot_token | string | no | Discord bot token (encrypted via Vault Transit) |
guild_ids | string[] | no | Discord guild snowflakes |
dm_role_id | string | no | Discord role ID for DM permissions |
monthly_session_hours | number | no | Quota override (0 = use plan default) |
plan_id | string | no | Subscription plan ID (see Billing) |
display_name | string | no | Human-readable tenant name (stored in management DB) |
contact_email | string | no | Primary contact email (stored in management DB) |
Flow:
- Validate request
POSTto gateway Admin API to create tenant- Store extended fields (
plan_id,display_name,contact_email) in management DB - Return combined response
Response: 201 Created
{
"data": {
"id": "rabenheim",
"license_tier": "shared",
"guild_ids": ["1234567890"],
"dm_role_id": "9876543210",
"campaign_id": "",
"monthly_session_hours": 20,
"plan_id": "plan_adventurer",
"display_name": "Die Chroniken von Rabenheim",
"contact_email": "luk@example.com",
"created_at": "2026-03-24T10:00:00Z",
"updated_at": "2026-03-24T10:00:00Z"
}
}
Note: bot_token is never returned in responses.
GET /tenants
List tenants. super_admin sees all; others see only their own tenant.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | viewer (own) / super_admin (all) |
| Rate limit | Read |
Query parameters:
| Param | Type | Description |
|---|---|---|
license_tier | string | Filter by tier |
search | string | Search by ID or display name |
Response: 200 OK β Paginated list of tenant objects (with management-DB extensions merged).
GET /tenants/{tenant_id}
Get a single tenant with merged gateway + management data.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | viewer (own tenant) / super_admin (any) |
| Rate limit | Read |
Response: 200 OK β Tenant object. 404 if not found.
PUT /tenants/{tenant_id}
Update tenant. Splits updates between gateway (core fields) and management DB (extended fields).
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | tenant_admin (own, limited fields) / super_admin (any, all fields) |
| Rate limit | Write |
Request body: Partial update.
{
"bot_token": "new_token...",
"monthly_session_hours": 40,
"display_name": "Rabenheim Chronicles"
}
tenant_admin-editable fields: display_name, contact_email, bot_token, guild_ids, dm_role_id
super_admin-only fields: license_tier, monthly_session_hours, plan_id
Response: 200 OK β Updated tenant object.
DELETE /tenants/{tenant_id}
Delete a tenant and all associated data. Cascades to campaigns, NPCs, sessions, usage records, and per-tenant schema.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | super_admin |
| Rate limit | Write |
Response: 204 No Content
Warning: This is destructive and irreversible. The frontend should require confirmation with the tenant ID typed out.
2.2 Provider Key Management (BYOK)
Tenants can bring their own API keys for LLM/STT/TTS providers, overriding the platform defaults.
GET /tenants/{tenant_id}/provider-keys
List configured provider keys for a tenant. Keys are redacted.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | tenant_admin |
| Rate limit | Read |
Response:
{
"data": [
{
"provider_type": "llm",
"provider_name": "openai",
"key_hint": "sk-...7xQ",
"base_url": "",
"model": "gpt-4o",
"status": "active",
"last_verified_at": "2026-03-24T09:00:00Z",
"created_at": "2026-03-20T10:00:00Z"
},
{
"provider_type": "tts",
"provider_name": "elevenlabs",
"key_hint": "el_...f3a",
"base_url": "",
"model": "",
"status": "active",
"last_verified_at": "2026-03-24T09:00:00Z",
"created_at": "2026-03-21T10:00:00Z"
}
]
}
PUT /tenants/{tenant_id}/provider-keys/{provider_type}
Set or update a provider key for a specific provider type.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | tenant_admin |
| Rate limit | Write |
Path parameters:
provider_typeβ One of:llm,stt,tts,s2s,embeddings
Request body:
{
"provider_name": "openai",
"api_key": "sk-...",
"base_url": "",
"model": "gpt-4o",
"options": {}
}
| Field | Type | Required | Description |
|---|---|---|---|
provider_name | string | yes | Registered provider name (e.g., openai, deepgram, elevenlabs) |
api_key | string | yes | Provider API key (encrypted via Vault Transit) |
base_url | string | no | Override providerβs default endpoint |
model | string | no | Model name override |
options | object | no | Provider-specific options |
Flow:
- Validate
provider_nameis registered in the gatewayβs provider registry - Optionally verify the key works (fire a lightweight test call)
- Encrypt key via Vault Transit
- Store in management DB
Response: 200 OK β Provider key object (redacted).
DELETE /tenants/{tenant_id}/provider-keys/{provider_type}
Remove a tenantβs custom provider key. Falls back to platform defaults.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | tenant_admin |
| Rate limit | Write |
Response: 204 No Content
POST /tenants/{tenant_id}/provider-keys/{provider_type}/verify
Test a provider key by making a lightweight API call.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | tenant_admin |
| Rate limit | 5/min |
Response:
{
"data": {
"valid": true,
"latency_ms": 142,
"provider_name": "openai",
"models_available": ["gpt-4o", "gpt-4o-mini", "gpt-3.5-turbo"]
}
}
On failure:
{
"data": {
"valid": false,
"error": "authentication failed: invalid API key"
}
}
2.3 Tenant Settings
GET /tenants/{tenant_id}/settings
Get tenant-level settings (feature flags, defaults, etc.).
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | tenant_admin |
| Rate limit | Read |
Response:
{
"data": {
"default_engine": "cascaded",
"default_budget_tier": "standard",
"default_voice_provider": "elevenlabs",
"max_npcs_per_campaign": 25,
"max_concurrent_sessions": 1,
"features": {
"knowledge_graph": true,
"voice_preview": true,
"session_replay": false,
"custom_voice_upload": false
},
"locale": "de",
"session_timeout_minutes": 360
}
}
PATCH /tenants/{tenant_id}/settings
Update tenant settings (deep merge).
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | tenant_admin (own) / super_admin (feature flags) |
| Rate limit | Write |
Request body: Partial settings object.
Response: 200 OK β Full settings object after merge.
3. Campaigns
Campaigns are a first-class entity owned by the management service. The gatewayβs Tenant.CampaignID field becomes a reference into this table.
3.1 Campaign CRUD
POST /tenants/{tenant_id}/campaigns
Create a new campaign within a tenant.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | dm |
| Rate limit | Write |
Request body:
{
"name": "Die Chroniken von Rabenheim",
"system": "dnd5e",
"description": "A dark fantasy campaign set in the cursed city of Rabenheim...",
"lore": "## History\n\nRabenheim was founded in 1247...",
"settings": {
"language": "de",
"default_voice_provider": "elevenlabs",
"default_engine": "cascaded",
"entity_files": [],
"vtt_imports": []
}
}
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Campaign name |
system | string | no | Game system identifier (dnd5e, pf2e, coc7e, custom) |
description | string | no | Short description |
lore | string | no | Campaign lore / world-building text (markdown) |
settings | object | no | Campaign-specific configuration |
Constraints:
- Shared-tier tenants: max 1 campaign (unless plan allows more)
- Dedicated-tier tenants: unlimited campaigns
Response: 201 Created
{
"data": {
"id": "cmp_abc123",
"tenant_id": "rabenheim",
"name": "Die Chroniken von Rabenheim",
"system": "dnd5e",
"description": "A dark fantasy campaign set in the cursed city of Rabenheim...",
"lore": "## History\n\nRabenheim was founded in 1247...",
"settings": { ... },
"npc_count": 0,
"last_session_at": null,
"created_at": "2026-03-24T10:00:00Z",
"updated_at": "2026-03-24T10:00:00Z"
}
}
GET /tenants/{tenant_id}/campaigns
List campaigns for a tenant.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | viewer |
| Rate limit | Read |
Query parameters:
| Param | Type | Description |
|---|---|---|
system | string | Filter by game system |
search | string | Search by name (case-insensitive) |
Response: 200 OK β Paginated list of campaign objects (includes npc_count and last_session_at).
GET /campaigns/{campaign_id}
Get a single campaign.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | viewer |
| Rate limit | Read |
Response: 200 OK β Campaign object.
PUT /campaigns/{campaign_id}
Update a campaign.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | dm |
| Rate limit | Write |
Request body: Partial update β only include fields to change.
Response: 200 OK β Updated campaign object.
DELETE /campaigns/{campaign_id}
Delete a campaign. NPCs within the campaign are also deleted. Session history is preserved (orphaned but queryable by campaign_id).
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | tenant_admin |
| Rate limit | Write |
Response: 204 No Content
3.2 Campaign NPC Assignment
POST /campaigns/{campaign_id}/npcs/{npc_id}
Link an existing NPC to a campaign.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | dm |
| Rate limit | Write |
Response: 204 No Content
Note: NPCs are created within a campaign context (see NPC CRUD), so this endpoint is primarily for moving NPCs between campaigns.
DELETE /campaigns/{campaign_id}/npcs/{npc_id}
Unlink an NPC from a campaign. Does not delete the NPC β it becomes orphaned.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | dm |
| Rate limit | Write |
Response: 204 No Content
4. NPCs
NPC data lives in the gatewayβs per-tenant PostgreSQL schema (npc_definitions table). The management service connects directly to this table through its own store layer (using the same npcstore.Store interface).
4.1 NPC CRUD
POST /campaigns/{campaign_id}/npcs
Create a new NPC within a campaign.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | dm |
| Rate limit | Write |
Request body:
{
"name": "Heinrich der WΓ€chter",
"personality": "Ein strenger aber gerechter StadtwΓ€chter der seit 20 Jahren...",
"engine": "cascaded",
"voice": {
"provider": "elevenlabs",
"voice_id": "pNInz6obpgDQGcFmaJgB",
"pitch_shift": -1.5,
"speed_factor": 0.9
},
"knowledge_scope": ["rabenheim_history", "guard_duties", "city_layout"],
"secret_knowledge": ["mayor_corruption", "hidden_tunnels"],
"behavior_rules": [
"Spricht immer Deutsch",
"Misstraut Fremden zunΓ€chst",
"Wird gesprΓ€chiger nach Bestechung"
],
"tools": ["patrol_route", "check_papers"],
"budget_tier": "standard",
"gm_helper": false,
"address_only": true,
"attributes": {
"alignment": "lawful neutral",
"race": "human",
"class": "fighter"
}
}
| Field | Type | Required | Validation | Description |
|---|---|---|---|---|
name | string | yes | non-empty | NPCβs in-world display name |
personality | string | no | Β | Free-text persona for LLM system prompt |
engine | string | no | cascaded/s2s/sentence_cascade | Pipeline mode (default: cascaded) |
voice | object | no | Β | Voice configuration |
voice.provider | string | no | Β | TTS provider name |
voice.voice_id | string | no | Β | Provider-specific voice identifier |
voice.pitch_shift | number | no | [-10, 10] | Semitone pitch adjustment |
voice.speed_factor | number | no | [0.5, 2.0] | Speed multiplier (0 = provider default) |
knowledge_scope | string[] | no | Β | Topic domains for knowledge retrieval |
secret_knowledge | string[] | no | Β | Knowledge only this NPC has |
behavior_rules | string[] | no | Β | Behavioral instructions |
tools | string[] | no | Β | MCP tool names this NPC can invoke |
budget_tier | string | no | fast/standard/deep | Resource allocation tier (default: fast) |
gm_helper | bool | no | Β | GM assistant mode (at most 1 per campaign) |
address_only | bool | no | Β | Only responds when explicitly addressed |
attributes | object | no | Β | Arbitrary key-value metadata |
Constraints:
gm_helper: trueβ at most one per campaign. Returns409if another exists.- Plan-based NPC limits enforced (e.g., free tier = 2 NPCs max).
Response: 201 Created
{
"data": {
"id": "npc_abc123",
"campaign_id": "cmp_abc123",
"name": "Heinrich der WΓ€chter",
"personality": "...",
"engine": "cascaded",
"voice": { ... },
"knowledge_scope": ["rabenheim_history", "guard_duties", "city_layout"],
"secret_knowledge": ["mayor_corruption", "hidden_tunnels"],
"behavior_rules": ["Spricht immer Deutsch", "..."],
"tools": ["patrol_route", "check_papers"],
"budget_tier": "standard",
"gm_helper": false,
"address_only": true,
"attributes": { "alignment": "lawful neutral", ... },
"created_at": "2026-03-24T10:00:00Z",
"updated_at": "2026-03-24T10:00:00Z"
}
}
GET /campaigns/{campaign_id}/npcs
List NPCs in a campaign.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | viewer |
| Rate limit | Read |
Query parameters:
| Param | Type | Description |
|---|---|---|
engine | string | Filter by engine type |
search | string | Search by name |
Response: 200 OK β Paginated list of NPC objects.
GET /npcs/{npc_id}
Get a single NPC by ID.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | viewer |
| Rate limit | Read |
Response: 200 OK β NPC object. 404 if not found.
PUT /npcs/{npc_id}
Update an NPC. Partial update β only include fields to change.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | dm |
| Rate limit | Write |
Request body: Same fields as create, all optional.
Response: 200 OK β Updated NPC object.
DELETE /npcs/{npc_id}
Delete an NPC.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | dm |
| Rate limit | Write |
Response: 204 No Content
4.2 Voice Preview
POST /npcs/{npc_id}/voice-preview
Generate a TTS audio preview using the NPCβs voice configuration.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | dm |
| Rate limit | 5/min per user |
Request body:
{
"text": "Halt! Wer geht da in der Nacht durch die StraΓen von Rabenheim?"
}
| Field | Type | Required | Validation | Description |
|---|---|---|---|---|
text | string | no | max 500 chars | Text to synthesize (default: auto-generated sample from personality) |
Response: 200 OK
Content-Type: audio/mpeg
Content-Length: 24576
X-TTS-Provider: elevenlabs
X-TTS-Latency-Ms: 340
Binary audio data (MP3 format).
Error cases:
402β TTS provider key invalid or quota exhausted429β Rate limit exceeded (voice previews are expensive)503β TTS provider unavailable
4.3 Voice Sample Upload
POST /npcs/{npc_id}/voice-samples
Upload audio samples for custom voice creation (voice cloning). Provider-specific (currently ElevenLabs Instant Voice Cloning).
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | tenant_admin |
| Rate limit | 3/hour per tenant |
| Content-Type | multipart/form-data |
Form fields:
| Field | Type | Required | Description |
|---|---|---|---|
samples | file[] | yes | Audio files (MP3/WAV/OGG, 1-25 files, each 1-10MB) |
name | string | yes | Voice name for the provider |
description | string | no | Voice description |
Response: 202 Accepted β Voice creation is async.
{
"data": {
"voice_job_id": "vj_abc123",
"status": "processing",
"estimated_completion_seconds": 120
}
}
GET /npcs/{npc_id}/voice-samples/{voice_job_id}
Check the status of a voice creation job.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | tenant_admin |
| Rate limit | Read |
Response:
{
"data": {
"voice_job_id": "vj_abc123",
"status": "completed",
"provider_voice_id": "pNInz6obpgDQGcFmaJgB",
"created_at": "2026-03-24T10:00:00Z",
"completed_at": "2026-03-24T10:02:15Z"
}
}
Status values: processing, completed, failed.
4.4 NPC Templates / Presets
GET /npc-templates
List built-in NPC templates/presets for common archetypes.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | viewer |
| Rate limit | Read |
Query parameters:
| Param | Type | Description |
|---|---|---|
system | string | Filter by game system (dnd5e, pf2e, etc.) |
category | string | Filter by archetype (tavern, guard, merchant, noble, villain) |
Response:
{
"data": [
{
"id": "tmpl_tavern_keeper",
"name": "Tavern Keeper",
"system": "dnd5e",
"category": "tavern",
"description": "A warm, gossip-loving innkeeper who knows everyone's business.",
"personality": "You are a warm and welcoming tavern keeper...",
"behavior_rules": ["Offers food and drink first", "Shares rumors for coin"],
"knowledge_scope": ["local_gossip", "tavern_menu", "travelers"],
"suggested_voice": {
"provider": "elevenlabs",
"voice_id": "...",
"pitch_shift": 0,
"speed_factor": 1.0
},
"attributes": { "alignment": "neutral good" }
}
]
}
POST /campaigns/{campaign_id}/npcs/from-template
Create an NPC from a template, with optional overrides.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | dm |
| Rate limit | Write |
Request body:
{
"template_id": "tmpl_tavern_keeper",
"overrides": {
"name": "Greta die Wirtin",
"personality": "Eine resolute Wirtin aus Bayern...",
"voice": {
"provider": "elevenlabs",
"voice_id": "custom_id"
}
}
}
Response: 201 Created β Full NPC object (same as create NPC).
5. Sessions
Session lifecycle data lives in the gatewayβs sessions table. The management service queries it directly (read-only) and proxies control operations to the gateway.
5.1 Session Queries
GET /sessions
List sessions with filtering.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | viewer |
| Rate limit | Read |
Query parameters:
| Param | Type | Default | Description |
|---|---|---|---|
tenant_id | string | callerβs | Filter by tenant (super_admin only for cross-tenant) |
campaign_id | string | Β | Filter by campaign |
state | string | Β | Filter by state: pending, active, ended |
guild_id | string | Β | Filter by Discord guild |
after | datetime | Β | Sessions started after this time (ISO 8601) |
before | datetime | Β | Sessions started before this time |
has_error | bool | Β | Filter to sessions with/without errors |
Response: 200 OK
{
"data": [
{
"id": "sess_abc123",
"tenant_id": "rabenheim",
"campaign_id": "cmp_abc123",
"guild_id": "1234567890",
"channel_id": "9876543210",
"license_tier": "shared",
"state": "active",
"error": "",
"worker_pod": "worker-0",
"duration_seconds": 2535,
"entry_count": 147,
"started_at": "2026-03-24T18:00:00Z",
"ended_at": null,
"last_heartbeat": "2026-03-24T18:42:15Z"
}
],
"meta": { "page": 1, "per_page": 25, "total": 42 }
}
GET /sessions/active
List all currently active sessions. Convenience endpoint with enriched data.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | viewer |
| Rate limit | Read |
Response: 200 OK β List of active session objects with additional live data:
{
"data": [
{
"id": "sess_abc123",
"tenant_id": "rabenheim",
"campaign_id": "cmp_abc123",
"campaign_name": "Die Chroniken von Rabenheim",
"guild_id": "1234567890",
"channel_id": "9876543210",
"state": "active",
"worker_pod": "worker-0",
"duration_seconds": 2535,
"entry_count": 147,
"active_npcs": [
{ "id": "npc_abc", "name": "Heinrich der WΓ€chter", "muted": false },
{ "id": "npc_def", "name": "Greta die Wirtin", "muted": false }
],
"started_at": "2026-03-24T18:00:00Z",
"last_heartbeat": "2026-03-24T18:42:15Z"
}
]
}
GET /sessions/{session_id}
Get session details.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | viewer |
| Rate limit | Read |
Response: 200 OK
{
"data": {
"id": "sess_abc123",
"tenant_id": "rabenheim",
"campaign_id": "cmp_abc123",
"campaign_name": "Die Chroniken von Rabenheim",
"guild_id": "1234567890",
"channel_id": "9876543210",
"license_tier": "shared",
"state": "ended",
"error": "",
"worker_pod": "worker-0",
"duration_seconds": 4980,
"entry_count": 312,
"usage": {
"session_hours": 1.38,
"llm_tokens": 45230,
"stt_seconds": 2840.5,
"tts_chars": 18420
},
"npcs": [
{ "id": "npc_abc", "name": "Heinrich der WΓ€chter" },
{ "id": "npc_def", "name": "Greta die Wirtin" }
],
"started_at": "2026-03-24T18:00:00Z",
"ended_at": "2026-03-24T19:23:00Z",
"last_heartbeat": "2026-03-24T19:22:50Z"
}
}
5.2 Session Transcript
GET /sessions/{session_id}/transcript
Get the session transcript (L1 entries).
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | viewer |
| Rate limit | Read |
Query parameters:
| Param | Type | Default | Description |
|---|---|---|---|
after | datetime | Β | Entries after this timestamp |
before | datetime | Β | Entries before this timestamp |
speaker_id | string | Β | Filter by speaker |
npc_id | string | Β | Filter by NPC |
search | string | Β | Full-text search within transcript text |
include_raw | bool | false | Include uncorrected STT text |
format | string | json | Response format: json, text, srt |
Response (json): 200 OK
{
"data": {
"session_id": "sess_abc123",
"entries": [
{
"speaker_id": "123456789",
"speaker_name": "Luk",
"speaker_role": "gm",
"text": "Ihr betretet die nebligen StraΓen von Rabenheim.",
"raw_text": "Ihr betretet die nebligen Strassen von Rabenheim",
"npc_id": "",
"timestamp": "2026-03-24T18:00:05Z",
"duration_ms": 3200
},
{
"speaker_id": "npc_abc123",
"speaker_name": "Heinrich der WΓ€chter",
"speaker_role": "",
"text": "Halt! Wer geht da?",
"raw_text": "",
"npc_id": "npc_abc123",
"timestamp": "2026-03-24T18:00:09Z",
"duration_ms": 1800
}
],
"total_entries": 312
},
"meta": { "page": 1, "per_page": 100, "total": 312 }
}
Response (text): 200 OK β Plain text format:
Content-Type: text/plain; charset=utf-8
[18:00:05] Luk (GM): Ihr betretet die nebligen StraΓen von Rabenheim.
[18:00:09] Heinrich der WΓ€chter: Halt! Wer geht da?
Response (srt): 200 OK β SRT subtitle format for future replay (issue #36):
Content-Type: text/srt; charset=utf-8
1
00:00:05,000 --> 00:00:08,200
[Luk] Ihr betretet die nebligen StraΓen von Rabenheim.
2
00:00:09,000 --> 00:00:10,800
[Heinrich der WΓ€chter] Halt! Wer geht da?
5.3 Active Session Monitoring
GET /sessions/{session_id}/live (WebSocket)
WebSocket endpoint for live session monitoring. Streams transcript entries and audio stats in real-time.
| Β | Β |
|---|---|
| Auth | JWT (passed as token query param for WebSocket upgrade) |
| Min role | viewer |
| Protocol | wss:// |
Connection: wss://manage.glyphoxa.app/api/v1/sessions/{session_id}/live?token=<jwt>
Server β Client messages:
Transcript entry:
{
"type": "transcript",
"data": {
"speaker_id": "npc_abc123",
"speaker_name": "Heinrich der WΓ€chter",
"text": "Halt! Wer geht da?",
"npc_id": "npc_abc123",
"timestamp": "2026-03-24T18:00:09Z"
}
}
Audio stats (every 5s):
{
"type": "audio_stats",
"data": {
"vad_active": true,
"stt_latency_ms": 145,
"tts_queue_depth": 2,
"active_speaker": "123456789",
"timestamp": "2026-03-24T18:42:15Z"
}
}
NPC state change:
{
"type": "npc_state",
"data": {
"npc_id": "npc_abc123",
"name": "Heinrich der WΓ€chter",
"muted": true
}
}
Session ended:
{
"type": "session_ended",
"data": {
"reason": "user_stopped",
"duration_seconds": 4980,
"ended_at": "2026-03-24T19:23:00Z"
}
}
Client β Server messages:
Ping (keepalive):
{ "type": "ping" }
5.4 Session Control
POST /sessions/{session_id}/stop
Force-stop an active session. Proxied to the gatewayβs worker client.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | dm |
| Rate limit | Write |
Request body: (optional)
{
"reason": "DM ended session via admin panel"
}
Response: 200 OK
{
"data": {
"session_id": "sess_abc123",
"previous_state": "active",
"new_state": "ended",
"stopped_at": "2026-03-24T19:23:00Z"
}
}
Error cases:
404β Session not found409β Session already ended
5.5 Session Replay (Future β Issue #36)
GET /sessions/{session_id}/replay
Get replay data for a completed session, including audio segments and transcript timeline. Reserved for future implementation.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | viewer |
| Rate limit | Read |
| Status | planned β not yet implemented |
Response: 501 Not Implemented (until issue #36 is resolved)
Planned response:
{
"data": {
"session_id": "sess_abc123",
"duration_seconds": 4980,
"timeline": [
{
"offset_ms": 5000,
"type": "speech",
"speaker_name": "Luk",
"text": "...",
"audio_url": "/api/v1/sessions/sess_abc123/replay/audio/segment_001.opus"
}
],
"recap": {
"text": "In tonight's session, the party entered Rabenheim...",
"audio_url": "/api/v1/sessions/sess_abc123/replay/recap.mp3"
}
}
}
6. Billing & Usage
6.1 Subscription Plans (Admin)
POST /admin/plans
Create a subscription plan.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | super_admin |
| Rate limit | Write |
Request body:
{
"id": "plan_adventurer",
"name": "Adventurer",
"description": "For casual DMs running regular sessions.",
"price_monthly_cents": 900,
"price_yearly_cents": 9000,
"currency": "eur",
"features": {
"max_sessions_per_month": 8,
"max_session_hours": 20,
"max_npcs_per_campaign": 10,
"max_campaigns": 3,
"voice_quality": "standard",
"llm_tier": "standard",
"knowledge_graph": true,
"custom_voice_upload": false,
"session_replay": false,
"priority_support": false
},
"stripe_price_id_monthly": "price_...",
"stripe_price_id_yearly": "price_...",
"visible": true,
"sort_order": 2
}
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Plan identifier (e.g., plan_apprentice, plan_adventurer) |
name | string | yes | Display name |
description | string | no | Plan description |
price_monthly_cents | int | yes | Monthly price in smallest currency unit |
price_yearly_cents | int | no | Annual price (0 = no annual option) |
currency | string | yes | ISO 4217 currency code |
features | object | yes | Feature limits and flags (see below) |
stripe_price_id_monthly | string | no | Stripe Price ID for monthly billing |
stripe_price_id_yearly | string | no | Stripe Price ID for annual billing |
visible | bool | no | Show on pricing page (default: true) |
sort_order | int | no | Display order on pricing page |
Plan features schema:
| Feature | Type | Description |
|---|---|---|
max_sessions_per_month | int | Max sessions per month (0 = unlimited) |
max_session_hours | float | Max total session hours per month |
max_npcs_per_campaign | int | NPC limit per campaign |
max_campaigns | int | Campaign limit per tenant |
voice_quality | string | basic, standard, premium |
llm_tier | string | budget, standard, premium |
knowledge_graph | bool | Access to L3 knowledge graph |
custom_voice_upload | bool | Can upload voice samples |
session_replay | bool | Access to session replay |
priority_support | bool | Priority support queue |
Response: 201 Created β Plan object.
GET /admin/plans
List all subscription plans.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | super_admin (all) / viewer (visible only) |
| Rate limit | Read |
Query parameters:
| Param | Type | Description |
|---|---|---|
visible | bool | Filter by visibility |
Response: 200 OK β List of plan objects, sorted by sort_order.
GET /admin/plans/{plan_id}
Get a single plan.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | viewer |
| Rate limit | Read |
Response: 200 OK β Plan object.
PUT /admin/plans/{plan_id}
Update a plan. Changes affect new subscriptions; existing subscribers keep their current terms until renewal.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | super_admin |
| Rate limit | Write |
Request body: Partial update.
Response: 200 OK β Updated plan object.
DELETE /admin/plans/{plan_id}
Soft-delete a plan. Sets visible: false and archived: true. Existing subscribers remain on the plan until they change.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | super_admin |
| Rate limit | Write |
Response: 204 No Content
6.2 Public Plans Listing
GET /plans
List visible subscription plans for the pricing page.
| Β | Β |
|---|---|
| Auth | public |
| Rate limit | 30/min per IP |
Response: 200 OK
{
"data": [
{
"id": "plan_apprentice",
"name": "Apprentice",
"description": "Try Glyphoxa for free.",
"price_monthly_cents": 0,
"price_yearly_cents": 0,
"currency": "eur",
"features": {
"max_sessions_per_month": 2,
"max_session_hours": 4,
"max_npcs_per_campaign": 2,
"max_campaigns": 1,
"voice_quality": "basic",
"llm_tier": "budget",
"knowledge_graph": false,
"custom_voice_upload": false,
"session_replay": false,
"priority_support": false
}
},
{
"id": "plan_adventurer",
"name": "Adventurer",
"description": "For casual DMs running regular sessions.",
"price_monthly_cents": 900,
"price_yearly_cents": 9000,
"currency": "eur",
"features": { ... }
},
{
"id": "plan_dungeon_master",
"name": "Dungeon Master",
"description": "For serious DMs who want it all.",
"price_monthly_cents": 1900,
"price_yearly_cents": 19000,
"currency": "eur",
"features": { ... }
}
]
}
6.3 User Subscription Management
GET /subscriptions/current
Get the current userβs (tenantβs) active subscription.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | tenant_admin |
| Rate limit | Read |
Response: 200 OK
{
"data": {
"id": "sub_abc123",
"tenant_id": "rabenheim",
"plan_id": "plan_adventurer",
"plan_name": "Adventurer",
"status": "active",
"billing_cycle": "monthly",
"current_period_start": "2026-03-01T00:00:00Z",
"current_period_end": "2026-04-01T00:00:00Z",
"cancel_at_period_end": false,
"stripe_subscription_id": "sub_stripe_...",
"stripe_customer_id": "cus_stripe_...",
"features": { ... },
"usage_this_period": {
"session_hours": 12.5,
"session_hours_limit": 20,
"sessions_count": 5,
"sessions_limit": 8,
"npcs_count": 7,
"npcs_limit": 10
},
"created_at": "2026-01-15T10:00:00Z"
}
}
Subscription statuses: active, trialing, past_due, canceled, unpaid.
POST /subscriptions
Create a new subscription (or start a free trial). Creates a Stripe Checkout session.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | tenant_admin |
| Rate limit | Write |
Request body:
{
"plan_id": "plan_adventurer",
"billing_cycle": "monthly",
"success_url": "https://manage.glyphoxa.app/billing?success=true",
"cancel_url": "https://manage.glyphoxa.app/billing?canceled=true"
}
| Field | Type | Required | Description |
|---|---|---|---|
plan_id | string | yes | Plan to subscribe to |
billing_cycle | string | yes | monthly or yearly |
success_url | string | yes | Redirect URL after successful payment |
cancel_url | string | yes | Redirect URL if user cancels checkout |
Response: 200 OK
{
"data": {
"checkout_url": "https://checkout.stripe.com/c/pay/cs_...",
"session_id": "cs_..."
}
}
For free plans (price = 0): subscription is created immediately without Stripe, returns the subscription object directly.
POST /subscriptions/change-plan
Change to a different plan (upgrade or downgrade).
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | tenant_admin |
| Rate limit | Write |
Request body:
{
"plan_id": "plan_dungeon_master",
"billing_cycle": "yearly",
"prorate": true
}
| Field | Type | Required | Description |
|---|---|---|---|
plan_id | string | yes | New plan ID |
billing_cycle | string | no | Change billing cycle (default: keep current) |
prorate | bool | no | Prorate charges (default: true) |
Response: 200 OK β Updated subscription object.
Upgrades take effect immediately. Downgrades take effect at the end of the current billing period.
POST /subscriptions/cancel
Cancel the current subscription.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | tenant_admin |
| Rate limit | Write |
Request body:
{
"at_period_end": true,
"reason": "too_expensive",
"feedback": "Would come back if there were more voices."
}
| Field | Type | Required | Description |
|---|---|---|---|
at_period_end | bool | no | Cancel at end of period (default: true) vs immediately |
reason | string | no | Cancellation reason code |
feedback | string | no | Free-text feedback |
Response: 200 OK β Subscription object with cancel_at_period_end: true.
POST /subscriptions/resume
Resume a subscription that was set to cancel at period end.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | tenant_admin |
| Rate limit | Write |
Response: 200 OK β Subscription object with cancel_at_period_end: false.
6.4 Usage Tracking & Reporting
GET /usage
Get usage overview for the current tenant (or all tenants for super_admin).
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | tenant_admin |
| Rate limit | Read |
Query parameters:
| Param | Type | Default | Description |
|---|---|---|---|
tenant_id | string | callerβs | Tenant filter (super_admin only for cross-tenant) |
period | string | current month | ISO 8601 month (2026-03) |
Response: 200 OK
{
"data": {
"tenant_id": "rabenheim",
"period": "2026-03",
"session_hours": 12.5,
"session_hours_limit": 20,
"llm_tokens": 245000,
"stt_seconds": 14200.5,
"tts_chars": 89400,
"sessions_count": 5,
"sessions_limit": 8,
"quota_percentage": 62.5,
"estimated_cost_cents": 625
}
}
GET /usage/history
Get usage history across multiple periods.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | tenant_admin |
| Rate limit | Read |
Query parameters:
| Param | Type | Default | Description |
|---|---|---|---|
tenant_id | string | callerβs | Tenant filter |
from | string | 6 months ago | Start period (2026-01) |
to | string | current month | End period (2026-03) |
granularity | string | monthly | daily or monthly |
Response: 200 OK
{
"data": {
"tenant_id": "rabenheim",
"periods": [
{
"period": "2026-01",
"session_hours": 18.3,
"llm_tokens": 389000,
"stt_seconds": 21400,
"tts_chars": 134000,
"sessions_count": 7
},
{
"period": "2026-02",
"session_hours": 14.7,
"llm_tokens": 312000,
"stt_seconds": 17800,
"tts_chars": 105000,
"sessions_count": 6
}
]
}
}
GET /usage/breakdown
Per-session usage breakdown for a billing period.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | tenant_admin |
| Rate limit | Read |
Query parameters:
| Param | Type | Default | Description |
|---|---|---|---|
period | string | current month | Billing period |
Response: 200 OK
{
"data": {
"tenant_id": "rabenheim",
"period": "2026-03",
"sessions": [
{
"session_id": "sess_abc123",
"campaign_name": "Die Chroniken von Rabenheim",
"started_at": "2026-03-15T18:00:00Z",
"duration_seconds": 4980,
"session_hours": 1.38,
"llm_tokens": 45230,
"stt_seconds": 2840.5,
"tts_chars": 18420,
"estimated_cost_cents": 138
}
]
}
}
GET /usage/export
Export usage data as CSV.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | tenant_admin |
| Rate limit | 5/min |
Query parameters:
| Param | Type | Default | Description |
|---|---|---|---|
from | string | start of current month | Start date |
to | string | now | End date |
format | string | csv | Export format: csv or json |
Response: 200 OK
Content-Type: text/csv
Content-Disposition: attachment; filename="glyphoxa-usage-2026-03.csv"
session_id,campaign,started_at,duration_hours,llm_tokens,stt_seconds,tts_chars
sess_abc123,Die Chroniken von Rabenheim,2026-03-15T18:00:00Z,1.38,45230,2840.5,18420
6.5 Quota Management
GET /tenants/{tenant_id}/quota
Get current quota status for a tenant.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | tenant_admin |
| Rate limit | Read |
Response: 200 OK
{
"data": {
"tenant_id": "rabenheim",
"plan_id": "plan_adventurer",
"monthly_session_hours": 20,
"used_session_hours": 12.5,
"remaining_session_hours": 7.5,
"monthly_sessions": 8,
"used_sessions": 5,
"remaining_sessions": 3,
"can_start_session": true,
"quota_resets_at": "2026-04-01T00:00:00Z"
}
}
PUT /tenants/{tenant_id}/quota
Manually override a tenantβs quota (admin override).
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | super_admin |
| Rate limit | Write |
Request body:
{
"monthly_session_hours": 40,
"reason": "Compensating for outage on 2026-03-20"
}
Response: 200 OK β Updated quota object.
The override is synced to the gatewayβs Tenant.MonthlySessionHours field.
6.6 Stripe Webhook
POST /webhooks/stripe
Stripe webhook endpoint for payment events.
| Β | Β |
|---|---|
| Auth | Stripe signature verification (Stripe-Signature header) |
| Rate limit | Webhook tier |
Handled events:
| Event | Action |
|---|---|
checkout.session.completed | Activate subscription, update tenant plan |
invoice.paid | Record payment, reset period quota |
invoice.payment_failed | Mark subscription past_due, notify tenant admin |
customer.subscription.updated | Sync plan changes from Stripe |
customer.subscription.deleted | Mark subscription canceled, downgrade to free |
customer.subscription.trial_will_end | Send trial ending notification (3 days before) |
Response: 200 OK β {"received": true}
All events are idempotent β reprocessing the same event ID is a no-op.
7. Memory / Knowledge
These endpoints provide read/write access to the 3-layer memory system. Data lives in per-tenant PostgreSQL schemas. The management service connects directly using the existing pkg/memory/postgres store.
7.1 L1 β Transcript Queries
GET /campaigns/{campaign_id}/memory/transcripts
List sessions with transcript data for a campaign.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | viewer |
| Rate limit | Read |
Query parameters:
| Param | Type | Description |
|---|---|---|
limit | int | Max sessions to return (default: 10) |
Response: 200 OK
{
"data": [
{
"session_id": "sess_abc123",
"started_at": "2026-03-24T18:00:00Z",
"ended_at": "2026-03-24T19:23:00Z",
"entry_count": 312
}
]
}
GET /campaigns/{campaign_id}/memory/transcripts/{session_id}
Get transcript entries for a specific session from the memory store.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | viewer |
| Rate limit | Read |
Query parameters:
| Param | Type | Description |
|---|---|---|
after | datetime | Filter entries after timestamp |
before | datetime | Filter entries before timestamp |
speaker_id | string | Filter by speaker |
limit | int | Max entries (default: 100) |
Response: 200 OK β List of TranscriptEntry objects (same format as session transcript).
POST /campaigns/{campaign_id}/memory/transcripts/search
Full-text search across all transcripts in a campaign.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | viewer |
| Rate limit | Read |
Request body:
{
"query": "BΓΌrgermeister Korruption",
"session_id": "",
"after": "2026-01-01T00:00:00Z",
"before": "",
"speaker_id": "",
"limit": 50
}
Response: 200 OK
{
"data": {
"results": [
{
"session_id": "sess_abc123",
"speaker_name": "Heinrich der WΓ€chter",
"text": "Der BΓΌrgermeister? Frag nicht nach dem...",
"timestamp": "2026-03-15T19:15:30Z",
"relevance_score": 0.87
}
],
"total": 3
}
}
7.2 L2 β Semantic Search
POST /campaigns/{campaign_id}/memory/semantic-search
Search campaign memory using semantic similarity (vector search via pgvector).
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | dm |
| Rate limit | 10/min |
Request body:
{
"query": "What does Heinrich know about the mayor's corruption?",
"top_k": 10,
"filter": {
"session_id": "",
"speaker_id": "",
"entity_id": "",
"after": "2026-01-01T00:00:00Z",
"before": ""
}
}
| Field | Type | Required | Description |
|---|---|---|---|
query | string | yes | Natural language query (embedded server-side) |
top_k | int | no | Number of results (default: 10, max: 50) |
filter | object | no | Filter criteria (maps to ChunkFilter) |
Response: 200 OK
{
"data": {
"results": [
{
"chunk_id": "chk_abc123",
"session_id": "sess_abc123",
"content": "Heinrich flΓΌsterte: 'Der BΓΌrgermeister hat seine HΓ€nde...'",
"speaker_id": "npc_abc123",
"entity_id": "",
"topic": "corruption",
"distance": 0.15,
"timestamp": "2026-03-15T19:15:30Z"
}
]
}
}
7.3 L3 β Knowledge Graph
GET /campaigns/{campaign_id}/knowledge/entities
List entities in the campaignβs knowledge graph.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | viewer |
| Rate limit | Read |
Query parameters:
| Param | Type | Description |
|---|---|---|
type | string | Entity type (npc, player, location, item, faction, event, quest, concept) |
name | string | Name substring search |
attribute | string | Attribute filter (key:value format, e.g., alignment:chaotic evil) |
Response: 200 OK
{
"data": [
{
"id": "ent_abc123",
"type": "npc",
"name": "Heinrich der WΓ€chter",
"attributes": {
"alignment": "lawful neutral",
"race": "human",
"occupation": "city guard"
},
"created_at": "2026-03-10T10:00:00Z",
"updated_at": "2026-03-24T19:00:00Z"
}
],
"meta": { "page": 1, "per_page": 25, "total": 34 }
}
POST /campaigns/{campaign_id}/knowledge/entities
Create a new entity in the knowledge graph.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | dm |
| Rate limit | Write |
Request body:
{
"type": "location",
"name": "Der Rabe Taverne",
"attributes": {
"district": "Altstadt",
"owner": "Greta die Wirtin",
"description": "A dimly lit tavern in the old quarter..."
}
}
| Field | Type | Required | Description |
|---|---|---|---|
type | string | yes | Entity type |
name | string | yes | Entity name |
attributes | object | no | Arbitrary key-value metadata |
Response: 201 Created β Entity object.
GET /campaigns/{campaign_id}/knowledge/entities/{entity_id}
Get a single entity with its relationships.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | viewer |
| Rate limit | Read |
Query parameters:
| Param | Type | Default | Description |
|---|---|---|---|
include_relationships | bool | true | Include related entities |
relationship_depth | int | 1 | Depth of relationship traversal (max: 3) |
Response: 200 OK
{
"data": {
"entity": {
"id": "ent_abc123",
"type": "npc",
"name": "Heinrich der WΓ€chter",
"attributes": { ... },
"created_at": "2026-03-10T10:00:00Z",
"updated_at": "2026-03-24T19:00:00Z"
},
"relationships": [
{
"source_id": "ent_abc123",
"target_id": "ent_def456",
"target_name": "Greta die Wirtin",
"target_type": "npc",
"rel_type": "knows",
"attributes": { "closeness": "acquaintance" },
"provenance": {
"session_id": "sess_abc123",
"timestamp": "2026-03-15T19:00:00Z",
"confidence": 0.85,
"source": "inferred",
"dm_confirmed": false
}
},
{
"source_id": "ent_abc123",
"target_id": "ent_ghi789",
"target_name": "Stadtwache",
"target_type": "faction",
"rel_type": "member_of",
"attributes": { "rank": "Hauptmann" },
"provenance": {
"session_id": "",
"confidence": 1.0,
"source": "stated",
"dm_confirmed": true
}
}
]
}
}
PUT /campaigns/{campaign_id}/knowledge/entities/{entity_id}
Update entity attributes (merge).
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | dm |
| Rate limit | Write |
Request body:
{
"attributes": {
"alignment": "neutral neutral",
"new_attribute": "some value"
}
}
Response: 200 OK β Updated entity object.
DELETE /campaigns/{campaign_id}/knowledge/entities/{entity_id}
Delete an entity and all its relationships.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | dm |
| Rate limit | Write |
Response: 204 No Content
POST /campaigns/{campaign_id}/knowledge/relationships
Create a relationship between two entities.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | dm |
| Rate limit | Write |
Request body:
{
"source_id": "ent_abc123",
"target_id": "ent_def456",
"rel_type": "hates",
"attributes": {
"reason": "Greta refused to serve him after midnight"
},
"provenance": {
"confidence": 0.9,
"source": "stated",
"dm_confirmed": true
}
}
| Field | Type | Required | Description |
|---|---|---|---|
source_id | string | yes | Source entity ID |
target_id | string | yes | Target entity ID |
rel_type | string | yes | Relationship type (e.g., knows, hates, owns, member_of) |
attributes | object | no | Relationship metadata |
provenance | object | no | Source and confidence info |
Response: 201 Created
DELETE /campaigns/{campaign_id}/knowledge/relationships
Delete a specific relationship.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | dm |
| Rate limit | Write |
Query parameters:
| Param | Type | Required | Description |
|---|---|---|---|
source_id | string | yes | Source entity ID |
target_id | string | yes | Target entity ID |
rel_type | string | yes | Relationship type |
Response: 204 No Content
GET /campaigns/{campaign_id}/knowledge/subgraph/{npc_id}
Get the visible knowledge subgraph for a specific NPC. Returns all entities and relationships that this NPC would have access to (based on knowledge scope).
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | dm |
| Rate limit | Read |
Response: 200 OK
{
"data": {
"npc_id": "npc_abc123",
"npc_name": "Heinrich der WΓ€chter",
"entities": [ ... ],
"relationships": [ ... ]
}
}
Designed for rendering as a force-directed graph in the frontend.
GET /campaigns/{campaign_id}/knowledge/paths
Find the shortest path between two entities in the knowledge graph.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | dm |
| Rate limit | Read |
Query parameters:
| Param | Type | Required | Description |
|---|---|---|---|
from | string | yes | Source entity ID |
to | string | yes | Target entity ID |
max_depth | int | no | Maximum hops (default: 5) |
Response: 200 OK
{
"data": {
"path": [
{ "id": "ent_abc", "name": "Heinrich", "type": "npc" },
{ "id": "ent_def", "name": "Stadtwache", "type": "faction" },
{ "id": "ent_ghi", "name": "BΓΌrgermeister Krause", "type": "npc" }
],
"relationships": [
{ "source_id": "ent_abc", "target_id": "ent_def", "rel_type": "member_of" },
{ "source_id": "ent_ghi", "target_id": "ent_def", "rel_type": "controls" }
]
}
}
7.4 Memory Admin
POST /campaigns/{campaign_id}/memory/rebuild-indexes
Rebuild semantic search indexes (L2 pgvector indexes) for a campaign. Use after bulk imports or embedding model changes.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | tenant_admin |
| Rate limit | 1/hour |
Response: 202 Accepted
{
"data": {
"job_id": "job_abc123",
"status": "queued",
"estimated_duration_seconds": 300
}
}
DELETE /campaigns/{campaign_id}/memory
Clear all memory data (L1 + L2 + L3) for a campaign. Destructive and irreversible.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | tenant_admin |
| Rate limit | Write |
Request body:
{
"confirm": "DELETE rabenheim/cmp_abc123",
"layers": ["l1", "l2", "l3"]
}
| Field | Type | Required | Description |
|---|---|---|---|
confirm | string | yes | Confirmation string: DELETE {tenant_id}/{campaign_id} |
layers | string[] | no | Layers to clear (default: all). Options: l1, l2, l3 |
Response: 200 OK
{
"data": {
"cleared": {
"l1_entries": 1247,
"l2_chunks": 892,
"l3_entities": 34,
"l3_relationships": 78
}
}
}
8. Support
8.1 Third-Party Ticket Integration
The management service integrates with an external ticket system rather than building its own. The API provides a thin proxy layer that normalizes ticket CRUD across providers.
Supported providers: Freshdesk (planned), Linear (planned), GitHub Issues (planned).
The provider is configured via environment variable: GLYPHOXA_SUPPORT_PROVIDER=freshdesk
POST /support/tickets
Create a support ticket.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | viewer |
| Rate limit | 5/hour |
Request body:
{
"subject": "NPC voice not working after update",
"description": "After updating Heinrich's voice config, the voice preview returns...",
"priority": "normal",
"category": "bug",
"metadata": {
"tenant_id": "rabenheim",
"npc_id": "npc_abc123",
"browser": "Firefox 130",
"url": "/npcs/npc_abc123"
}
}
| Field | Type | Required | Description |
|---|---|---|---|
subject | string | yes | Ticket subject |
description | string | yes | Detailed description |
priority | string | no | low, normal, high, urgent (default: normal) |
category | string | no | bug, feature, question, billing |
metadata | object | no | Auto-populated context (tenant, browser, page URL) |
Response: 201 Created
{
"data": {
"id": "tkt_abc123",
"external_id": "FD-12345",
"external_url": "https://glyphoxa.freshdesk.com/support/tickets/12345",
"subject": "NPC voice not working after update",
"status": "open",
"priority": "normal",
"created_at": "2026-03-24T10:00:00Z"
}
}
GET /support/tickets
List support tickets for the current user.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | viewer |
| Rate limit | Read |
Query parameters:
| Param | Type | Description |
|---|---|---|
status | string | Filter by status: open, pending, resolved, closed |
Response: 200 OK β Paginated list of ticket objects.
GET /support/tickets/{ticket_id}
Get ticket details including conversation history.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | viewer (own tickets) / tenant_admin (tenantβs tickets) |
| Rate limit | Read |
Response: 200 OK
{
"data": {
"id": "tkt_abc123",
"external_id": "FD-12345",
"external_url": "https://glyphoxa.freshdesk.com/support/tickets/12345",
"subject": "NPC voice not working after update",
"description": "...",
"status": "pending",
"priority": "normal",
"messages": [
{
"author": "Support Agent",
"body": "Thanks for reporting this. Can you share the NPC configuration?",
"created_at": "2026-03-24T11:00:00Z"
}
],
"created_at": "2026-03-24T10:00:00Z",
"updated_at": "2026-03-24T11:00:00Z"
}
}
POST /support/tickets/{ticket_id}/reply
Add a reply to an existing ticket.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | viewer |
| Rate limit | 10/hour per ticket |
Request body:
{
"body": "Here's the NPC config: ..."
}
Response: 200 OK β Updated ticket object.
9. Admin / Observability
9.1 System Health
GET /admin/health
Aggregated health status across all components. Combines the management serviceβs own health with gateway health probes.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | super_admin |
| Rate limit | Read |
Response: 200 OK
{
"data": {
"status": "healthy",
"components": {
"management_db": { "status": "ok", "latency_ms": 2 },
"gateway": { "status": "ok", "url": "http://gateway:8081", "latency_ms": 5 },
"gateway_db": { "status": "ok", "latency_ms": 3 },
"stripe": { "status": "ok", "latency_ms": 120 },
"redis": { "status": "ok", "latency_ms": 1 }
},
"version": "0.12.0",
"build_sha": "2d28fc0",
"uptime_seconds": 86400
}
}
GET /admin/health/gateway
Proxy to the gatewayβs /healthz and /readyz endpoints.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | super_admin |
| Rate limit | Read |
Response: 200 OK
{
"data": {
"liveness": { "status": "ok" },
"readiness": {
"status": "ok",
"checks": {
"database": "ok",
"providers": "ok"
}
}
}
}
9.2 OpenTelemetry Dashboard Proxy
GET /admin/metrics
Proxy to the gatewayβs Prometheus metrics endpoint. Returns raw Prometheus exposition format for dashboard consumption.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | super_admin |
| Rate limit | 30/min |
Response: 200 OK
Content-Type: text/plain; version=0.0.4; charset=utf-8
# HELP glyphoxa_sessions_active Number of active voice sessions
# TYPE glyphoxa_sessions_active gauge
glyphoxa_sessions_active{tenant_id="rabenheim"} 1
...
GET /admin/metrics/summary
Pre-aggregated metrics summary for the admin dashboard. Avoids the frontend having to parse Prometheus format.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | super_admin |
| Rate limit | Read |
Response: 200 OK
{
"data": {
"active_sessions": 2,
"total_sessions_today": 5,
"total_tenants": 3,
"total_users": 12,
"total_npcs": 24,
"provider_health": {
"llm": { "provider": "openai", "status": "healthy", "p50_ms": 340, "p99_ms": 1200 },
"stt": { "provider": "deepgram", "status": "healthy", "p50_ms": 145, "p99_ms": 380 },
"tts": { "provider": "elevenlabs", "status": "healthy", "p50_ms": 280, "p99_ms": 850 }
},
"usage_this_month": {
"total_session_hours": 47.3,
"total_llm_tokens": 1245000,
"total_stt_seconds": 68400,
"total_tts_chars": 425000
},
"error_rate_24h": 0.02
}
}
9.3 Billing Reports
GET /admin/billing/report
Aggregated billing report across all tenants.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | super_admin |
| Rate limit | Read |
Query parameters:
| Param | Type | Default | Description |
|---|---|---|---|
period | string | current month | Billing period (2026-03) |
Response: 200 OK
{
"data": {
"period": "2026-03",
"summary": {
"total_revenue_cents": 4700,
"total_cost_cents": 1850,
"margin_percentage": 60.6,
"active_subscriptions": 3,
"churned_subscriptions": 0,
"new_subscriptions": 1
},
"by_plan": [
{
"plan_id": "plan_apprentice",
"plan_name": "Apprentice",
"subscriber_count": 1,
"revenue_cents": 0
},
{
"plan_id": "plan_adventurer",
"plan_name": "Adventurer",
"subscriber_count": 2,
"revenue_cents": 1800
},
{
"plan_id": "plan_dungeon_master",
"plan_name": "Dungeon Master",
"subscriber_count": 1,
"revenue_cents": 1900
}
],
"by_tenant": [
{
"tenant_id": "rabenheim",
"plan_name": "Dungeon Master",
"revenue_cents": 1900,
"cost_cents": 820,
"session_hours": 18.3,
"sessions": 7
}
]
}
}
GET /admin/billing/mrr
Monthly Recurring Revenue (MRR) trend.
| Β | Β |
|---|---|
| Auth | JWT |
| Min role | super_admin |
| Rate limit | Read |
Query parameters:
| Param | Type | Default | Description |
|---|---|---|---|
months | int | 12 | Number of months of history |
Response: 200 OK
{
"data": {
"current_mrr_cents": 4700,
"trend": [
{ "month": "2026-01", "mrr_cents": 1900, "subscribers": 1 },
{ "month": "2026-02", "mrr_cents": 2800, "subscribers": 2 },
{ "month": "2026-03", "mrr_cents": 4700, "subscribers": 3 }
]
}
}
Endpoint Summary
Public Endpoints (no auth)
| Method | Path | Description |
|---|---|---|
| GET | /auth/discord | Initiate Discord OAuth2 |
| GET | /auth/discord/callback | Discord OAuth2 callback |
| GET | /auth/google | Initiate Google OAuth2 |
| GET | /auth/google/callback | Google OAuth2 callback |
| POST | /auth/token | Exchange credentials for JWT |
| POST | /auth/refresh | Refresh access token |
| GET | /plans | List public subscription plans |
| POST | /webhooks/stripe | Stripe payment webhooks |
Auth Required β viewer+
| Method | Path | Description |
|---|---|---|
| POST | /auth/revoke | Revoke refresh token |
| GET | /users/me | Get own profile |
| PATCH | /users/me/preferences | Update preferences |
| GET | /tenants/{id} | Get own tenant |
| GET | /tenants/{id}/campaigns | List campaigns |
| GET | /campaigns/{id} | Get campaign |
| GET | /campaigns/{id}/npcs | List NPCs |
| GET | /npcs/{id} | Get NPC |
| GET | /sessions | List sessions |
| GET | /sessions/active | List active sessions |
| GET | /sessions/{id} | Get session |
| GET | /sessions/{id}/transcript | Get transcript |
| WS | /sessions/{id}/live | Live session stream |
| GET | /campaigns/{id}/memory/transcripts | List transcript sessions |
| GET | /campaigns/{id}/memory/transcripts/{sid} | Get session transcript |
| POST | /campaigns/{id}/memory/transcripts/search | Search transcripts |
| GET | /campaigns/{id}/knowledge/entities | List entities |
| GET | /campaigns/{id}/knowledge/entities/{eid} | Get entity |
| GET | /campaigns/{id}/knowledge/paths | Find path |
| GET | /npc-templates | List NPC templates |
| POST | /support/tickets | Create support ticket |
| GET | /support/tickets | List own tickets |
| GET | /support/tickets/{id} | Get ticket |
| POST | /support/tickets/{id}/reply | Reply to ticket |
Auth Required β dm+
| Method | Path | Description |
|---|---|---|
| POST | /tenants/{id}/campaigns | Create campaign |
| PUT | /campaigns/{id} | Update campaign |
| POST | /campaigns/{id}/npcs | Create NPC |
| POST | /campaigns/{id}/npcs/from-template | Create NPC from template |
| POST | /campaigns/{id}/npcs/{nid} | Link NPC to campaign |
| DELETE | /campaigns/{id}/npcs/{nid} | Unlink NPC |
| PUT | /npcs/{id} | Update NPC |
| DELETE | /npcs/{id} | Delete NPC |
| POST | /npcs/{id}/voice-preview | Voice preview |
| POST | /sessions/{id}/stop | Force-stop session |
| POST | /campaigns/{id}/memory/semantic-search | Semantic search |
| POST | /campaigns/{id}/knowledge/entities | Create entity |
| PUT | /campaigns/{id}/knowledge/entities/{eid} | Update entity |
| DELETE | /campaigns/{id}/knowledge/entities/{eid} | Delete entity |
| POST | /campaigns/{id}/knowledge/relationships | Create relationship |
| DELETE | /campaigns/{id}/knowledge/relationships | Delete relationship |
| GET | /campaigns/{id}/knowledge/subgraph/{nid} | NPC knowledge subgraph |
Auth Required β tenant_admin+
| Method | Path | Description |
|---|---|---|
| PUT | /tenants/{id} | Update own tenant |
| DELETE | /campaigns/{id} | Delete campaign |
| POST | /users | Create user |
| GET | /users | List users |
| PUT | /users/{id} | Update user |
| DELETE | /users/{id} | Delete user |
| GET | /tenants/{id}/provider-keys | List provider keys |
| PUT | /tenants/{id}/provider-keys/{type} | Set provider key |
| DELETE | /tenants/{id}/provider-keys/{type} | Remove provider key |
| POST | /tenants/{id}/provider-keys/{type}/verify | Verify provider key |
| GET | /tenants/{id}/settings | Get tenant settings |
| PATCH | /tenants/{id}/settings | Update tenant settings |
| GET | /subscriptions/current | Get subscription |
| POST | /subscriptions | Create subscription |
| POST | /subscriptions/change-plan | Change plan |
| POST | /subscriptions/cancel | Cancel subscription |
| POST | /subscriptions/resume | Resume subscription |
| GET | /usage | Usage overview |
| GET | /usage/history | Usage history |
| GET | /usage/breakdown | Per-session breakdown |
| GET | /usage/export | Export usage CSV |
| GET | /tenants/{id}/quota | Get quota |
| POST | /npcs/{id}/voice-samples | Upload voice samples |
| GET | /npcs/{id}/voice-samples/{jid} | Check voice job |
| POST | /campaigns/{id}/memory/rebuild-indexes | Rebuild search indexes |
| DELETE | /campaigns/{id}/memory | Clear memory |
Auth Required β super_admin
| Method | Path | Description |
|---|---|---|
| POST | /tenants | Create tenant |
| GET | /tenants | List all tenants |
| DELETE | /tenants/{id} | Delete tenant |
| PUT | /tenants/{id}/quota | Override quota |
| POST | /admin/plans | Create plan |
| GET | /admin/plans | List all plans |
| GET | /admin/plans/{id} | Get plan |
| PUT | /admin/plans/{id} | Update plan |
| DELETE | /admin/plans/{id} | Archive plan |
| GET | /admin/health | System health |
| GET | /admin/health/gateway | Gateway health |
| GET | /admin/metrics | Prometheus metrics |
| GET | /admin/metrics/summary | Metrics summary |
| GET | /admin/billing/report | Billing report |
| GET | /admin/billing/mrr | MRR trend |
Database Schema (Management Service)
The management service has its own PostgreSQL database, separate from the gatewayβs database. It connects to the gatewayβs DB in read-only mode for session and NPC data.
-- Users (management DB)
CREATE TABLE users (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
discord_id TEXT UNIQUE,
google_id TEXT UNIQUE,
email TEXT,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer',
preferences JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Refresh tokens (management DB)
CREATE TABLE refresh_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Campaigns (management DB)
CREATE TABLE campaigns (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
name TEXT NOT NULL,
system TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
lore TEXT NOT NULL DEFAULT '',
settings JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Tenant extensions (management DB β supplements gateway tenant data)
CREATE TABLE tenant_profiles (
tenant_id TEXT PRIMARY KEY,
plan_id TEXT REFERENCES subscription_plans(id),
display_name TEXT NOT NULL DEFAULT '',
contact_email TEXT NOT NULL DEFAULT '',
settings JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Provider keys (management DB β encrypted via Vault)
CREATE TABLE provider_keys (
id BIGSERIAL PRIMARY KEY,
tenant_id TEXT NOT NULL,
provider_type TEXT NOT NULL,
provider_name TEXT NOT NULL,
api_key TEXT NOT NULL, -- vault-encrypted
base_url TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
options JSONB NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'active',
last_verified TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(tenant_id, provider_type)
);
-- Subscription plans (management DB)
CREATE TABLE subscription_plans (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
price_monthly_cents INT NOT NULL DEFAULT 0,
price_yearly_cents INT NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'eur',
features JSONB NOT NULL DEFAULT '{}',
stripe_price_id_monthly TEXT,
stripe_price_id_yearly TEXT,
visible BOOLEAN NOT NULL DEFAULT true,
archived BOOLEAN NOT NULL DEFAULT false,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Subscriptions (management DB)
CREATE TABLE subscriptions (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL UNIQUE,
plan_id TEXT NOT NULL REFERENCES subscription_plans(id),
status TEXT NOT NULL DEFAULT 'active',
billing_cycle TEXT NOT NULL DEFAULT 'monthly',
current_period_start TIMESTAMPTZ NOT NULL,
current_period_end TIMESTAMPTZ NOT NULL,
cancel_at_period_end BOOLEAN NOT NULL DEFAULT false,
cancellation_reason TEXT,
cancellation_feedback TEXT,
stripe_subscription_id TEXT UNIQUE,
stripe_customer_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Support tickets (management DB β thin layer over external system)
CREATE TABLE support_tickets (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
tenant_id TEXT NOT NULL,
external_id TEXT,
external_url TEXT,
subject TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
priority TEXT NOT NULL DEFAULT 'normal',
category TEXT,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- NPC templates (management DB β platform-wide, not per-tenant)
CREATE TABLE npc_templates (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
system TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
config JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Indexes
CREATE INDEX idx_users_tenant ON users(tenant_id);
CREATE INDEX idx_users_discord ON users(discord_id);
CREATE INDEX idx_users_google ON users(google_id);
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_expires ON refresh_tokens(expires_at);
CREATE INDEX idx_campaigns_tenant ON campaigns(tenant_id);
CREATE INDEX idx_provider_keys_tenant ON provider_keys(tenant_id);
CREATE INDEX idx_subscriptions_stripe ON subscriptions(stripe_subscription_id);
CREATE INDEX idx_support_tickets_user ON support_tickets(user_id);
CREATE INDEX idx_support_tickets_tenant ON support_tickets(tenant_id);
Error Codes
Standardized error codes used across all endpoints:
| Code | HTTP Status | Description |
|---|---|---|
validation_error | 400 | Request body failed validation |
invalid_json | 400 | Malformed JSON in request body |
missing_field | 400 | Required field missing |
unauthorized | 401 | Missing or expired auth token |
forbidden | 403 | Insufficient role/permissions |
not_found | 404 | Resource not found |
conflict | 409 | Resource already exists or constraint violated |
rate_limited | 429 | Too many requests |
quota_exceeded | 402 | Tenant usage quota exceeded |
payment_required | 402 | Subscription inactive or past due |
provider_error | 502 | Upstream provider (TTS/LLM/STT) failed |
gateway_error | 502 | Gateway Admin API unreachable or returned error |
internal_error | 500 | Unhandled server error |
Open Design Questions
-
Gateway communication: HTTP client vs gRPC? The gateway currently only exposes HTTP. Adding gRPC would be more efficient but requires gateway changes. Recommend HTTP for MVP, gRPC later if latency becomes an issue.
-
Cache layer: Should the management service cache gateway responses (tenant data, session lists)? A Redis cache with short TTLs (5-30s) would reduce gateway load but adds operational complexity. Defer to Phase 2 unless load requires it.
-
Webhook reliability: Stripe webhooks need guaranteed delivery. Use a
webhook_eventstable for idempotent processing with status tracking (pending,processed,failed). -
Tenant provisioning flow: When a new user signs up via OAuth2 and creates a subscription, the full flow is: create user β create subscription β create tenant (via gateway) β create campaign. Should this be a single transactional endpoint or separate steps? Recommend a
POST /onboardingorchestration endpoint. -
Multi-tenant NPC access: The management service needs to read/write NPC data across tenant schemas. Options: (a) connect with a superuser role that can switch schemas, (b) maintain a connection pool per tenant schema. Recommend (a) for simplicity, with schema name validated against the tenant registry.