Operator / Multi-tenant Self-hosting
When you run Impri as a shared platform for multiple teams or users, you need visibility into the whole instance — not just your own project. The operator endpoint provides platform-wide stats without exposing individual project data.
Setup
Set the OPERATOR_PROJECT_ID environment variable to the ID of the project whose admin key you will use for operator calls:
OPERATOR_PROJECT_ID=prj_your_project_idAny admin key belonging to that project can call the operator stats endpoint. All other keys (including admin keys from other projects) receive 404 Not Found — the endpoint is invisible to non-operators.
GET /v1/admin/stats — platform totals
GET /v1/admin/stats HTTP/1.1
Authorization: Bearer im_<operator-admin-key>Response:
{
"signups": {
"total": 142,
"last_24h": 3,
"last_7d": 18,
"last_30d": 61
},
"by_tier": {
"free": 115,
"indie": 22,
"team": 5
},
"paid": 27,
"activity": {
"actions_total": 9842,
"actions_7d": 1203,
"watchers": 88
},
"funnel": {
"signed_up": 142,
"created_api_key": 98,
"first_action": 71,
"first_decision": 54,
"demo_decided": 61,
"integration_connected": 22,
"paid": 27,
"activated_last_7d": 18,
"activated_last_30d": 39
},
"ts": 1720000000
}Fields:
| Field | Description |
|---|---|
signups.total |
Total number of projects (one per signup) |
signups.last_24h/7d/30d |
Projects created in the last N days |
by_tier |
Breakdown of projects by current tier |
paid |
Count of indie + team projects |
activity.actions_total |
Total actions ever created across all projects |
activity.actions_7d |
Actions created in the last 7 days |
activity.watchers |
Active (non-paused) watchers across all projects |
funnel |
Activation funnel — see below |
ts |
Unix timestamp of the response |
funnel — activation funnel
Every step below counts distinct projects, and every step excludes the
operator's own project (OPERATOR_PROJECT_ID) so dogfooding never inflates
the numbers.
| Field | Meaning |
|---|---|
signed_up |
Projects (excluding the operator project) |
created_api_key |
Projects with at least one API key (any scope, revoked or not) |
first_action |
Projects with at least one real action — see below |
first_decision |
Projects with at least one human decision on a real action — see below |
demo_decided |
Projects that decided their onboarding demo action (any decision, human or auto) — the onboarding "aha" signal, tracked separately, see below |
integration_connected |
Projects with at least one notification channel (Slack, Discord, Telegram, ntfy, email or webhook) |
paid |
Projects with tier != 'free' |
activated_last_7d / activated_last_30d |
Projects with at least one human decision on a real action in the last 7 / 30 days |
The funnel is deliberately monotonic — each step is a subset of projects that could have reached the previous one — so two definitions are narrower than "any row exists":
- "Real" action (
first_action, and the action side offirst_decision/activated_last_7d/30d) excludes the built-in demo/test actions — the onboarding "Send a test approval" button (ui/src/components/GettingStarted.vue) creates an action withkind = 'demo', andimpri init --demo(the CLI onboarding wizard) createskind = 'demo.email'/kind = 'demo.publish'. Both are excluded by akind = 'demo' OR kind LIKE 'demo.%'filter so clicking the onboarding button once doesn't count as activation. This kind is reliably distinguishable, so there is no separatefirst_real_actionfield —first_actionalready means "real". Without this filter, a project that only ever decided its demo action could show up as MORE activated (first_decision/activated_*) than one that took a real action but hadn't decided it yet — that onboarding signal is real, but it isn't the same thing as activation, so it's tracked separately asdemo_decidedinstead. - "Human" decision (
first_decision,activated_last_7d/30d) excludes decisions made by the rules engine:auto_approve/auto_rejectoutcomes are written withdecisions.channel = 'auto'(seeserver/src/routes/actions.ts), and a rule firing automatically is not a person doing anything.demo_decideddoes NOT apply this filter — deciding the demo action at all (even via a rule) still means the project ran through onboarding.
POST /v1/admin/comp-tier — grant a tier without Stripe
Sets a project's tier directly, bypassing checkout. For comping accounts — the operator's own working project, a beta tester, a partner — where a real subscription doesn't apply.
POST /v1/admin/comp-tier HTTP/1.1
Authorization: Bearer im_<operator-admin-key>
Content-Type: application/json
{
"project_id": "proj_target",
"tier": "team",
"expires_in": 315360000
}| Field | Required | Description |
|---|---|---|
project_id |
yes | The project to grant the tier to (not the caller's own project) |
tier |
yes | free, indie, or team |
expires_in |
no | Seconds until current_period_end. Cosmetic only — display field, never enforced. Capped at 10 years. Omit for no expiry. |
Response is the updated project row: { "id", "tier", "subscription_status": "comped", "current_period_end" }.
The grant is stable against Stripe: the webhook handler only ever updates a
project matched by stripe_customer_id, so a project that has never been
through checkout (the common case for a comped account) is never touched by a
later webhook event. Recorded as an admin.tier_comped event in the
target project's own audit log — its owner can see who granted the tier
and when.
Security notes
- Both operator endpoints return
404 Not Foundfor all keys that do not belong toOPERATOR_PROJECT_ID, regardless of scope. Neither is discoverable. GET /v1/admin/statsexposes no individual project data — only platform-level aggregate counts.- Even operator keys cannot read another project's actions, decisions, or audit log.
POST /v1/admin/comp-tieris rate-limited to 10 requests/min per key.- Set
OPERATOR_PROJECT_IDto a dedicated operator project — do not reuse a user-facing project for this.
Usage example (Python)
import os
from impri import ImpriClient
operator_key = os.environ["OPERATOR_API_KEY"] # admin key for OPERATOR_PROJECT_ID
client = ImpriClient(api_key=operator_key, base_url="http://localhost:8484")
# The SDK doesn't have a dedicated method — call the raw endpoint
import urllib.request, json, os
req = urllib.request.Request(
"http://localhost:8484/v1/admin/stats",
headers={"Authorization": f"Bearer {operator_key}"},
)
with urllib.request.urlopen(req) as resp:
stats = json.loads(resp.read())
print(f"Total signups: {stats['signups']['total']}")
print(f"Paid projects: {stats['paid']}")
print(f"Actions this week: {stats['activity']['actions_7d']}")Environment variables for multi-tenant operation
| Variable | Purpose |
|---|---|
OPERATOR_PROJECT_ID |
Unlocks GET /v1/admin/stats for that project's admin keys |
ALLOW_SIGNUP |
When set to 1 or true, enables POST /v1/signup for self-serve project creation |
DB_PATH |
SQLite database path (default: data/impri.db) |
BASE_URL |
Public base URL shown in inbox links |
See Self-hosting for the full environment variable reference.