diff --git a/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx
index 1c9e4b6b197..2f7b8d526b9 100644
--- a/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx
+++ b/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx
@@ -188,6 +188,18 @@ Your reverse proxy's body-size limit must be at least as large as the app limits
See [Observability](/platform/self-hosting/observability).
+## Usage telemetry
+
+Off unless all four required variables are set. See [Usage Telemetry](/platform/self-hosting/usage-telemetry).
+
+| Variable | Description |
+|----------|-------------|
+| `ONPREM_TELEMETRY_ENABLED` | `true` to report daily usage to a Sim instance. Leave unset on airgapped deployments |
+| `ONPREM_TELEMETRY_ENDPOINT` | Base URL of the receiving instance |
+| `ONPREM_TELEMETRY_DEPLOYMENT_ID` | Deployment id issued by the receiving instance's admin API |
+| `ONPREM_TELEMETRY_API_KEY` | Deployment API key issued alongside the id |
+| `ONPREM_TELEMETRY_LOOKBACK_DAYS` | Trailing days each run re-sends. Defaults to `7`, max `90` |
+
## Knowledge Bases
| Variable | Description |
diff --git a/apps/docs/content/docs/platform/self-hosting/meta.json b/apps/docs/content/docs/platform/self-hosting/meta.json
index 856966682e4..db4a259b31a 100644
--- a/apps/docs/content/docs/platform/self-hosting/meta.json
+++ b/apps/docs/content/docs/platform/self-hosting/meta.json
@@ -22,6 +22,7 @@
"desktop",
"---Operate---",
"observability",
+ "usage-telemetry",
"scaling",
"upgrades",
"troubleshooting"
diff --git a/apps/docs/content/docs/platform/self-hosting/usage-telemetry.mdx b/apps/docs/content/docs/platform/self-hosting/usage-telemetry.mdx
new file mode 100644
index 00000000000..5bc1ab4e594
--- /dev/null
+++ b/apps/docs/content/docs/platform/self-hosting/usage-telemetry.mdx
@@ -0,0 +1,106 @@
+---
+title: Usage Telemetry
+description: Report daily workflow and credit usage from a self-hosted deployment to a Sim instance
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+
+A self-hosted deployment can report a daily summary of its usage — workflow runs, credits, and model token volume — to a Sim instance, so that instance can see how the deployment is used over time and value that usage at a rate agreed for the deployment.
+
+The feature is **off by default**. Nothing is collected or sent until the four variables below are set, and the workflow execution path never touches it: reporting runs from a background job that reads the deployment's own database after the fact.
+
+## What is sent
+
+One record per UTC calendar day, re-sent for a trailing window on every run so a day converges on its final figures once it has elapsed. Each record carries:
+
+| Field | Source |
+|-------|--------|
+| Workflow executions, failures, total duration | Execution logs |
+| Credits | The usage ledger's dollar sum for the day × 200 |
+| Input and output tokens, per model | The usage ledger's token metadata |
+| Events and credits per usage source | The usage ledger (`workflow`, `knowledge-base`, `wand`, …) |
+
+Nothing else crosses the wire: no workflow inputs or outputs, no credentials, no workflow or user identifiers, and no tool or block names. Aggregation happens inside the database query, so per-execution rows never reach the reporting code.
+
+
+ On a self-hosted deployment, models usually run on your own API keys, so their ledger rows carry **tokens with zero credits**. Credits on such a deployment mostly reflect the per-run execution charge (1 credit per workflow run); token volume is reported alongside so the receiving instance can see model usage that Sim never billed.
+
+
+## Enable reporting
+
+Register the deployment on the receiving instance (see [Receiving reports](#receiving-reports) below), then set on the self-hosted deployment:
+
+```bash
+ONPREM_TELEMETRY_ENABLED=true
+ONPREM_TELEMETRY_ENDPOINT=https://sim.example.com # the receiving instance
+ONPREM_TELEMETRY_DEPLOYMENT_ID=acme-prod # issued at registration
+ONPREM_TELEMETRY_API_KEY=simot_… # issued at registration, shown once
+```
+
+Optional:
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `ONPREM_TELEMETRY_LOOKBACK_DAYS` | `7` | How many trailing days each run re-sends (max 90). Bounds how long the receiving instance can be unreachable before a day is missed. |
+
+The report runs from the `onprem-usage-report` background job every six hours (Docker Compose: `docker/crontab`; Kubernetes: `cronjobs.jobs.onpremUsageReport`). To send immediately:
+
+```bash
+curl -H "Authorization: Bearer $CRON_SECRET" https://your-deployment/api/cron/onprem-usage-report
+```
+
+The response reports `delivered`, `failed` (with the reason), or `disabled`.
+
+## Disable reporting
+
+Unset `ONPREM_TELEMETRY_ENABLED` (or set it to `false`). The next job run exits before running any query or opening any connection, and stays that way until re-enabled. No restart is needed. This is the right setting for airgapped deployments; the job itself is harmless to leave scheduled.
+
+A deployment that is enabled but cannot reach the endpoint keeps running workflows normally. The job logs the failure and re-sends the same window next time.
+
+## Receiving reports
+
+The receiving instance is any Sim deployment with the [admin API](/platform/self-hosting/environment-variables) enabled. Register a deployment and (optionally) its rate in one call:
+
+```bash
+curl -X POST https://sim.example.com/api/v1/admin/onprem-telemetry/deployments \
+ -H "x-admin-key: $ADMIN_API_KEY" -H "Content-Type: application/json" \
+ -d '{"id": "acme-prod", "name": "Acme production", "usdPerCredit": 0.005}'
+```
+
+The response includes `apiKey` exactly once; only its hash is stored.
+
+### Conversion rates
+
+Each deployment has its own append-only history of credit → dollar rates:
+
+```bash
+# Set a new rate, effective now
+curl -X POST https://sim.example.com/api/v1/admin/onprem-telemetry/deployments/acme-prod/rates \
+ -H "x-admin-key: $ADMIN_API_KEY" -H "Content-Type: application/json" \
+ -d '{"usdPerCredit": 0.004}'
+
+# Backdate a correction
+curl -X POST … -d '{"usdPerCredit": 0.0045, "effectiveFrom": "2026-09-01T00:00:00Z"}'
+```
+
+Rates are never edited or deleted. Credits are the stored fact; dollars are computed when usage is read, from the rate whose `effectiveFrom` most recently precedes each day's start. That defines what a rate change does to history:
+
+- A rate effective **now** changes the value of days from now on. Earlier days keep the rate that covered them.
+- A rate effective at an **earlier instant** re-values the days from that instant forward the next time they are read. No credit figure changes, and the rate table shows what applied when.
+- Days before a deployment's first rate have no dollar value; the usage response counts their credits as `unvaluedCredits` rather than pricing them silently.
+
+### Reading usage
+
+```bash
+curl "https://sim.example.com/api/v1/admin/onprem-telemetry/deployments/acme-prod/usage?from=2026-09-01T00:00:00Z&to=2026-10-01T00:00:00Z" \
+ -H "x-admin-key: $ADMIN_API_KEY"
+```
+
+Every row states its `credits`, the `rate` it was valued with (`usdPerCredit` and `effectiveFrom`), and the resulting `usd`, plus the per-source and per-model breakdown the deployment sent. Totals cover the range. `GET …/deployments` lists deployments with their current rate and last report time; `GET …/deployments/{id}/rates` shows the rate history.
+
+## Limitations
+
+- **Cooperative, not enforced.** The deployment controls its own database and can disable reporting; nothing here proves completeness. It is a usage picture, not a metering system.
+- **Chat usage is not included.** With billing disabled, cost callbacks from the Sim agent service are acknowledged without being recorded, so Chat model usage never reaches the ledger on a self-hosted deployment.
+- **The current day is partial** until the first run after UTC midnight; `reportedAt` on each row says when it was last sent.
+- **Ledger scan.** Each run aggregates the trailing window from `usage_log` by `created_at`. On a very large deployment consider adding an index on `usage_log (created_at)`.
diff --git a/apps/sim/.env.example b/apps/sim/.env.example
index 33b321ba57d..4d75d0f825b 100644
--- a/apps/sim/.env.example
+++ b/apps/sim/.env.example
@@ -192,6 +192,15 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
# Admin API (Optional - for self-hosted GitOps)
# ADMIN_API_KEY= # Use `openssl rand -hex 32` to generate. Enables admin API for workflow export/import.
+
+# On-prem usage telemetry (Optional). Reports daily workflow counts, credits and
+# token volume for this deployment to a Sim instance. Off unless all four are set;
+# leave unset on airgapped deployments. See docs: Self-Hosting → Usage telemetry.
+# ONPREM_TELEMETRY_ENABLED=true
+# ONPREM_TELEMETRY_ENDPOINT=https://sim.ai # Base URL of the receiving instance
+# ONPREM_TELEMETRY_DEPLOYMENT_ID= # Issued by the receiving instance's admin API
+# ONPREM_TELEMETRY_API_KEY= # Issued alongside the id; shown once
+# ONPREM_TELEMETRY_LOOKBACK_DAYS=7 # Trailing days re-sent each run (max 90)
# Usage: curl -H "x-admin-key: your_key" https://your-instance/api/v1/admin/workspaces
# Enterprise Features (Optional - self-hosted). One switch enables organizations, SSO,
diff --git a/apps/sim/app/api/cron/onprem-usage-report/route.test.ts b/apps/sim/app/api/cron/onprem-usage-report/route.test.ts
new file mode 100644
index 00000000000..b8059fcd71e
--- /dev/null
+++ b/apps/sim/app/api/cron/onprem-usage-report/route.test.ts
@@ -0,0 +1,41 @@
+import { createMockRequest } from '@sim/testing'
+import { authInternalMock, authInternalMockFns } from '@sim/testing/mocks/auth-internal.mock'
+import { createMockFetch } from '@sim/testing/mocks/fetch.mock'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { env } from '@/lib/core/config/env'
+
+vi.mock('@/lib/auth/internal', () => authInternalMock)
+
+import { GET } from '@/app/api/cron/onprem-usage-report/route'
+
+describe('GET /api/cron/onprem-usage-report', () => {
+ let fetchMock: ReturnType
+
+ beforeEach(() => {
+ env.ONPREM_TELEMETRY_ENABLED = undefined
+ fetchMock = createMockFetch({ json: { accepted: 0 } })
+ vi.stubGlobal('fetch', fetchMock)
+ authInternalMockFns.mockVerifyCronAuth.mockReturnValue(null)
+ })
+
+ afterEach(() => vi.unstubAllGlobals())
+
+ it('requires cron authentication', async () => {
+ authInternalMockFns.mockVerifyCronAuth.mockReturnValueOnce(
+ new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 })
+ )
+ const response = await GET(createMockRequest('GET'))
+ expect(response.status).toBe(401)
+ })
+
+ it('answers disabled without any outbound request when telemetry is off', async () => {
+ const response = await GET(createMockRequest('GET'))
+ expect(response.status).toBe(200)
+ await expect(response.json()).resolves.toEqual({
+ success: true,
+ status: 'disabled',
+ reason: 'ONPREM_TELEMETRY_ENABLED is not set',
+ })
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/api/cron/onprem-usage-report/route.ts b/apps/sim/app/api/cron/onprem-usage-report/route.ts
new file mode 100644
index 00000000000..384db0b15b3
--- /dev/null
+++ b/apps/sim/app/api/cron/onprem-usage-report/route.ts
@@ -0,0 +1,34 @@
+import { createLogger } from '@sim/logger'
+import { type NextRequest, NextResponse } from 'next/server'
+import { verifyCronAuth } from '@/lib/auth/internal'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { runOnPremUsageReport } from '@/lib/onprem-telemetry/report'
+
+const logger = createLogger('OnPremUsageReportCron')
+
+export const dynamic = 'force-dynamic'
+
+/**
+ * Cron endpoint that reports this deployment's usage to the configured Sim
+ * instance. Scheduled in helm/sim/values.yaml (`cronjobs.jobs.onpremUsageReport`)
+ * and docker/crontab.
+ *
+ * The whole feature lives behind this endpoint: it is the only caller of the
+ * reporter, and nothing on the workflow execution path imports it. When
+ * `ONPREM_TELEMETRY_ENABLED` is unset the reporter returns before touching the
+ * database or the network. Re-reports are idempotent on the receiver, so no
+ * lock is needed to keep overlapping runs or multiple replicas correct.
+ */
+export const GET = withRouteHandler(async (request: NextRequest) => {
+ const authError = verifyCronAuth(request, 'On-prem usage report')
+ if (authError) return authError
+
+ const result = await runOnPremUsageReport()
+
+ if (result.status === 'failed') {
+ logger.warn('On-prem usage report run failed', result)
+ return NextResponse.json({ success: false, ...result }, { status: 502 })
+ }
+
+ return NextResponse.json({ success: true, ...result })
+})
diff --git a/apps/sim/app/api/onprem-telemetry/report/route.test.ts b/apps/sim/app/api/onprem-telemetry/report/route.test.ts
new file mode 100644
index 00000000000..925dd319367
--- /dev/null
+++ b/apps/sim/app/api/onprem-telemetry/report/route.test.ts
@@ -0,0 +1,97 @@
+import { sha256Hex } from '@sim/security/hash'
+import {
+ createMockRequest,
+ dbChainMockFns,
+ queueTableRows,
+ resetDbChainMock,
+ schemaMock,
+} from '@sim/testing'
+import { beforeEach, describe, expect, it } from 'vitest'
+import { POST } from '@/app/api/onprem-telemetry/report/route'
+
+const API_KEY = 'simot_test_key'
+const URL = 'http://localhost:3000/api/onprem-telemetry/report'
+
+const bucket = {
+ periodStart: '2026-09-26T00:00:00.000Z',
+ periodEnd: '2026-09-27T00:00:00.000Z',
+ workflowExecutions: 4,
+ workflowExecutionsFailed: 1,
+ workflowDurationMs: 1000,
+ credits: 4,
+ inputTokens: 10,
+ outputTokens: 5,
+ sources: [{ source: 'workflow', category: 'fixed', events: 4, credits: 4 }],
+ models: [],
+}
+
+function report(body: unknown, headers: Record = {}) {
+ return createMockRequest('POST', body, { Authorization: `Bearer ${API_KEY}`, ...headers }, URL)
+}
+
+function validBody(overrides: Record = {}) {
+ return {
+ schemaVersion: 1,
+ deploymentId: 'acme-prod',
+ reportedAt: '2026-09-27T06:15:00.000Z',
+ buckets: [bucket],
+ ...overrides,
+ }
+}
+
+describe('POST /api/onprem-telemetry/report', () => {
+ beforeEach(() => resetDbChainMock())
+
+ it('rejects a request without a bearer token', async () => {
+ const response = await POST(createMockRequest('POST', validBody(), {}, URL))
+ expect(response.status).toBe(401)
+ expect(dbChainMockFns.insert).not.toHaveBeenCalled()
+ })
+
+ it('rejects an unknown key', async () => {
+ const response = await POST(report(validBody()))
+ expect(response.status).toBe(401)
+ expect(dbChainMockFns.insert).not.toHaveBeenCalled()
+ })
+
+ it('looks the deployment up by the hash of the key, never the key', async () => {
+ queueTableRows(schemaMock.onpremDeployment, [{ id: 'acme-prod' }])
+ await POST(report(validBody()))
+ const boundValues = dbChainMockFns.where.mock.calls.flat().map((c) => JSON.stringify(c))
+ expect(boundValues.join()).toContain(sha256Hex(API_KEY))
+ expect(boundValues.join()).not.toContain(API_KEY)
+ })
+
+ it('refuses a body claiming a different deployment', async () => {
+ queueTableRows(schemaMock.onpremDeployment, [{ id: 'acme-prod' }])
+ const response = await POST(report(validBody({ deploymentId: 'someone-else' })))
+ expect(response.status).toBe(403)
+ expect(dbChainMockFns.insert).not.toHaveBeenCalled()
+ })
+
+ it('rejects a payload that does not match the contract', async () => {
+ queueTableRows(schemaMock.onpremDeployment, [{ id: 'acme-prod' }])
+ const response = await POST(report(validBody({ schemaVersion: 2 })))
+ expect(response.status).toBe(400)
+ expect(dbChainMockFns.insert).not.toHaveBeenCalled()
+ })
+
+ it('upserts one row per day and reports how many were accepted', async () => {
+ queueTableRows(schemaMock.onpremDeployment, [{ id: 'acme-prod' }])
+ const duplicateDay = { ...bucket, workflowExecutions: 9 }
+ const response = await POST(report(validBody({ buckets: [bucket, duplicateDay] })))
+
+ expect(response.status).toBe(200)
+ await expect(response.json()).resolves.toEqual({ accepted: 1 })
+ const [rows] = dbChainMockFns.values.mock.calls[0] as [Array>]
+ expect(rows).toHaveLength(1)
+ expect(rows[0]).toMatchObject({
+ deploymentId: 'acme-prod',
+ periodStart: new Date(bucket.periodStart),
+ workflowExecutions: 9,
+ credits: '4',
+ breakdown: { sources: bucket.sources, models: [] },
+ schemaVersion: 1,
+ })
+ })
+})
diff --git a/apps/sim/app/api/onprem-telemetry/report/route.ts b/apps/sim/app/api/onprem-telemetry/report/route.ts
new file mode 100644
index 00000000000..d97ed91ab51
--- /dev/null
+++ b/apps/sim/app/api/onprem-telemetry/report/route.ts
@@ -0,0 +1,122 @@
+import { db } from '@sim/db'
+import { onpremDeployment, onpremUsageReport } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { sha256Hex } from '@sim/security/hash'
+import { generateId } from '@sim/utils/id'
+import { eq, sql } from 'drizzle-orm'
+import { type NextRequest, NextResponse } from 'next/server'
+import {
+ type OnPremUsageBucket,
+ onPremUsageReportContract,
+} from '@/lib/api/contracts/onprem-telemetry'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+
+const logger = createLogger('OnPremTelemetryReportAPI')
+
+/** Buckets are small; 90 days of per-model lines fits comfortably. */
+const MAX_BODY_BYTES = 2 * 1024 * 1024
+
+function readBearerToken(request: NextRequest): string | null {
+ const header = request.headers.get('authorization')
+ if (!header?.startsWith('Bearer ')) return null
+ const token = header.slice('Bearer '.length).trim()
+ return token.length > 0 ? token : null
+}
+
+/** Last bucket wins for a repeated day, so one INSERT never touches a row twice. */
+function dedupeByPeriodStart(buckets: OnPremUsageBucket[]): OnPremUsageBucket[] {
+ const byStart = new Map()
+ for (const bucket of buckets) byStart.set(new Date(bucket.periodStart).toISOString(), bucket)
+ return [...byStart.values()]
+}
+
+/**
+ * POST /api/onprem-telemetry/report
+ *
+ * Receives a self-hosted deployment's daily usage buckets. Authenticated by
+ * the deployment API key issued through the admin API; the body's
+ * `deploymentId` must be the key's own deployment. Each day is upserted on
+ * `(deployment_id, period_start)`, so a re-sent day replaces the earlier copy.
+ */
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const token = readBearerToken(request)
+ if (!token) {
+ return NextResponse.json({ error: 'Deployment API key required' }, { status: 401 })
+ }
+
+ const [deployment] = await db
+ .select({ id: onpremDeployment.id })
+ .from(onpremDeployment)
+ .where(eq(onpremDeployment.apiKeyHash, sha256Hex(token)))
+ .limit(1)
+ if (!deployment) {
+ return NextResponse.json({ error: 'Invalid deployment API key' }, { status: 401 })
+ }
+
+ const parsed = await parseRequest(
+ onPremUsageReportContract,
+ request,
+ {},
+ {
+ maxBodyBytes: MAX_BODY_BYTES,
+ }
+ )
+ if (!parsed.success) return parsed.response
+
+ const { body } = parsed.data
+ if (body.deploymentId !== deployment.id) {
+ return NextResponse.json(
+ { error: 'deploymentId does not match the authenticated deployment' },
+ { status: 403 }
+ )
+ }
+
+ const buckets = dedupeByPeriodStart(body.buckets)
+ if (buckets.length === 0) {
+ return NextResponse.json({ accepted: 0 })
+ }
+
+ const reportedAt = new Date(body.reportedAt)
+ const rows = buckets.map((bucket) => ({
+ id: generateId(),
+ deploymentId: deployment.id,
+ periodStart: new Date(bucket.periodStart),
+ periodEnd: new Date(bucket.periodEnd),
+ workflowExecutions: bucket.workflowExecutions,
+ workflowExecutionsFailed: bucket.workflowExecutionsFailed,
+ workflowDurationMs: bucket.workflowDurationMs,
+ credits: bucket.credits.toString(),
+ inputTokens: bucket.inputTokens,
+ outputTokens: bucket.outputTokens,
+ breakdown: { sources: bucket.sources, models: bucket.models },
+ schemaVersion: body.schemaVersion,
+ reportedAt,
+ }))
+
+ await db
+ .insert(onpremUsageReport)
+ .values(rows)
+ .onConflictDoUpdate({
+ target: [onpremUsageReport.deploymentId, onpremUsageReport.periodStart],
+ set: {
+ periodEnd: sql`excluded.period_end`,
+ workflowExecutions: sql`excluded.workflow_executions`,
+ workflowExecutionsFailed: sql`excluded.workflow_executions_failed`,
+ workflowDurationMs: sql`excluded.workflow_duration_ms`,
+ credits: sql`excluded.credits`,
+ inputTokens: sql`excluded.input_tokens`,
+ outputTokens: sql`excluded.output_tokens`,
+ breakdown: sql`excluded.breakdown`,
+ schemaVersion: sql`excluded.schema_version`,
+ reportedAt: sql`excluded.reported_at`,
+ receivedAt: sql`now()`,
+ },
+ })
+
+ logger.info('Accepted on-prem usage report', {
+ deploymentId: deployment.id,
+ buckets: rows.length,
+ })
+ return NextResponse.json({ accepted: rows.length })
+})
diff --git a/apps/sim/app/api/v1/admin/onprem-telemetry/deployments/[id]/rates/route.ts b/apps/sim/app/api/v1/admin/onprem-telemetry/deployments/[id]/rates/route.ts
new file mode 100644
index 00000000000..1531bb75611
--- /dev/null
+++ b/apps/sim/app/api/v1/admin/onprem-telemetry/deployments/[id]/rates/route.ts
@@ -0,0 +1,89 @@
+/**
+ * GET /api/v1/admin/onprem-telemetry/deployments/[id]/rates
+ *
+ * Full rate history for a deployment, newest first.
+ *
+ * Response: AdminSingleResponse<{ rates: AdminV1OnPremRate[] }>
+ *
+ * POST /api/v1/admin/onprem-telemetry/deployments/[id]/rates
+ *
+ * Append a rate. Rates are never edited or deleted; a new row supersedes the
+ * previous one from `effectiveFrom` on, and dollars are derived at read time,
+ * so historical periods re-value only when a rate is backdated over them.
+ *
+ * Body:
+ * - usdPerCredit: number
+ * - effectiveFrom?: ISO date (defaults to now)
+ *
+ * Response: AdminSingleResponse
+ */
+
+import { db } from '@sim/db'
+import { onpremDeploymentRate } from '@sim/db/schema'
+import { generateId } from '@sim/utils/id'
+import {
+ adminV1CreateOnPremRateContract,
+ adminV1ListOnPremRatesContract,
+} from '@/lib/api/contracts/v1/admin'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import {
+ findDeployment,
+ loadRates,
+ presentRate,
+ toEffectiveRate,
+} from '@/lib/onprem-telemetry/presenters'
+import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
+import {
+ adminInvalidJsonResponse,
+ adminValidationErrorResponse,
+ notFoundResponse,
+ singleResponse,
+} from '@/app/api/v1/admin/responses'
+
+interface RouteParams {
+ id: string
+}
+
+export const GET = withRouteHandler(
+ withAdminAuthParams(async (request, context) => {
+ const parsed = await parseRequest(adminV1ListOnPremRatesContract, request, context, {
+ validationErrorResponse: adminValidationErrorResponse,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+ if (!(await findDeployment(id))) return notFoundResponse('Deployment')
+
+ const rates = (await loadRates([id])).get(id) ?? []
+ return singleResponse({ rates: rates.map(presentRate).reverse() })
+ })
+)
+
+export const POST = withRouteHandler(
+ withAdminAuthParams(async (request, context) => {
+ const parsed = await parseRequest(adminV1CreateOnPremRateContract, request, context, {
+ validationErrorResponse: adminValidationErrorResponse,
+ invalidJsonResponse: adminInvalidJsonResponse,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+ if (!(await findDeployment(id))) return notFoundResponse('Deployment')
+
+ const { usdPerCredit, effectiveFrom } = parsed.data.body
+ const now = new Date()
+ const [row] = await db
+ .insert(onpremDeploymentRate)
+ .values({
+ id: generateId(),
+ deploymentId: id,
+ usdPerCredit: usdPerCredit.toString(),
+ effectiveFrom: effectiveFrom ? new Date(effectiveFrom) : now,
+ createdAt: now,
+ })
+ .returning()
+
+ return singleResponse(presentRate(toEffectiveRate(row)))
+ })
+)
diff --git a/apps/sim/app/api/v1/admin/onprem-telemetry/deployments/[id]/usage/route.ts b/apps/sim/app/api/v1/admin/onprem-telemetry/deployments/[id]/usage/route.ts
new file mode 100644
index 00000000000..2cf8f0838a2
--- /dev/null
+++ b/apps/sim/app/api/v1/admin/onprem-telemetry/deployments/[id]/usage/route.ts
@@ -0,0 +1,140 @@
+/**
+ * GET /api/v1/admin/onprem-telemetry/deployments/[id]/usage
+ *
+ * Daily usage a deployment has reported, each day valued at the rate in
+ * effect at its `periodStart`. Every row carries the rate it was valued with
+ * and the resulting dollars, so a figure is never shown without the rate
+ * behind it.
+ *
+ * Query:
+ * - from?: ISO date (inclusive; default 30 days before `to`)
+ * - to?: ISO date (exclusive; default start of tomorrow UTC)
+ *
+ * Response: AdminSingleResponse
+ */
+
+import { db } from '@sim/db'
+import { onpremUsageReport } from '@sim/db/schema'
+import { and, asc, eq, gte, lt } from 'drizzle-orm'
+import { adminV1GetOnPremUsageContract } from '@/lib/api/contracts/v1/admin'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { utcDayStart } from '@/lib/onprem-telemetry/collect'
+import {
+ findDeployment,
+ loadRates,
+ presentDeployments,
+ presentRate,
+} from '@/lib/onprem-telemetry/presenters'
+import { valueUsage } from '@/lib/onprem-telemetry/rates'
+import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
+import {
+ adminValidationErrorResponse,
+ badRequestResponse,
+ notFoundResponse,
+ singleResponse,
+} from '@/app/api/v1/admin/responses'
+
+interface RouteParams {
+ id: string
+}
+
+const DAY_MS = 24 * 60 * 60 * 1000
+const DEFAULT_RANGE_DAYS = 30
+
+interface StoredBreakdown {
+ sources?: unknown[]
+ models?: unknown[]
+}
+
+export const GET = withRouteHandler(
+ withAdminAuthParams(async (request, context) => {
+ const parsed = await parseRequest(adminV1GetOnPremUsageContract, request, context, {
+ validationErrorResponse: adminValidationErrorResponse,
+ })
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+ const deployment = await findDeployment(id)
+ if (!deployment) return notFoundResponse('Deployment')
+
+ const now = new Date()
+ const to = parsed.data.query.to
+ ? new Date(parsed.data.query.to)
+ : new Date(utcDayStart(now).getTime() + DAY_MS)
+ const from = parsed.data.query.from
+ ? new Date(parsed.data.query.from)
+ : new Date(to.getTime() - DEFAULT_RANGE_DAYS * DAY_MS)
+ if (from.getTime() >= to.getTime()) return badRequestResponse('from must be before to')
+
+ const [reports, rates, [presented]] = await Promise.all([
+ db
+ .select()
+ .from(onpremUsageReport)
+ .where(
+ and(
+ eq(onpremUsageReport.deploymentId, id),
+ gte(onpremUsageReport.periodStart, from),
+ lt(onpremUsageReport.periodStart, to)
+ )
+ )
+ .orderBy(asc(onpremUsageReport.periodStart)),
+ loadRates([id]).then((byId) => byId.get(id) ?? []),
+ presentDeployments([deployment], now),
+ ])
+
+ const valued = valueUsage(
+ reports.map((report) => ({ ...report, credits: Number(report.credits) })),
+ rates
+ )
+
+ const totals = {
+ workflowExecutions: 0,
+ workflowExecutionsFailed: 0,
+ workflowDurationMs: 0,
+ inputTokens: 0,
+ outputTokens: 0,
+ credits: 0,
+ usd: 0,
+ unvaluedCredits: 0,
+ }
+ const rows = valued.map(({ row, rate, usd }) => {
+ const breakdown = (row.breakdown ?? {}) as StoredBreakdown
+ totals.workflowExecutions += row.workflowExecutions
+ totals.workflowExecutionsFailed += row.workflowExecutionsFailed
+ totals.workflowDurationMs += row.workflowDurationMs
+ totals.inputTokens += row.inputTokens
+ totals.outputTokens += row.outputTokens
+ totals.credits += row.credits
+ if (usd === null) totals.unvaluedCredits += row.credits
+ else totals.usd += usd
+ return {
+ periodStart: row.periodStart.toISOString(),
+ periodEnd: row.periodEnd.toISOString(),
+ workflowExecutions: row.workflowExecutions,
+ workflowExecutionsFailed: row.workflowExecutionsFailed,
+ workflowDurationMs: row.workflowDurationMs,
+ inputTokens: row.inputTokens,
+ outputTokens: row.outputTokens,
+ credits: row.credits,
+ rate: rate ? presentRate(rate) : null,
+ usd,
+ sources: Array.isArray(breakdown.sources) ? breakdown.sources : [],
+ models: Array.isArray(breakdown.models) ? breakdown.models : [],
+ reportedAt: row.reportedAt.toISOString(),
+ receivedAt: row.receivedAt.toISOString(),
+ }
+ })
+ totals.credits = Math.round(totals.credits * 1e6) / 1e6
+ totals.usd = Math.round(totals.usd * 1e6) / 1e6
+ totals.unvaluedCredits = Math.round(totals.unvaluedCredits * 1e6) / 1e6
+
+ return singleResponse({
+ deployment: presented,
+ from: from.toISOString(),
+ to: to.toISOString(),
+ rows,
+ totals,
+ })
+ })
+)
diff --git a/apps/sim/app/api/v1/admin/onprem-telemetry/deployments/route.ts b/apps/sim/app/api/v1/admin/onprem-telemetry/deployments/route.ts
new file mode 100644
index 00000000000..5ca09dd0994
--- /dev/null
+++ b/apps/sim/app/api/v1/admin/onprem-telemetry/deployments/route.ts
@@ -0,0 +1,114 @@
+/**
+ * GET /api/v1/admin/onprem-telemetry/deployments
+ *
+ * List registered on-prem deployments with their current rate and last report time.
+ *
+ * Response: AdminListResponse
+ *
+ * POST /api/v1/admin/onprem-telemetry/deployments
+ *
+ * Register a deployment and issue its API key. The key is returned once and
+ * never stored in the clear; the operator sets it as ONPREM_TELEMETRY_API_KEY.
+ *
+ * Body:
+ * - name: string
+ * - id?: string - slug the deployment will report as (generated when omitted)
+ * - usdPerCredit?: number - initial rate, effective immediately
+ *
+ * Response: AdminSingleResponse
+ */
+
+import { randomBytes } from 'node:crypto'
+import { db } from '@sim/db'
+import { onpremDeployment, onpremDeploymentRate } from '@sim/db/schema'
+import { sha256Hex } from '@sim/security/hash'
+import { generateId } from '@sim/utils/id'
+import { count, desc } from 'drizzle-orm'
+import {
+ adminV1CreateOnPremDeploymentContract,
+ adminV1ListOnPremDeploymentsContract,
+} from '@/lib/api/contracts/v1/admin'
+import { parseRequest } from '@/lib/api/server'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { findDeployment, presentDeployments } from '@/lib/onprem-telemetry/presenters'
+import { withAdminAuth } from '@/app/api/v1/admin/middleware'
+import {
+ adminInvalidJsonResponse,
+ adminValidationErrorResponse,
+ conflictResponse,
+ listResponse,
+ singleResponse,
+} from '@/app/api/v1/admin/responses'
+
+/** Prefixed so a leaked key is recognisable in logs and secret scanners. */
+function issueDeploymentApiKey(): string {
+ return `simot_${randomBytes(24).toString('hex')}`
+}
+
+export const GET = withRouteHandler(
+ withAdminAuth(async (request) => {
+ const parsed = await parseRequest(
+ adminV1ListOnPremDeploymentsContract,
+ request,
+ {},
+ {
+ validationErrorResponse: adminValidationErrorResponse,
+ }
+ )
+ if (!parsed.success) return parsed.response
+
+ const { limit, offset } = parsed.data.query
+ const [rows, [{ total }]] = await Promise.all([
+ db
+ .select()
+ .from(onpremDeployment)
+ .orderBy(desc(onpremDeployment.createdAt))
+ .limit(limit)
+ .offset(offset),
+ db.select({ total: count() }).from(onpremDeployment),
+ ])
+
+ const data = await presentDeployments(rows)
+ return listResponse(data, { total, limit, offset, hasMore: offset + rows.length < total })
+ })
+)
+
+export const POST = withRouteHandler(
+ withAdminAuth(async (request) => {
+ const parsed = await parseRequest(
+ adminV1CreateOnPremDeploymentContract,
+ request,
+ {},
+ {
+ validationErrorResponse: adminValidationErrorResponse,
+ invalidJsonResponse: adminInvalidJsonResponse,
+ }
+ )
+ if (!parsed.success) return parsed.response
+
+ const { id: requestedId, name, usdPerCredit } = parsed.data.body
+ const id = requestedId ?? generateId()
+ if (await findDeployment(id)) {
+ return conflictResponse(`Deployment '${id}' already exists`)
+ }
+
+ const apiKey = issueDeploymentApiKey()
+ const now = new Date()
+ const [row] = await db
+ .insert(onpremDeployment)
+ .values({ id, name, apiKeyHash: sha256Hex(apiKey), createdAt: now, updatedAt: now })
+ .returning()
+ if (usdPerCredit !== undefined) {
+ await db.insert(onpremDeploymentRate).values({
+ id: generateId(),
+ deploymentId: id,
+ usdPerCredit: usdPerCredit.toString(),
+ effectiveFrom: now,
+ createdAt: now,
+ })
+ }
+
+ const [presented] = await presentDeployments([row], now)
+ return singleResponse({ ...presented, apiKey })
+ })
+)
diff --git a/apps/sim/lib/api/contracts/onprem-telemetry.ts b/apps/sim/lib/api/contracts/onprem-telemetry.ts
new file mode 100644
index 00000000000..739bc7ddc0b
--- /dev/null
+++ b/apps/sim/lib/api/contracts/onprem-telemetry.ts
@@ -0,0 +1,80 @@
+import { z } from 'zod'
+import { defineRouteContract } from '@/lib/api/contracts/types'
+
+/**
+ * Wire format between a self-hosted deployment (sender) and the Sim instance
+ * that collects its usage (receiver). Both sides import this file, so the
+ * receiver rejects anything the sender could not have produced. Bump the
+ * version when a field changes meaning; add optional fields without bumping.
+ */
+export const ONPREM_TELEMETRY_SCHEMA_VERSION = 1
+
+/** A sender may re-send up to 90 trailing days; the receiver caps the same. */
+export const ONPREM_TELEMETRY_MAX_BUCKETS = 90
+
+const nonNegativeInt = z.number().int().min(0)
+const nonNegative = z.number().min(0)
+const isoDate = z
+ .string()
+ .refine((value) => !Number.isNaN(Date.parse(value)), { error: 'must be an ISO 8601 date' })
+
+/** Credits and event count for one (source, category) pair inside a day. */
+export const onPremUsageSourceLineSchema = z.object({
+ source: z.string().min(1),
+ category: z.string().min(1),
+ events: nonNegativeInt,
+ credits: nonNegative,
+})
+
+/**
+ * Token volume for one model inside a day. `credits` is nonzero only when the
+ * model ran on a Sim hosted key; BYOK usage reports tokens with zero credits.
+ */
+export const onPremUsageModelLineSchema = z.object({
+ model: z.string().min(1),
+ events: nonNegativeInt,
+ inputTokens: nonNegativeInt,
+ outputTokens: nonNegativeInt,
+ credits: nonNegative,
+})
+
+/** One UTC calendar day of usage. No identifiers, inputs or outputs cross the wire. */
+export const onPremUsageBucketSchema = z.object({
+ periodStart: isoDate,
+ periodEnd: isoDate,
+ workflowExecutions: nonNegativeInt,
+ workflowExecutionsFailed: nonNegativeInt,
+ workflowDurationMs: nonNegativeInt,
+ /** 200 × the ledger dollar sum for the day (see `lib/billing/credits/conversion.ts`). */
+ credits: nonNegative,
+ inputTokens: nonNegativeInt,
+ outputTokens: nonNegativeInt,
+ sources: z.array(onPremUsageSourceLineSchema),
+ models: z.array(onPremUsageModelLineSchema),
+})
+
+export const onPremUsageReportBodySchema = z.object({
+ schemaVersion: z.literal(ONPREM_TELEMETRY_SCHEMA_VERSION),
+ deploymentId: z.string().min(1),
+ reportedAt: isoDate,
+ buckets: z.array(onPremUsageBucketSchema).max(ONPREM_TELEMETRY_MAX_BUCKETS),
+})
+
+export const onPremUsageReportResponseSchema = z.object({
+ accepted: nonNegativeInt,
+})
+
+export const onPremUsageReportContract = defineRouteContract({
+ method: 'POST',
+ path: '/api/onprem-telemetry/report',
+ body: onPremUsageReportBodySchema,
+ response: {
+ mode: 'json',
+ schema: onPremUsageReportResponseSchema,
+ },
+})
+
+export type OnPremUsageSourceLine = z.infer
+export type OnPremUsageModelLine = z.infer
+export type OnPremUsageBucket = z.infer
+export type OnPremUsageReportBody = z.infer
diff --git a/apps/sim/lib/api/contracts/v1/admin/index.ts b/apps/sim/lib/api/contracts/v1/admin/index.ts
index d4360f88c5e..1191e873e7f 100644
--- a/apps/sim/lib/api/contracts/v1/admin/index.ts
+++ b/apps/sim/lib/api/contracts/v1/admin/index.ts
@@ -3,6 +3,7 @@ export * from '@/lib/api/contracts/v1/admin/billing'
export * from '@/lib/api/contracts/v1/admin/dashboard'
export * from '@/lib/api/contracts/v1/admin/dashboard-workspaces'
export * from '@/lib/api/contracts/v1/admin/global-work'
+export * from '@/lib/api/contracts/v1/admin/onprem-telemetry'
export * from '@/lib/api/contracts/v1/admin/organizations'
export * from '@/lib/api/contracts/v1/admin/outbox'
export * from '@/lib/api/contracts/v1/admin/referral-campaigns'
diff --git a/apps/sim/lib/api/contracts/v1/admin/onprem-telemetry.ts b/apps/sim/lib/api/contracts/v1/admin/onprem-telemetry.ts
new file mode 100644
index 00000000000..051109eb925
--- /dev/null
+++ b/apps/sim/lib/api/contracts/v1/admin/onprem-telemetry.ts
@@ -0,0 +1,171 @@
+import { z } from 'zod'
+import {
+ onPremUsageModelLineSchema,
+ onPremUsageSourceLineSchema,
+} from '@/lib/api/contracts/onprem-telemetry'
+import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types'
+import {
+ adminV1IdParamsSchema,
+ adminV1ListResponseSchema,
+ adminV1PaginationQuerySchema,
+ adminV1SingleResponseSchema,
+ lastQueryValue,
+} from '@/lib/api/contracts/v1/admin/shared'
+
+const isoDateSchema = z
+ .string()
+ .refine((value) => !Number.isNaN(Date.parse(value)), { error: 'must be an ISO 8601 date' })
+
+/** Operator-chosen slug; doubles as the value a deployment sets in `ONPREM_TELEMETRY_DEPLOYMENT_ID`. */
+export const adminV1OnPremDeploymentIdSchema = z.string().regex(/^[a-z0-9][a-z0-9-]{1,63}$/, {
+ error: 'id must be 2-64 lowercase letters, digits or hyphens, starting with a letter or digit',
+})
+
+export const adminV1OnPremRateSchema = z.object({
+ id: z.string(),
+ usdPerCredit: z.number(),
+ effectiveFrom: z.string(),
+ createdAt: z.string(),
+})
+
+export const adminV1OnPremDeploymentSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ createdAt: z.string(),
+ updatedAt: z.string(),
+ /** The rate that applies to a period starting now; null until a rate is set. */
+ currentRate: adminV1OnPremRateSchema.nullable(),
+ lastReportedAt: z.string().nullable(),
+})
+
+export const adminV1CreateOnPremDeploymentBodySchema = z.object({
+ id: adminV1OnPremDeploymentIdSchema.optional(),
+ name: z.string().trim().min(1).max(120),
+ /** Optional initial rate, effective immediately. */
+ usdPerCredit: z.number().positive().max(1000).optional(),
+})
+
+/** The API key is returned exactly once, here; only its hash is stored. */
+export const adminV1CreateOnPremDeploymentResultSchema = adminV1OnPremDeploymentSchema.extend({
+ apiKey: z.string(),
+})
+
+export const adminV1CreateOnPremRateBodySchema = z.object({
+ usdPerCredit: z.number().positive().max(1000),
+ /** Defaults to now. An earlier instant backdates the rate (see lib/onprem-telemetry/rates.ts). */
+ effectiveFrom: isoDateSchema.optional(),
+})
+
+export const adminV1OnPremRatesResultSchema = z.object({
+ rates: z.array(adminV1OnPremRateSchema),
+})
+
+export const adminV1OnPremUsageQuerySchema = z.object({
+ /** Inclusive; defaults to 30 days before `to`. */
+ from: z.preprocess(lastQueryValue, isoDateSchema.optional()),
+ /** Exclusive; defaults to the start of tomorrow (UTC). */
+ to: z.preprocess(lastQueryValue, isoDateSchema.optional()),
+})
+
+export const adminV1OnPremUsageRowSchema = z.object({
+ periodStart: z.string(),
+ periodEnd: z.string(),
+ workflowExecutions: z.number(),
+ workflowExecutionsFailed: z.number(),
+ workflowDurationMs: z.number(),
+ inputTokens: z.number(),
+ outputTokens: z.number(),
+ credits: z.number(),
+ /** The rate applied to this row, or null when no rate covered `periodStart`. */
+ rate: adminV1OnPremRateSchema.nullable(),
+ /** `credits × rate.usdPerCredit`, or null when `rate` is null. */
+ usd: z.number().nullable(),
+ sources: z.array(onPremUsageSourceLineSchema),
+ models: z.array(onPremUsageModelLineSchema),
+ reportedAt: z.string(),
+ receivedAt: z.string(),
+})
+
+export const adminV1OnPremUsageResultSchema = z.object({
+ deployment: adminV1OnPremDeploymentSchema,
+ from: z.string(),
+ to: z.string(),
+ rows: z.array(adminV1OnPremUsageRowSchema),
+ totals: z.object({
+ workflowExecutions: z.number(),
+ workflowExecutionsFailed: z.number(),
+ workflowDurationMs: z.number(),
+ inputTokens: z.number(),
+ outputTokens: z.number(),
+ credits: z.number(),
+ /** Dollars across rows that had a rate. */
+ usd: z.number(),
+ /** Credits in rows no rate covered — nonzero means `usd` understates the period. */
+ unvaluedCredits: z.number(),
+ }),
+})
+
+export const adminV1ListOnPremDeploymentsContract = defineRouteContract({
+ method: 'GET',
+ path: '/api/v1/admin/onprem-telemetry/deployments',
+ query: adminV1PaginationQuerySchema,
+ response: {
+ mode: 'json',
+ schema: adminV1ListResponseSchema(adminV1OnPremDeploymentSchema),
+ },
+})
+
+export const adminV1CreateOnPremDeploymentContract = defineRouteContract({
+ method: 'POST',
+ path: '/api/v1/admin/onprem-telemetry/deployments',
+ body: adminV1CreateOnPremDeploymentBodySchema,
+ response: {
+ mode: 'json',
+ schema: adminV1SingleResponseSchema(adminV1CreateOnPremDeploymentResultSchema),
+ },
+})
+
+export const adminV1ListOnPremRatesContract = defineRouteContract({
+ method: 'GET',
+ path: '/api/v1/admin/onprem-telemetry/deployments/[id]/rates',
+ params: adminV1IdParamsSchema,
+ response: {
+ mode: 'json',
+ schema: adminV1SingleResponseSchema(adminV1OnPremRatesResultSchema),
+ },
+})
+
+export const adminV1CreateOnPremRateContract = defineRouteContract({
+ method: 'POST',
+ path: '/api/v1/admin/onprem-telemetry/deployments/[id]/rates',
+ params: adminV1IdParamsSchema,
+ body: adminV1CreateOnPremRateBodySchema,
+ response: {
+ mode: 'json',
+ schema: adminV1SingleResponseSchema(adminV1OnPremRateSchema),
+ },
+})
+
+export const adminV1GetOnPremUsageContract = defineRouteContract({
+ method: 'GET',
+ path: '/api/v1/admin/onprem-telemetry/deployments/[id]/usage',
+ params: adminV1IdParamsSchema,
+ query: adminV1OnPremUsageQuerySchema,
+ response: {
+ mode: 'json',
+ schema: adminV1SingleResponseSchema(adminV1OnPremUsageResultSchema),
+ },
+})
+
+export type AdminV1OnPremDeployment = z.infer
+export type AdminV1OnPremRate = z.infer
+export type AdminV1OnPremUsageRow = z.infer
+export type AdminV1ListOnPremDeploymentsResponse = ContractJsonResponse<
+ typeof adminV1ListOnPremDeploymentsContract
+>
+export type AdminV1CreateOnPremDeploymentResponse = ContractJsonResponse<
+ typeof adminV1CreateOnPremDeploymentContract
+>
+export type AdminV1GetOnPremUsageResponse = ContractJsonResponse<
+ typeof adminV1GetOnPremUsageContract
+>
diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts
index d213f8fe180..9c38dbb0b97 100644
--- a/apps/sim/lib/core/config/env.ts
+++ b/apps/sim/lib/core/config/env.ts
@@ -326,6 +326,13 @@ export const env = createEnv({
// Admin API
ADMIN_API_KEY: z.string().min(32).optional(), // Admin API key for self-hosted GitOps access (generate with: openssl rand -hex 32)
+ // On-prem usage telemetry (sender side; see lib/onprem-telemetry/README.md)
+ ONPREM_TELEMETRY_ENABLED: z.boolean().optional(), // Report daily usage buckets to the Sim instance at ONPREM_TELEMETRY_ENDPOINT. Off by default; leave unset on airgapped deployments
+ ONPREM_TELEMETRY_ENDPOINT: z.string().url().optional(), // Base URL of the receiving Sim instance (e.g. https://sim.ai)
+ ONPREM_TELEMETRY_DEPLOYMENT_ID: z.string().min(1).optional(), // Deployment id issued by the receiving instance's admin API
+ ONPREM_TELEMETRY_API_KEY: z.string().min(1).optional(), // Deployment API key issued alongside the id
+ ONPREM_TELEMETRY_LOOKBACK_DAYS: z.string().optional(), // How many trailing UTC days each report re-sends (default 7); bounds how long an outage can last without losing a day
+
// Mothership Admin
MOTHERSHIP_API_ADMIN_KEY: z.string().min(1).optional(), // Admin API key for mothership/copilot admin endpoints
MOTHERSHIP_DEV_URL: z.string().url().optional(), // Mothership dev environment URL
diff --git a/apps/sim/lib/onprem-telemetry/README.md b/apps/sim/lib/onprem-telemetry/README.md
new file mode 100644
index 00000000000..2f08e3b748d
--- /dev/null
+++ b/apps/sim/lib/onprem-telemetry/README.md
@@ -0,0 +1,111 @@
+# On-prem usage telemetry
+
+Reports a self-hosted deployment's daily usage to a Sim instance, where it is
+valued at a per-deployment credit → dollar rate and read through the admin API.
+Operator documentation: `apps/docs/content/docs/platform/self-hosting/usage-telemetry.mdx`.
+
+## Shape
+
+```
+usage_log + workflow_execution_logs (already written on every deployment)
+ │ aggregate query, grouped by UTC day — no per-row data leaves Postgres
+ ▼
+lib/onprem-telemetry/collect.ts buckets: one per day, counts + sums only
+ │ POST, bearer = deployment API key, trailing N days every run
+ ▼ (app/api/cron/onprem-usage-report — the feature's only entry point)
+POST /api/onprem-telemetry/report upsert on (deployment_id, period_start)
+ ▼
+onprem_usage_report credits stored; dollars derived at read
+ ▼
+GET /api/v1/admin/onprem-telemetry/… rows carry credits + applied rate + usd
+```
+
+Both halves live in this codebase: a self-hosted deployment is the sender, and
+whichever Sim instance owns the admin API is the receiver.
+
+## Decisions
+
+**Read the ledger; do not add instrumentation.** `recordUsage` already writes
+`usage_log` on every deployment regardless of `BILLING_ENABLED` — its own
+comment calls the ledger "the single, universal source of truth for cost
+(including self-hosted)". Workflow counts, status and duration are likewise
+already in `workflow_execution_logs`. So the collector is a reader, not a new
+write path, and nothing about how a workflow runs changed.
+
+**Separate from `lib/core/telemetry.ts` on purpose.** That pipeline is
+fire-and-forget by design (`trackPlatformEvent` swallows errors; the
+`/api/telemetry` route forwards with a 5 s abort and no retry). That is right
+for product analytics and wrong for figures with a dollar value attached. This
+system re-sends a trailing window on every run and the receiver upserts, so a
+day is delivered at least once and converges without an outbox or local state.
+
+**Aggregate in SQL.** The two queries `GROUP BY` day (and source/category, or
+model) and return counts and sums. Per-execution rows, identifiers, inputs,
+outputs and tool names never reach the reporting code, so the privacy
+requirement holds by construction rather than by a filter someone must
+remember to maintain. `description` is kept only where it names a model.
+
+**Off the execution path, structurally.** The only caller of the reporter is
+the cron endpoint. Disabled means `getOnPremTelemetryConfig()` returns before
+any query or `fetch` — the first statement of the run, covered by a test that
+asserts `fetch` is never called. A reachable-but-failing receiver yields a
+`failed` result and a log line; nothing throws into anything a workflow
+depends on.
+
+**Credits are the fact; dollars are derived.** The receiver stores `credits`
+per day and an append-only, effective-dated rate table. `valueUsage` joins each
+day to the rate whose `effectiveFrom` most recently precedes `periodStart`.
+That makes rate-change semantics a one-line rule (see `rates.ts`): a new rate
+applies from its `effectiveFrom` forward; a backdated rate re-values the days it
+now covers on the next read; no stored credit ever changes; days before the
+first rate report `usd: null` and are summed as `unvaluedCredits` instead of
+being priced silently. Rates live on the receiver, not the deployment, so they
+are commercial terms the customer cannot edit and changing one needs no
+redeploy.
+
+**Why a per-deployment rate exists at all.** Cloud enterprise contracts already
+convert dollars to credits at a negotiated figure (`enterprise-credit-limits.ts`
+overrides `dollarsToCredits` via `usageLimitCredits`). This is the same idea for
+deployments Sim cannot instrument directly. The rate is deliberately a third
+concept next to `CREDIT_MULTIPLIER` (the fixed 200 credits/dollar used to
+compute `credits` from ledger dollars) and `COST_MULTIPLIER` (an execution-time
+cost scaler); neither is reused.
+
+**Why credits on a self-hosted deployment are mostly the run charge.** Models
+there run on the customer's own keys, so the ledger records them as
+`model_unbilled` with cost 0 and tokens in metadata (the same path a BYOK user
+takes on cloud — it is a key-ownership branch, not a deployment branch).
+`BASE_EXECUTION_CHARGE` is ungated, so every run contributes exactly one credit.
+Token volume per model is reported next to credits so the receiver can see the
+model usage Sim never billed, which the brief asks for explicitly. Pricing BYOK
+tokens is left to the receiver (it holds the price book) and is not done here.
+
+**Minimal surface.** Three tables, one wire contract shared by sender and
+receiver (`lib/api/contracts/onprem-telemetry.ts`, schema-versioned), one cron
+route, one ingest route, three admin routes. No new pool profile (the aggregate
+holds no transaction), no Redis lock (upserts make overlapping runs safe), no
+outbox (re-sending the window is the retry).
+
+## Limitations
+
+- **Cooperative, not enforced.** Nothing here proves a deployment reported
+ completely; the customer controls the database and the flag. Enforcement
+ would need attestation or a license mechanism, which does not exist in this
+ codebase and is out of scope.
+- **Copilot chat usage is absent on-prem.** `POST /api/billing/update-cost`
+ returns `200 "Billing disabled, cost update skipped"` when `BILLING_ENABLED`
+ is unset, so chat model cost never reaches `usage_log` there; the four
+ Sim-Chat-family sources will read as zero. Recording those callbacks
+ unconditionally would close the gap and is the natural follow-up.
+- **Ledger scan.** `usage_log` has no index led by `created_at`; the collector's
+ window scan is a sequential scan on a large ledger. The job runs every six
+ hours to keep that cheap. A concurrent index on `usage_log (created_at)` is
+ the fix if a deployment's ledger grows large; it was not added here to avoid
+ an index build on Sim Cloud's own ledger as a side effect.
+- **Day granularity, UTC.** A day is partial until the first run after UTC
+ midnight (`reportedAt` says when it was last sent). Rate changes apply at day
+ boundaries — a rate effective mid-day covers that whole day if it precedes
+ the day's start, otherwise starts the next day.
+- **No audit rows** for deployment or rate creation. The admin API key is the
+ only actor, as with other admin endpoints; adding `AuditAction` values was
+ left out to keep the change small.
diff --git a/apps/sim/lib/onprem-telemetry/collect.test.ts b/apps/sim/lib/onprem-telemetry/collect.test.ts
new file mode 100644
index 00000000000..fb788a8113c
--- /dev/null
+++ b/apps/sim/lib/onprem-telemetry/collect.test.ts
@@ -0,0 +1,122 @@
+import { describe, expect, it } from 'vitest'
+import {
+ buildUsageBuckets,
+ type ExecutionDayRow,
+ type LedgerDayRow,
+ reportWindow,
+ utcDayKey,
+} from '@/lib/onprem-telemetry/collect'
+
+describe('reportWindow', () => {
+ it('spans the trailing N UTC days including the current, partial one', () => {
+ const window = reportWindow(new Date('2026-09-27T15:42:00Z'), 3)
+ expect(window.start.toISOString()).toBe('2026-09-25T00:00:00.000Z')
+ expect(window.end.toISOString()).toBe('2026-09-28T00:00:00.000Z')
+ })
+
+ it('keys days by UTC date regardless of local time', () => {
+ expect(utcDayKey(new Date('2026-09-27T23:59:59.999Z'))).toBe('2026-09-27')
+ expect(utcDayKey(new Date('2026-09-28T00:00:00.000Z'))).toBe('2026-09-28')
+ })
+})
+
+describe('buildUsageBuckets', () => {
+ const window = reportWindow(new Date('2026-09-27T12:00:00Z'), 2)
+
+ const ledger: LedgerDayRow[] = [
+ /** Base execution charges: 40 runs × $0.005 = 40 credits. */
+ {
+ day: '2026-09-26',
+ source: 'workflow',
+ category: 'fixed',
+ description: 'Base execution charge',
+ events: 40,
+ cost: '0.2',
+ inputTokens: 0,
+ outputTokens: 0,
+ },
+ /** BYOK model usage: tokens with zero cost. */
+ {
+ day: '2026-09-26',
+ source: 'workflow',
+ category: 'model_unbilled',
+ description: 'gpt-5',
+ events: 35,
+ cost: '0',
+ inputTokens: 120_000,
+ outputTokens: 30_000,
+ },
+ /** A hosted-key model: cost and tokens both present. */
+ {
+ day: '2026-09-26',
+ source: 'knowledge-base',
+ category: 'model',
+ description: 'text-embedding-3-small',
+ events: 5,
+ cost: '0.01',
+ inputTokens: 50_000,
+ outputTokens: 0,
+ },
+ /** Outside the window: dropped, not mis-filed. */
+ {
+ day: '2026-09-01',
+ source: 'workflow',
+ category: 'fixed',
+ description: 'Base execution charge',
+ events: 999,
+ cost: '5',
+ inputTokens: 0,
+ outputTokens: 0,
+ },
+ ]
+
+ const executions: ExecutionDayRow[] = [
+ { day: '2026-09-26', status: 'completed', executions: 37, durationMs: 111_000 },
+ { day: '2026-09-26', status: 'failed', executions: 3, durationMs: 9_000 },
+ { day: '2026-09-27', status: 'running', executions: 1, durationMs: 0 },
+ ]
+
+ it('emits one bucket per day in the window, zero-filled when idle', () => {
+ const buckets = buildUsageBuckets(window, [], [])
+ expect(buckets.map((b) => b.periodStart)).toEqual([
+ '2026-09-26T00:00:00.000Z',
+ '2026-09-27T00:00:00.000Z',
+ ])
+ expect(buckets[0].periodEnd).toBe('2026-09-27T00:00:00.000Z')
+ expect(buckets[0]).toMatchObject({ workflowExecutions: 0, credits: 0, sources: [], models: [] })
+ })
+
+ it('converts ledger dollars to credits and folds tokens out of model rows', () => {
+ const [day26, day27] = buildUsageBuckets(window, ledger, executions)
+
+ expect(day26.credits).toBe(42)
+ expect(day26.inputTokens).toBe(170_000)
+ expect(day26.outputTokens).toBe(30_000)
+ expect(day26.workflowExecutions).toBe(40)
+ expect(day26.workflowExecutionsFailed).toBe(3)
+ expect(day26.workflowDurationMs).toBe(120_000)
+
+ expect(day26.sources).toEqual([
+ { source: 'workflow', category: 'fixed', events: 40, credits: 40 },
+ { source: 'workflow', category: 'model_unbilled', events: 35, credits: 0 },
+ { source: 'knowledge-base', category: 'model', events: 5, credits: 2 },
+ ])
+ expect(day26.models).toEqual([
+ { model: 'gpt-5', events: 35, inputTokens: 120_000, outputTokens: 30_000, credits: 0 },
+ {
+ model: 'text-embedding-3-small',
+ events: 5,
+ inputTokens: 50_000,
+ outputTokens: 0,
+ credits: 2,
+ },
+ ])
+
+ expect(day27).toMatchObject({ workflowExecutions: 1, credits: 0 })
+ })
+
+ it('never carries a description for non-model categories', () => {
+ const [day26] = buildUsageBuckets(window, ledger, executions)
+ expect(JSON.stringify(day26)).not.toContain('Base execution charge')
+ })
+})
diff --git a/apps/sim/lib/onprem-telemetry/collect.ts b/apps/sim/lib/onprem-telemetry/collect.ts
new file mode 100644
index 00000000000..ea14e173222
--- /dev/null
+++ b/apps/sim/lib/onprem-telemetry/collect.ts
@@ -0,0 +1,233 @@
+import { dbReplica } from '@sim/db'
+import { usageLog, workflowExecutionLogs } from '@sim/db/schema'
+import { and, gte, lt, sql } from 'drizzle-orm'
+import type {
+ OnPremUsageBucket,
+ OnPremUsageModelLine,
+ OnPremUsageSourceLine,
+} from '@/lib/api/contracts/onprem-telemetry'
+import { CREDIT_MULTIPLIER } from '@/lib/billing/credits/conversion'
+import type { DbClient } from '@/lib/db/types'
+
+const DAY_MS = 24 * 60 * 60 * 1000
+
+/** Model categories whose `description` is a model name and whose metadata carries tokens. */
+const MODEL_CATEGORIES = new Set(['model', 'model_unbilled'])
+
+/** Start of the UTC calendar day containing `at`. */
+export function utcDayStart(at: Date): Date {
+ return new Date(Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), at.getUTCDate()))
+}
+
+/** `YYYY-MM-DD` of the UTC day containing `at`; the grouping key the queries emit. */
+export function utcDayKey(at: Date): string {
+ return utcDayStart(at).toISOString().slice(0, 10)
+}
+
+export interface ReportWindow {
+ /** Inclusive, a UTC midnight. */
+ start: Date
+ /** Exclusive, a UTC midnight. */
+ end: Date
+}
+
+/**
+ * The trailing `lookbackDays` UTC calendar days ending with the one containing
+ * `now`. The current day is included and will be partial until the next report
+ * after UTC midnight; the receiver replaces a day on each re-report, so it
+ * converges once the day has elapsed.
+ */
+export function reportWindow(now: Date, lookbackDays: number): ReportWindow {
+ const end = new Date(utcDayStart(now).getTime() + DAY_MS)
+ const start = new Date(end.getTime() - lookbackDays * DAY_MS)
+ return { start, end }
+}
+
+export interface LedgerDayRow {
+ day: string
+ source: string
+ category: string
+ description: string
+ events: number
+ /** Dollars, as the decimal column renders. */
+ cost: string | number
+ inputTokens: number
+ outputTokens: number
+}
+
+export interface ExecutionDayRow {
+ day: string
+ status: string
+ executions: number
+ durationMs: number
+}
+
+/**
+ * Aggregates in the database so no per-execution row ever reaches this
+ * process: the only things read back are counts and sums grouped by day.
+ * `description` is kept only where it names a model (see `MODEL_CATEGORIES`);
+ * every other category collapses to `(source, category)`.
+ *
+ * Runs on the replica when one is configured. A plain aggregate holds no
+ * transaction, so it cannot pin a pooled connection across the scan.
+ */
+export async function readLedgerDays(
+ window: ReportWindow,
+ executor: DbClient = dbReplica
+): Promise {
+ const rows = await executor
+ .select({
+ day: sql`to_char(date_trunc('day', ${usageLog.createdAt}), 'YYYY-MM-DD')`.as('day'),
+ source: usageLog.source,
+ category: usageLog.category,
+ description: usageLog.description,
+ events: sql`COUNT(*)`.mapWith(Number).as('events'),
+ cost: sql`COALESCE(SUM(${usageLog.cost}), 0)`.as('cost'),
+ inputTokens: sql`
+ COALESCE(SUM(CASE WHEN jsonb_typeof(${usageLog.metadata} -> 'inputTokens') = 'number'
+ THEN (${usageLog.metadata} ->> 'inputTokens')::bigint ELSE 0 END), 0)`
+ .mapWith(Number)
+ .as('input_tokens'),
+ outputTokens: sql`
+ COALESCE(SUM(CASE WHEN jsonb_typeof(${usageLog.metadata} -> 'outputTokens') = 'number'
+ THEN (${usageLog.metadata} ->> 'outputTokens')::bigint ELSE 0 END), 0)`
+ .mapWith(Number)
+ .as('output_tokens'),
+ })
+ .from(usageLog)
+ .where(and(gte(usageLog.createdAt, window.start), lt(usageLog.createdAt, window.end)))
+ .groupBy(sql`day`, usageLog.source, usageLog.category, usageLog.description)
+ return rows
+}
+
+export async function readExecutionDays(
+ window: ReportWindow,
+ executor: DbClient = dbReplica
+): Promise {
+ const rows = await executor
+ .select({
+ day: sql`to_char(date_trunc('day', ${workflowExecutionLogs.startedAt}), 'YYYY-MM-DD')`.as(
+ 'day'
+ ),
+ status: workflowExecutionLogs.status,
+ executions: sql`COUNT(*)`.mapWith(Number).as('executions'),
+ durationMs: sql`COALESCE(SUM(${workflowExecutionLogs.totalDurationMs}), 0)`
+ .mapWith(Number)
+ .as('duration_ms'),
+ })
+ .from(workflowExecutionLogs)
+ .where(
+ and(
+ gte(workflowExecutionLogs.startedAt, window.start),
+ lt(workflowExecutionLogs.startedAt, window.end)
+ )
+ )
+ .groupBy(sql`day`, workflowExecutionLogs.status)
+ return rows
+}
+
+function toCredits(cost: string | number): number {
+ const dollars = typeof cost === 'number' ? cost : Number.parseFloat(cost)
+ if (!Number.isFinite(dollars)) return 0
+ return Math.round(dollars * CREDIT_MULTIPLIER * 1e6) / 1e6
+}
+
+/**
+ * Shapes grouped rows into one bucket per UTC day in the window. Days with no
+ * activity are emitted as zeros so the receiver can tell "idle" from "not
+ * reported". Pure, so the payload shape is testable without a database.
+ */
+export function buildUsageBuckets(
+ window: ReportWindow,
+ ledger: readonly LedgerDayRow[],
+ executions: readonly ExecutionDayRow[]
+): OnPremUsageBucket[] {
+ const buckets = new Map()
+ for (let at = window.start.getTime(); at < window.end.getTime(); at += DAY_MS) {
+ const periodStart = new Date(at)
+ buckets.set(utcDayKey(periodStart), {
+ periodStart: periodStart.toISOString(),
+ periodEnd: new Date(at + DAY_MS).toISOString(),
+ workflowExecutions: 0,
+ workflowExecutionsFailed: 0,
+ workflowDurationMs: 0,
+ credits: 0,
+ inputTokens: 0,
+ outputTokens: 0,
+ sources: [],
+ models: [],
+ })
+ }
+
+ const sourceLines = new Map>()
+ const modelLines = new Map>()
+
+ for (const row of ledger) {
+ const bucket = buckets.get(row.day)
+ if (!bucket) continue
+ const credits = toCredits(row.cost)
+ bucket.credits += credits
+ bucket.inputTokens += row.inputTokens
+ bucket.outputTokens += row.outputTokens
+
+ const sources = sourceLines.get(row.day) ?? new Map()
+ sourceLines.set(row.day, sources)
+ const sourceKey = `${row.source}::${row.category}`
+ const source = sources.get(sourceKey) ?? {
+ source: row.source,
+ category: row.category,
+ events: 0,
+ credits: 0,
+ }
+ source.events += row.events
+ source.credits += credits
+ sources.set(sourceKey, source)
+
+ if (MODEL_CATEGORIES.has(row.category)) {
+ const models = modelLines.get(row.day) ?? new Map()
+ modelLines.set(row.day, models)
+ const model = models.get(row.description) ?? {
+ model: row.description,
+ events: 0,
+ inputTokens: 0,
+ outputTokens: 0,
+ credits: 0,
+ }
+ model.events += row.events
+ model.inputTokens += row.inputTokens
+ model.outputTokens += row.outputTokens
+ model.credits += credits
+ models.set(row.description, model)
+ }
+ }
+
+ for (const row of executions) {
+ const bucket = buckets.get(row.day)
+ if (!bucket) continue
+ bucket.workflowExecutions += row.executions
+ bucket.workflowDurationMs += row.durationMs
+ if (row.status === 'failed') bucket.workflowExecutionsFailed += row.executions
+ }
+
+ for (const [day, bucket] of buckets) {
+ bucket.credits = Math.round(bucket.credits * 1e6) / 1e6
+ bucket.sources = [...(sourceLines.get(day)?.values() ?? [])].map((line) => ({
+ ...line,
+ credits: Math.round(line.credits * 1e6) / 1e6,
+ }))
+ bucket.models = [...(modelLines.get(day)?.values() ?? [])].map((line) => ({
+ ...line,
+ credits: Math.round(line.credits * 1e6) / 1e6,
+ }))
+ }
+
+ return [...buckets.values()]
+}
+
+export async function collectUsageBuckets(window: ReportWindow): Promise {
+ const [ledger, executions] = await Promise.all([
+ readLedgerDays(window),
+ readExecutionDays(window),
+ ])
+ return buildUsageBuckets(window, ledger, executions)
+}
diff --git a/apps/sim/lib/onprem-telemetry/config.ts b/apps/sim/lib/onprem-telemetry/config.ts
new file mode 100644
index 00000000000..c8f32939fb9
--- /dev/null
+++ b/apps/sim/lib/onprem-telemetry/config.ts
@@ -0,0 +1,53 @@
+import { env, isTruthy } from '@/lib/core/config/env'
+
+export const DEFAULT_LOOKBACK_DAYS = 7
+export const MAX_LOOKBACK_DAYS = 90
+
+export type OnPremTelemetryConfig =
+ | { enabled: false; reason: string }
+ | {
+ enabled: true
+ /** Receiving instance base URL, no trailing slash. */
+ endpoint: string
+ deploymentId: string
+ apiKey: string
+ lookbackDays: number
+ }
+
+/**
+ * Resolves the sender configuration from the environment on every call, so a
+ * deployment that flips `ONPREM_TELEMETRY_ENABLED` off takes effect at the
+ * next cron tick without a restart. Anything short of a complete
+ * configuration reads as disabled — a half-configured deployment never sends.
+ */
+export function getOnPremTelemetryConfig(): OnPremTelemetryConfig {
+ if (!isTruthy(env.ONPREM_TELEMETRY_ENABLED)) {
+ return { enabled: false, reason: 'ONPREM_TELEMETRY_ENABLED is not set' }
+ }
+
+ const endpoint = env.ONPREM_TELEMETRY_ENDPOINT
+ const deploymentId = env.ONPREM_TELEMETRY_DEPLOYMENT_ID
+ const apiKey = env.ONPREM_TELEMETRY_API_KEY
+ const missing = [
+ !endpoint && 'ONPREM_TELEMETRY_ENDPOINT',
+ !deploymentId && 'ONPREM_TELEMETRY_DEPLOYMENT_ID',
+ !apiKey && 'ONPREM_TELEMETRY_API_KEY',
+ ].filter((name): name is string => Boolean(name))
+ if (missing.length > 0 || !endpoint || !deploymentId || !apiKey) {
+ return { enabled: false, reason: `${missing.join(', ')} not set` }
+ }
+
+ const parsedLookback = Number.parseInt(env.ONPREM_TELEMETRY_LOOKBACK_DAYS ?? '', 10)
+ const lookbackDays =
+ Number.isFinite(parsedLookback) && parsedLookback > 0
+ ? Math.min(parsedLookback, MAX_LOOKBACK_DAYS)
+ : DEFAULT_LOOKBACK_DAYS
+
+ return {
+ enabled: true,
+ endpoint: endpoint.replace(/\/+$/, ''),
+ deploymentId,
+ apiKey,
+ lookbackDays,
+ }
+}
diff --git a/apps/sim/lib/onprem-telemetry/onprem-telemetry.integration.ts b/apps/sim/lib/onprem-telemetry/onprem-telemetry.integration.ts
new file mode 100644
index 00000000000..f8467e67d1d
--- /dev/null
+++ b/apps/sim/lib/onprem-telemetry/onprem-telemetry.integration.ts
@@ -0,0 +1,456 @@
+import { createServer } from 'node:http'
+import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
+import { NextRequest } from 'next/server'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+
+/**
+ * End-to-end proof against real PostgreSQL: seed a deployment's own ledger and
+ * execution logs, aggregate them with the real collector SQL, deliver the
+ * buckets through the receiver's upsert, and value them at a per-deployment
+ * rate — the same path a deployed on-prem instance takes.
+ *
+ * The collector's aggregate queries (jsonb token extraction, day bucketing) and
+ * the receiver's `ON CONFLICT` upsert only exist as SQL, so a unit test with a
+ * mocked driver cannot execute either. This suite is where they actually run.
+ */
+readTestDatabaseUrl()
+
+const ACTOR = 'onprem-it-user'
+const WORKSPACE = 'onprem-it-workspace'
+const WORKFLOW = 'onprem-it-workflow'
+const SNAPSHOT = 'onprem-it-snapshot'
+const DEPLOYMENT = 'onprem-it-deployment'
+
+const DAY_26 = new Date('2026-09-26T00:00:00Z')
+const DAY_27 = new Date('2026-09-27T00:00:00Z')
+const NOW = new Date('2026-09-27T12:00:00Z')
+
+/**
+ * The real env module captures these at import time, so every variable the
+ * routes and the reporter read must be set before the dynamic imports below —
+ * including the bridge's port, which is why the server binds at module scope
+ * (the same ordering `vitest.integration.setup.ts` uses for its realtime fixture).
+ */
+const ADMIN_KEY = 'onprem-integration-admin-key-not-a-real-credential'
+process.env.ADMIN_API_KEY = ADMIN_KEY
+const DEPLOYMENT_API_KEY = 'simot_onprem_integration_key'
+
+/** Late-bound so the bridge can serve the receiver route once beforeAll has imported it. */
+let receiveReport: ((request: NextRequest) => Promise) | null = null
+
+/**
+ * Stands in for the receiving Sim instance: accepts the sender's real HTTP
+ * request and hands the body to the actual receiver route. Only the network
+ * hop's destination is local — auth, parsing and the upsert are all real.
+ */
+const bridge = createServer((request, response) => {
+ const chunks: Buffer[] = []
+ request.on('data', (chunk) => chunks.push(chunk as Buffer))
+ request.on('end', () => {
+ if (!receiveReport) {
+ response.writeHead(503).end()
+ return
+ }
+ void receiveReport(
+ new NextRequest(`http://127.0.0.1${request.url}`, {
+ method: 'POST',
+ headers: request.headers as Record,
+ body: Buffer.concat(chunks).toString('utf8'),
+ })
+ ).then(async (result) => {
+ response.writeHead(result.status, { 'content-type': 'application/json' })
+ response.end(await result.text())
+ })
+ })
+})
+await new Promise((resolve) => bridge.listen(0, '127.0.0.1', resolve))
+const bridgeAddress = bridge.address()
+if (!bridgeAddress || typeof bridgeAddress === 'string') throw new Error('bridge failed to bind')
+
+process.env.ONPREM_TELEMETRY_ENABLED = 'true'
+process.env.ONPREM_TELEMETRY_ENDPOINT = `http://127.0.0.1:${bridgeAddress.port}`
+process.env.ONPREM_TELEMETRY_DEPLOYMENT_ID = DEPLOYMENT
+process.env.ONPREM_TELEMETRY_API_KEY = DEPLOYMENT_API_KEY
+process.env.ONPREM_TELEMETRY_LOOKBACK_DAYS = '2'
+
+async function loadRuntime() {
+ const [{ db }, schema, drizzle, collect, rates, hash, receiver, adminUsage] = await Promise.all([
+ import('@sim/db'),
+ import('@sim/db/schema'),
+ import('drizzle-orm'),
+ import('@/lib/onprem-telemetry/collect'),
+ import('@/lib/onprem-telemetry/rates'),
+ import('@sim/security/hash'),
+ import('@/app/api/onprem-telemetry/report/route'),
+ import('@/app/api/v1/admin/onprem-telemetry/deployments/[id]/usage/route'),
+ ])
+ return { db, schema, drizzle, collect, rates, hash, receiver, adminUsage }
+}
+
+type Runtime = Awaited>
+let runtime: Runtime
+
+/** Ledger row shorthand; `cost` is dollars, matching the decimal column. */
+function ledgerRow(
+ id: string,
+ createdAt: Date,
+ category: 'fixed' | 'model' | 'model_unbilled' | 'tool',
+ source: string,
+ description: string,
+ cost: string,
+ metadata: Record | null = null
+) {
+ return {
+ id,
+ userId: ACTOR,
+ category,
+ source,
+ description,
+ metadata,
+ cost,
+ eventKey: id,
+ workspaceId: WORKSPACE,
+ workflowId: WORKFLOW,
+ executionId: `exec-${id}`,
+ createdAt,
+ }
+}
+
+async function seed() {
+ const { db, schema } = runtime
+ await db.insert(schema.user).values({
+ id: ACTOR,
+ name: 'On-prem integration actor',
+ email: `${ACTOR}@example.test`,
+ emailVerified: false,
+ createdAt: NOW,
+ updatedAt: NOW,
+ })
+ await db.insert(schema.workspace).values({
+ id: WORKSPACE,
+ name: 'On-prem integration workspace',
+ ownerId: ACTOR,
+ billedAccountUserId: ACTOR,
+ createdAt: NOW,
+ updatedAt: NOW,
+ })
+ await db.insert(schema.workflow).values({
+ id: WORKFLOW,
+ userId: ACTOR,
+ workspaceId: WORKSPACE,
+ name: 'On-prem integration workflow',
+ lastSynced: NOW,
+ createdAt: NOW,
+ updatedAt: NOW,
+ })
+ await db.insert(schema.workflowExecutionSnapshots).values({
+ id: SNAPSHOT,
+ workflowId: WORKFLOW,
+ stateHash: 'onprem-it-hash',
+ stateData: {},
+ createdAt: NOW,
+ })
+
+ await db.insert(schema.usageLog).values([
+ /** 40 runs × $0.005 base execution charge = $0.20 -> 40 credits. */
+ ledgerRow('it-fixed-26', DAY_26, 'fixed', 'workflow', 'Base execution charge', '0.20'),
+ /** BYOK: the on-prem default. Zero cost, tokens carried in metadata. */
+ ledgerRow('it-byok-26', DAY_26, 'model_unbilled', 'workflow', 'gpt-5', '0', {
+ inputTokens: 120000,
+ outputTokens: 30000,
+ }),
+ /** Hosted-key model: cost AND tokens. $0.01 -> 2 credits. */
+ ledgerRow('it-model-26', DAY_26, 'model', 'knowledge-base', 'text-embedding-3-small', '0.01', {
+ inputTokens: 50000,
+ outputTokens: 0,
+ }),
+ /** A second day, so bucketing by day is actually exercised. */
+ ledgerRow('it-fixed-27', DAY_27, 'fixed', 'workflow', 'Base execution charge', '0.015'),
+ /** Outside the reporting window entirely. */
+ ledgerRow('it-old', new Date('2026-08-01T00:00:00Z'), 'fixed', 'workflow', 'Base', '9.99'),
+ ])
+
+ await db.insert(schema.workflowExecutionLogs).values(
+ [
+ { id: 'it-exec-ok', status: 'completed', startedAt: DAY_26, duration: 111_000 },
+ { id: 'it-exec-fail', status: 'failed', startedAt: DAY_26, duration: 9_000 },
+ { id: 'it-exec-day27', status: 'completed', startedAt: DAY_27, duration: 3_000 },
+ ].map((row) => ({
+ id: row.id,
+ workflowId: WORKFLOW,
+ workspaceId: WORKSPACE,
+ executionId: row.id,
+ stateSnapshotId: SNAPSHOT,
+ level: row.status === 'failed' ? 'error' : 'info',
+ status: row.status,
+ trigger: 'api',
+ startedAt: row.startedAt,
+ endedAt: new Date(row.startedAt.getTime() + row.duration),
+ totalDurationMs: row.duration,
+ executionData: {},
+ createdAt: row.startedAt,
+ }))
+ )
+}
+
+async function cleanup() {
+ const { db, schema, drizzle } = runtime
+ const { eq, inArray } = drizzle
+ await db
+ .delete(schema.onpremUsageReport)
+ .where(eq(schema.onpremUsageReport.deploymentId, DEPLOYMENT))
+ await db
+ .delete(schema.onpremDeploymentRate)
+ .where(eq(schema.onpremDeploymentRate.deploymentId, DEPLOYMENT))
+ await db.delete(schema.onpremDeployment).where(eq(schema.onpremDeployment.id, DEPLOYMENT))
+ await db
+ .delete(schema.workflowExecutionLogs)
+ .where(inArray(schema.workflowExecutionLogs.workflowId, [WORKFLOW]))
+ await db.delete(schema.usageLog).where(eq(schema.usageLog.userId, ACTOR))
+ await db
+ .delete(schema.workflowExecutionSnapshots)
+ .where(eq(schema.workflowExecutionSnapshots.id, SNAPSHOT))
+ await db.delete(schema.workflow).where(eq(schema.workflow.id, WORKFLOW))
+ await db.delete(schema.workspace).where(eq(schema.workspace.id, WORKSPACE))
+ await db.delete(schema.user).where(eq(schema.user.id, ACTOR))
+}
+
+afterAll(
+ () =>
+ new Promise((resolve) => {
+ bridge.closeAllConnections()
+ bridge.close(() => resolve())
+ })
+)
+
+beforeAll(async () => {
+ runtime = await loadRuntime()
+ receiveReport = runtime.receiver.POST
+ await cleanup()
+ await seed()
+ await runtime.db.insert(runtime.schema.onpremDeployment).values({
+ id: DEPLOYMENT,
+ name: 'Integration deployment',
+ apiKeyHash: runtime.hash.sha256Hex(DEPLOYMENT_API_KEY),
+ createdAt: NOW,
+ updatedAt: NOW,
+ })
+ return cleanup
+})
+
+describe('on-prem usage telemetry against real PostgreSQL', () => {
+ it('aggregates the ledger into per-day buckets with credits and tokens', async () => {
+ const { collect } = runtime
+ const window = collect.reportWindow(NOW, 2)
+
+ const buckets = await collect.collectUsageBuckets(window)
+
+ expect(buckets.map((b) => b.periodStart)).toEqual([
+ '2026-09-26T00:00:00.000Z',
+ '2026-09-27T00:00:00.000Z',
+ ])
+
+ const [day26, day27] = buckets
+ /** $0.20 + $0 + $0.01 = $0.21 -> 42 credits. */
+ expect(day26.credits).toBe(42)
+ /** 120k + 50k input across both model rows; the `fixed` row contributes none. */
+ expect(day26.inputTokens).toBe(170_000)
+ expect(day26.outputTokens).toBe(30_000)
+ expect(day26.workflowExecutions).toBe(2)
+ expect(day26.workflowExecutionsFailed).toBe(1)
+ expect(day26.workflowDurationMs).toBe(120_000)
+
+ expect(day26.models).toEqual(
+ expect.arrayContaining([
+ { model: 'gpt-5', events: 1, inputTokens: 120_000, outputTokens: 30_000, credits: 0 },
+ {
+ model: 'text-embedding-3-small',
+ events: 1,
+ inputTokens: 50_000,
+ outputTokens: 0,
+ credits: 2,
+ },
+ ])
+ )
+ /** `fixed` is a source line but never a model line. */
+ expect(day26.models.map((m) => m.model)).not.toContain('Base execution charge')
+
+ expect(day27.credits).toBe(3)
+ expect(day27.workflowExecutions).toBe(1)
+ })
+
+ it('excludes rows outside the window rather than misfiling them', async () => {
+ const { collect } = runtime
+ const buckets = await collect.collectUsageBuckets(collect.reportWindow(NOW, 2))
+ const total = buckets.reduce((sum, b) => sum + b.credits, 0)
+ /** The August row is 9.99 -> 1998 credits; its absence is the assertion. */
+ expect(total).toBe(45)
+ })
+
+ it('accepts a report through the real receiver route and upserts on re-report', async () => {
+ const { db, schema, drizzle, collect, receiver } = runtime
+ const { eq } = drizzle
+ const buckets = await collect.collectUsageBuckets(collect.reportWindow(NOW, 2))
+
+ const post = (body: unknown, token = DEPLOYMENT_API_KEY) =>
+ receiver.POST(
+ new NextRequest('http://localhost:3000/api/onprem-telemetry/report', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
+ body: JSON.stringify(body),
+ })
+ )
+
+ const rejected = await post(
+ { schemaVersion: 1, deploymentId: DEPLOYMENT, reportedAt: NOW.toISOString(), buckets },
+ 'simot_wrong'
+ )
+ expect(rejected.status).toBe(401)
+
+ const first = await post({
+ schemaVersion: 1,
+ deploymentId: DEPLOYMENT,
+ reportedAt: NOW.toISOString(),
+ buckets,
+ })
+ expect(first.status).toBe(200)
+ await expect(first.json()).resolves.toEqual({ accepted: 2 })
+
+ /** A later run re-sends the same window with a day revised upward. */
+ const later = new Date('2026-09-27T18:00:00Z')
+ const revised = buckets.map((bucket) =>
+ bucket.periodStart === '2026-09-27T00:00:00.000Z'
+ ? { ...bucket, workflowExecutions: 99, credits: 77 }
+ : bucket
+ )
+ const second = await post({
+ schemaVersion: 1,
+ deploymentId: DEPLOYMENT,
+ reportedAt: later.toISOString(),
+ buckets: revised,
+ })
+ expect(second.status).toBe(200)
+
+ const stored = await db
+ .select()
+ .from(schema.onpremUsageReport)
+ .where(eq(schema.onpremUsageReport.deploymentId, DEPLOYMENT))
+ .orderBy(schema.onpremUsageReport.periodStart)
+
+ /** Two days, not four: the re-report replaced rather than appended. */
+ expect(stored).toHaveLength(2)
+ expect(Number(stored[0].credits)).toBe(42)
+ expect(stored[1].workflowExecutions).toBe(99)
+ expect(Number(stored[1].credits)).toBe(77)
+ expect((stored[0].breakdown as { models: unknown[] }).models).toHaveLength(2)
+ })
+
+ it('serves credits, the applied rate and dollars through the real admin route', async () => {
+ const { db, schema, adminUsage } = runtime
+ await db.insert(schema.onpremDeploymentRate).values([
+ {
+ id: 'it-rate-list',
+ deploymentId: DEPLOYMENT,
+ usdPerCredit: '0.00500000',
+ effectiveFrom: new Date('2026-01-01T00:00:00Z'),
+ createdAt: NOW,
+ },
+ {
+ id: 'it-rate-discount',
+ deploymentId: DEPLOYMENT,
+ usdPerCredit: '0.00300000',
+ effectiveFrom: DAY_27,
+ createdAt: NOW,
+ },
+ ])
+
+ const get = (key = ADMIN_KEY) =>
+ adminUsage.GET(
+ new NextRequest(
+ `http://localhost:3000/api/v1/admin/onprem-telemetry/deployments/${DEPLOYMENT}/usage?from=2026-09-01T00:00:00Z&to=2026-10-01T00:00:00Z`,
+ { headers: { 'x-admin-key': key } }
+ ),
+ { params: Promise.resolve({ id: DEPLOYMENT }) }
+ )
+
+ expect((await get('wrong-key')).status).toBe(401)
+
+ const response = await get()
+ expect(response.status).toBe(200)
+ const { data } = (await response.json()) as {
+ data: {
+ rows: Array<{
+ periodStart: string
+ credits: number
+ usd: number | null
+ rate: { id: string } | null
+ }>
+ totals: {
+ credits: number
+ usd: number
+ unvaluedCredits: number
+ workflowExecutions: number
+ }
+ }
+ }
+
+ /** Sept 26 predates the discount: 42 credits x $0.005 = $0.21. */
+ expect(data.rows[0]).toMatchObject({ credits: 42, usd: 0.21 })
+ expect(data.rows[0].rate?.id).toBe('it-rate-list')
+ /** Sept 27 starts exactly at the discount's effectiveFrom: 77 x $0.003 = $0.231. */
+ expect(data.rows[1]).toMatchObject({ credits: 77, usd: 0.231 })
+ expect(data.rows[1].rate?.id).toBe('it-rate-discount')
+
+ expect(data.totals.credits).toBe(119)
+ expect(data.totals.usd).toBe(0.441)
+ expect(data.totals.unvaluedCredits).toBe(0)
+ expect(data.totals.workflowExecutions).toBe(101)
+ })
+
+ it('completes the full loop: cron reporter -> HTTP -> receiver -> admin route', async () => {
+ const { db, schema, drizzle, adminUsage } = runtime
+ const { eq } = drizzle
+
+ await db
+ .delete(schema.onpremUsageReport)
+ .where(eq(schema.onpremUsageReport.deploymentId, DEPLOYMENT))
+
+ const { runOnPremUsageReport } = await import('@/lib/onprem-telemetry/report')
+ const result = await runOnPremUsageReport(NOW)
+
+ expect(result).toEqual({ status: 'delivered', buckets: 2, accepted: 2 })
+
+ /** The data is now readable through the admin route, which is the deliverable. */
+ const response = await adminUsage.GET(
+ new NextRequest(
+ `http://localhost:3000/api/v1/admin/onprem-telemetry/deployments/${DEPLOYMENT}/usage?from=2026-09-01T00:00:00Z&to=2026-10-01T00:00:00Z`,
+ { headers: { 'x-admin-key': ADMIN_KEY } }
+ ),
+ { params: Promise.resolve({ id: DEPLOYMENT }) }
+ )
+ expect(response.status).toBe(200)
+ const { data } = (await response.json()) as {
+ data: { rows: Array<{ credits: number; usd: number | null }>; totals: { credits: number } }
+ }
+ /** Straight from the seeded ledger, through the wire, to a valued figure. */
+ expect(data.rows.map((row) => row.credits)).toEqual([42, 3])
+ expect(data.totals.credits).toBe(45)
+ })
+
+ it('reports credits as unvalued rather than guessing when no rate covers a day', async () => {
+ const { rates } = runtime
+ const effective = [
+ {
+ id: 'r1',
+ usdPerCredit: 0.005,
+ effectiveFrom: new Date('2026-01-01T00:00:00Z'),
+ createdAt: NOW,
+ },
+ ]
+ const valued = rates.valueUsage(
+ [{ periodStart: new Date('2025-01-01T00:00:00Z'), credits: 100 }],
+ effective
+ )
+ expect(valued[0]).toMatchObject({ rate: null, usd: null })
+ })
+})
diff --git a/apps/sim/lib/onprem-telemetry/presenters.ts b/apps/sim/lib/onprem-telemetry/presenters.ts
new file mode 100644
index 00000000000..24335738070
--- /dev/null
+++ b/apps/sim/lib/onprem-telemetry/presenters.ts
@@ -0,0 +1,89 @@
+import { db } from '@sim/db'
+import { onpremDeployment, onpremDeploymentRate, onpremUsageReport } from '@sim/db/schema'
+import { eq, type InferSelectModel, inArray, sql } from 'drizzle-orm'
+import type {
+ AdminV1OnPremDeployment,
+ AdminV1OnPremRate,
+} from '@/lib/api/contracts/v1/admin/onprem-telemetry'
+import { type EffectiveRate, resolveRateAt } from '@/lib/onprem-telemetry/rates'
+
+export type OnPremDeploymentRow = InferSelectModel
+type RateRow = InferSelectModel
+
+export function toEffectiveRate(row: RateRow): EffectiveRate {
+ return {
+ id: row.id,
+ usdPerCredit: Number(row.usdPerCredit),
+ effectiveFrom: row.effectiveFrom,
+ createdAt: row.createdAt,
+ }
+}
+
+export function presentRate(rate: EffectiveRate): AdminV1OnPremRate {
+ return {
+ id: rate.id,
+ usdPerCredit: rate.usdPerCredit,
+ effectiveFrom: rate.effectiveFrom.toISOString(),
+ createdAt: rate.createdAt.toISOString(),
+ }
+}
+
+export async function findDeployment(id: string): Promise {
+ const [row] = await db.select().from(onpremDeployment).where(eq(onpremDeployment.id, id)).limit(1)
+ return row ?? null
+}
+
+/** Every rate for each deployment, oldest first; valuation picks from the full history. */
+export async function loadRates(deploymentIds: string[]): Promise