Add-on Features & Unbundled Billing
Oktivo platform documentation for developers. Covers the unbundled pricing model where recording, transcription, and analysis are optional paid add-ons billed on top of the calling (preset) rate.
Overview
Historically, a preset's rate bundled everything: the call, the recording, the transcript, and the analysis were all included in one per-minute price.
The unbundled model splits these apart:
- The preset rate covers the CALL only.
- Recording, transcription, and analysis are separate, optional add-on features. A customer is billed for an add-on only when they have the corresponding feature enabled.
- When a customer does not have an add-on, the artifact is never generated, stored, or billed the recording egress doesn't start, the transcript isn't synced, and analysis doesn't run.
The three add-on features
Defined in client_backend/src/types/index.ts → AVAILABLE_FEATURES, and mirrored on the frontend in app/src/lib/features.ts.
| Feature key | What it gates |
|---|---|
call_recording | Whether the call is recorded (LiveKit egress) and playable |
call_transcription | Whether the transcript is produced and stored |
call_analysis | Whether post-call analysis (sentiment, summary, custom schema) runs |
Dependency: call_analysis requires call_transcription analysis needs a transcript to work on. This is enforced everywhere:
- Backend billing (
resolveAddons) forces analysis off when transcription is off. - Dispatch gates (
resolveAddonGates) force analysis off when transcription is off. - Frontend feature assignment (
toggleFeatureWithDependencies) auto-selects transcription when analysis is enabled and drops analysis when transcription is removed.
ADDON_FEATURES and ADDON_DEPENDENCIES live in client_backend/src/types/billing.types.ts (and app/src/lib/features.ts).
Pricing model (two tiers + floor)
Add-on rates follow the same superadmin → org tiering as preset rates.
SuperAdmin global rate (floor) ──► Org rate (>= floor, org margin) ──► Customer billed- SuperAdmin sets a global platform rate per add-on. Same for every preset. A rate of
0means the add-on is free at the platform level. - Org may set its own rate ≥ the global floor to add margin. If the org doesn't set one, it uses the global rate.
- Customer is billed at the org rate. The wholesale/superadmin cost stays at the global rate, so the org keeps
(org_rate − global_rate) × unitsas margin exactly like preset margin.
Rates are expressed per billing unit (the same unit as the call), so they scale with call length. Example with billingUnit = 60s, recording rate 0.5: a 2-minute call adds 2 × 0.5 = 1 credit for recording on top of the call cost.
Storage
| Tier | Where | Field |
|---|---|---|
| Global (floor) | SystemConfig | addon_rates: { call_recording, call_transcription, call_analysis } |
| Org | OrgConfigReplica | addon_rates: { call_recording?, call_transcription?, call_analysis? } (null = use global) |
Both default to 0 / unset. Values are non-negative with at most 2 decimal places.
Billing flow
Add-on resolution and charging happen inside the existing credit pipeline.
1. Rate snapshot (call end)
captureRateSnapshot(orgUID, presetId, customerUID) in client_backend/src/utils/rateResolver.ts resolves the calling rate as before, then calls resolveAddons(...) and attaches an addons map to the snapshot:
addons: {
call_recording: { enabled: boolean, rate: number, superadmin_rate: number },
call_transcription: { enabled: boolean, rate: number, superadmin_rate: number },
call_analysis: { enabled: boolean, rate: number, superadmin_rate: number },
}enabledcustomer has the feature (org plan AND user assignment), viafeatureAccessService.checkCustomerFeature.ratecustomer-facing rate (org rate if set and ≥ floor, else global).superadmin_ratewholesale/global rate (the floor).
Performance short-circuit: if no add-on has a configured rate (neither global nor org), resolveAddons returns early with everything disabled and performs no feature-access DB lookups. This keeps the common "add-ons not in use" path cheap.
2. Consumption
CreditService.consumeCredits (client_backend/src/services/credit.service.ts) combines the calling rate with the enabled add-on rates:
customer_rate = call_org_rate + Σ enabled addon.rate (customer-facing)
wholesale_rate = call_superadmin_rate + Σ enabled addon.superadmin_rate (platform cost)
customer_charge = billing_units × customer_rate → debited from User.creditBalance
org_charge = billing_units × wholesale_rate → recorded as wholesale cost (counter only)
margin = customer_charge − org_charge → recorded in totalProfitRetainedHelpers sumEnabledAddonRates and sumEnabledAddonSuperadminRates live in billing.types.ts. The checkBalance pre-campaign guard uses the same combined customer rate so it reserves enough credits.
Transaction metadata records the full breakdown: addons, addon_rate_total, addon_superadmin_total, and call_rate (the calling rate excluding add-ons).
Skip generation (when an add-on is disabled)
When a customer lacks an add-on, the artifact is never produced. Gates exist at multiple layers for defense-in-depth.
Recording
- Dispatch:
resolveAddonGates(client_backend/src/utils/dispatch.utils.ts) resolvesenable_recordingand passes it in the LiveKit dispatch metadata. - Agent:
start_recordinginAgent/src/handlers/call_handler.pypostsskipRecording: trueto/start-recordingwhen recording is off (it still calls the endpoint socallAnsweredAtis set for accurate billing duration). - Backend safety net: the
/start-recordinghandler (webhook.controller.ts→startRecording) independently re-checkscall_recordingviafeatureAccessServiceand skips egress if the customer lacks it. Test calls are exempt.
Transcription & analysis (standard AI calls)
The Python agent produces the transcript and triggers analysis. Both are gated in Agent/src/services/post_call.py:
enable_transcriptionoff → transcript sync is skipped entirely.enable_analysisoff → analysis trigger is skipped (analysis also requires transcription).
These flags come from the dispatch metadata (enable_transcription, enable_analysis), computed by resolveAddonGates.
Transcription & analysis (CRM human / phone-to-phone / human_callback calls)
These calls have no AI agent in the room. The transcript is now produced live by a backend-only STT listener that runs inside the room while the call is active (Agent/src/services/room_transcriber.py), wired into the bridge handlers (phone_bridge_handler.py, cloud_telephony_handler.py, human_agent_inbound_handler.py). The STT provider/model/language are resolved from the selected human_agent's preset (BYOK supported; fallback deepgram/nova-3/multi). On call end, the agent syncs the transcript and triggers analysis via sync_and_analyse (Agent/src/services/bridge_post_call.py): POST /transcription-sync then POST /analyse/conversation the same transcription/analysis path used by AI calls.
- The backend no longer triggers
POST /analyse/crm-recordingfor these call types onegress_ended. The egress handlers only save the recording and firerecording_ready. - There is no recording fallback: if the live transcript is empty, no transcript is synced and no analysis runs.
- Recording-based STT + analysis (
/analyse/crm-recording, Sarvam) is still used for manual retry / reanalyze (/api/v1/crm/retry-analysis,HumanCallService.reprocessCall,PhoneToPhoneCallService.reprocessCall,reanalyze-contacts.ts).
Add-on gating note. The unbundled add-on gate
resolvePostCallAddonGate(inwebhook.controller.ts) is no longer wired into the automatic post-call flow for these calls (it was used to gate the removedcrm-recordingtrigger). It is retained for the recording-based retry paths. Thecall_transcription/call_analysisfeature checks for AI calls continue viaresolveAddonGates.
Frontend
| Role | Where | Capability |
|---|---|---|
| SuperAdmin | System Config panel → "Add-on Rates" card | Set global rates (the floor). 0 = free |
| Org Admin | Config Management → Pricing & Visibility → "Add-on Rates" | View floor + set org rate (≥ floor) |
| Customer | Credits page → "Add-on Rates" | View the effective org rate (read-only) |
- Component:
app/src/components/features/AddonRatesEditor.tsx(editable + read-only modes). - SuperAdmin editor lives in
app/src/components/features/SystemConfigPanel.tsx. - Feature assignment UIs (
OrgManagementPage,CustomerManagementPage) show thecall_transcriptioncheckbox automatically and enforce the analysis→transcription dependency viatoggleFeatureWithDependencies. - Recording player, transcript tab, and analysis tab are hidden when the customer lacks the corresponding feature (
HistoryPage,CampaignDetailPage).
API endpoints
SuperAdmin (global rates)
| Method | Path | Body |
|---|---|---|
PUT | /superadmin/system-config/addon-rates | { call_recording?, call_transcription?, call_analysis? } |
Global config (including addon_rates) is returned by GET /superadmin/system-config.
Org / Customer (org rates + floor view)
| Method | Path | Role | What it does |
|---|---|---|---|
GET | /api/org-config/replica/addon-rates | admin, customer | Returns { superadmin_rate, org_rate, org_rate_set } per add-on |
PUT | /api/org-config/replica/addon-rates | admin | Set org rates (each must be ≥ the global floor) |
Validation: org rate below the global floor is rejected with 400.
Backend code map
client_backend/src/
├── types/
│ ├── billing.types.ts # ADDON_FEATURES, ADDON_DEPENDENCIES, IAddonRate,
│ │ # sumEnabledAddonRates, sumEnabledAddonSuperadminRates,
│ │ # validateAddonRate
│ └── index.ts # call_transcription added to AVAILABLE_FEATURES + FEATURE_MAP
├── models/
│ ├── SystemConfig.ts # addon_rates (global floor)
│ └── OrgConfigReplica.ts # addon_rates (org rate, IOrgAddonRates)
├── utils/
│ ├── rateResolver.ts # resolveAddons(); snapshot carries addons map
│ └── dispatch.utils.ts # resolveAddonGates() → enable_recording/transcription/analysis
├── services/
│ ├── credit.service.ts # consumeCredits/checkBalance add addon rates + margin
│ ├── config.service.ts # updateAddonRates() (global)
│ ├── orgConfigReplica.service.ts # getAddonRates()/updateAddonRates() (org, floor-enforced)
│ └── calling.service.ts # passes enable_* gates in dispatch metadata
├── repositories/
│ └── orgConfigReplica.repository.ts # setAddonRates()
├── controllers/
│ ├── systemConfig.controller.ts # updateDefaultAddonRates
│ ├── orgConfigReplica.controller.ts # getAddonRates / updateAddonRates
│ └── webhook.controller.ts # recording gate + resolvePostCallAddonGate
└── routes/
├── superadmin.routes.ts # PUT /system-config/addon-rates
└── orgConfigReplica.routes.ts # GET/PUT /replica/addon-ratesPython:
Agent/src/schemas/metadata_schema.py # enable_recording/transcription/analysis (default true)
Agent/src/handlers/call_handler.py # start_recording passes skipRecording
Agent/src/services/post_call.py # gates transcript sync + analysis trigger
Analysis/src/schemas/analysis_schema.py # enable_analysis on RecordingAnalysisRequest
Analysis/src/routes/analysis_routes.py # threads enable_analysis
Analysis/src/orchestration/analysis_orchestrator.py # skips analysis step, keeps transcriptBackward compatibility & rollout
- All add-on rates default to
0/ unset, and the dispatch/metadata gates default totrue. Until a superadmin sets rates and features are granted, behaviour is unchanged nothing new is billed and no artifacts are skipped. - Existing config documents without
addon_ratesare handled by fallbacks that treat missing rates as0. - If the superadmin later raises a global rate above an org's set rate, billing defensively falls back to the global rate for that add-on, so an org can never be billed below the floor even before any cascade.
Migration (deferred)
Flipping existing customers to the unbundled model deciding whether to rebalance preset prices (since they no longer bundle add-ons) and how to grant add-on features so current bills don't jump is a deliberate, business-sensitive step handled separately. The mechanism above ships first; the migration is applied last.
Testing notes
consumeCredits/checkBalancebilling math includes add-on rate sums.resolveAddonsshort-circuits (no DB calls) when no add-on rate is configured — important for property-test performance and production efficiency.- Tests that mock
utils/dispatch.utils.jsmust include aresolveAddonGatesmock returning{ enable_recording, enable_transcription, enable_analysis }.