Billing & credits
ContentsVision tracks two completely separate notions of cost. Keeping them straight is the key to this page.
| Customer billing | Internal observability cost | |
|---|---|---|
| Unit | Credits | US dollars |
| Purpose | What the customer is charged | Estimating our own AI spend |
| Where | services/billing.py, stored on the job | core/observability.py, in memory |
| Based on | Flat rates per minute / per request | Actual AI token usage |
This page is about the first one (credits). The second is covered in observability.
The credit rates
Flat rates, set in config.py and environment-overridable:
| Media | Rate | Setting |
|---|---|---|
| Video | 10 credits per minute | cost_per_video_minute |
| Audio | 3 credits per minute | cost_per_audio_minute |
| Photo | 1 credit per request | cost_per_photo_request |
A "photo request" is one AI call, which is one photo group in the grouped pipeline. Grouping five photos into one item is still one request. A user-described item (AS-705) makes no AI call (the user supplied the name), so it costs nothing and is excluded from both the quote and the final charge. This covers any named item: a whole photo or merged group the user named in the Items panel, or a named drawn segment. An item left unnamed is identified by the AI and bills as one request like any group.
How a job is priced
Billing is computed once, when a job completes, by finalize_job_billing. Video and audio time is rounded up to whole minutes before charging.
Examples:
- A 2.5-minute video bills as 3 minutes = 30 credits.
- A 90-second voice memo bills as 2 minutes = 6 credits.
- A photo job analyzed in 4 groups bills as 4 credits.
billed_cost_usdThe stored column is billed_cost_usd for backward compatibility, but it now holds a whole number of credits, not dollars. Do not read it as money.
Aggregation
Spend rolls up from jobs to claims to the whole team:
claim_spend(claim_id)sums all jobs in a claim (joining job to room to claim).claim_spend_map(claim_ids)does the same for many claims in one query (used by the dashboard list).account_spend(team_id)sums every claim in a team.
Where it surfaces in the UI
- Per claim: the dashboard's claims table shows each claim's spend in a Usage column, attached as
spend_usdon the claim list and detail responses. This is where a user sees what a claim has cost. - Team total:
GET /api/billing/summaryreturns the team's total spend (total_spend_usd) plus the flat rates. The persistent credits widget in the app shell shows the team's available balance and a Buy credits button, but only when prepaid billing is enabled (the summary'scredits_balanceandbuy_credits_url; see Prepaid billing). On invoiced/enterprise instances there is no balance, so the widget is hidden. - System-wide (internal): the admin dashboard sums
billed_cost_usdacross all teams for headline credit spend, a per-media-type split, and a per-team leaderboard, all scoped to a selectable time range (today through all time). It is the one place spend is reported untethered from a single team.
Slack notification on media processing
Optional, instance-wide, off by default. When SLACK_WEBHOOK_URL is set, every time a media-processing job finishes the backend posts a message to a Slack channel, on both success and failure. It is an internal ops signal (it goes to your team's Slack, not to the customer), wired into the pipeline alongside the analytics event in app/services/slack_notifier.py. With no webhook configured it no-ops and never calls out, so local dev and the test suite stay offline.
A successful job posts:
ContentsVision: New media uploaded
Company: <team name>
User: <user who processed the media>
Media Type: Video | Photo | Audio
Credits Used: <job credits>
Revenue: <credits used × team credit rate>
A failed job posts a Media processing failed message with the same company / user / media type plus the error, and no credits or revenue.
Revenue is the credits used times the team credit rate, the dollar value of one credit. The rate is read from Team.credit_rate when set, otherwise from the global DEFAULT_TEAM_CREDIT_RATE (default 0.15). Team.credit_rate is nullable and unset by default, so every team uses the global rate until given its own; existing teams are not backfilled.
Identity (company, user) is resolved through the owning claim (job → claim → team / created-by user), the same attribution analytics uses. The post is best-effort: a Slack outage or a bad webhook is logged and swallowed, so it can never break or slow the pipeline.
Prepaid billing against an external service (optional)
Everything above is usage metering: it records what each job costs in credits and shows it, but never blocks anything. Some deployments also need a prepaid model, where a team buys credits up front and cannot process more than it has paid for. That is turned on per instance with ENABLE_BILLING and integrates with a separate billing service (its own ledger, Stripe checkout, and credit packages); ContentsVision is only the client.
When ENABLE_BILLING is off (the default, used by invoiced / enterprise instances) none of this runs and the app behaves exactly as described above: it meters usage but never reads a balance, blocks an upload, or charges.
| Setting | Purpose |
|---|---|
ENABLE_BILLING | Master switch for the prepaid layer (instance-wide). |
BILLING_APP_DOMAIN | Base URL of the external billing service. |
BILLING_APP_API_KEY | Bearer key for that service. |
BILLING_EXTERNAL_APP | The external_app identifier we send (default contentsvision). |
Credits are spent up front, at the moment the user confirms processing — that spend doubles as the affordability gate, and nothing is charged or processed until the user has acknowledged the exact cost. There is no local balance arithmetic; every credit number comes from the billing service. The flow has four parts, split between services/billing.py (policy) and services/billing_client.py (the HTTP calls):
-
Park & quote (before processing). When a video/audio upload finishes, the job is parked in
awaiting_confirmation(its duration is probed for pricing) instead of processing immediately; a photo job waits inawaiting_groupinguntil the user presses Process. The frontend then callsGET /api/jobs/{id}/quote, which returns the exact cost (estimate_video/audio/photo_credits) and the team'scredits_balance, and shows a confirmation modal. The quote charges nothing. -
Spend at confirm (this is the gate). On confirm,
POST /api/jobs/{id}/processcallscharge_upfront, which spends the job's credits viaPOST /api/v1/credits/spend(keyed on the job id), then starts the matching pipeline. If the team cannot cover it the service returnsinsufficient_credits→ HTTP 402 and nothing is processed. The estimate is exact (the same round-up minutes / group count the job bills), so there is no top-up.charge_upfrontfails open on anything that is not a flat refusal: billing off, a zero cost, a team with no external id, a config gap, or a billing outage all let processing proceed uncharged (logged). Cancelling instead —POST /api/jobs/{id}/cancel— discards an unconfirmed video/audio job and its uploaded file before any credits are spent. The charged amount and transaction id are stored on the job (billing_amount,billing_txn_id). -
Refund on failure. If processing then fails, the pipeline's error path calls
refund_for_job, which returns the credits viaPOST /api/v1/credits/refundand setsbilling_refunded. It is idempotent and never raises (a failed refund is logged and left to reconcile, rather than masking the original error). Re-processing an errored job (whose charge was refunded) charges again, which is correct. -
Buy credits. With billing on,
GET /api/billing/summaryalso returns the team'scredits_balance(a live read, not the gate) and abuy_credits_url(the billing service's/billing/buypage). The app shell shows the remaining balance and a Buy credits button; the confirmation modal and any blocked upload surface the same call to action when the balance is short.
Besides media jobs, sending a claim charges 1 credit per item on both paths, through primitives generalized from charge_upfront: billing.charge_credits gates the send up front (a flat refusal → 402, nothing sent) and billing.refund_credits returns the credits if the send then fails. A metered team pays at upload and at send (the product decision, AS-951).
- Adjust Square (
submit-to-adjust-square): 1 credit per item in the batch, keyedas-send:{claim_id}:{already_submitted}so a retried batch does not double-charge. Refunded on a synchronous create-full / room-sync failure. Priced byPOST /api/claims/{id}/adjust-square-quote. - Contents Estimation (
submit-to-contents-estimation): 1 credit per active item, once per claim (ce-send:{claim_id}); a re-send is free and CE does not re-bill. Priced byGET /api/claims/{id}/contents-estimation-quote. See Contents Estimation → Payment.
Enterprise exemption. A per-team enterprise flag (admin Teams tab, component_visibility, default off) exempts a team from all charges — the per-upload job charge and both sends. billing.is_enterprise_team / is_enterprise_job gate every charge site; the quotes report enterprise, so the confirm modals show "Included with your enterprise plan" instead of a cost line.
Identity is keyed on the Adjust Square ids already stored: Team.external_id (sent as team_id) and User.external_id (sent as user_id), scoped by BILLING_EXTERNAL_APP.
billing_charged / billing_txn_id / billing_amount / billing_refunded on processing_jobs record the up-front spend and any refund. All stay at their defaults when billing is off.
POST /api/v1/credits/refund is assumed to mirror spend (the design only named a refund transaction type). Confirm the exact contract against the live billing service.
Important properties
- Computed at completion, never retroactive. Old jobs that predate billing stay at 0; there is no backfill. Rate changes apply only to jobs billed after the change.
- Errored jobs are not billed. Billing only runs on successful completion.
- Credits are charged for processing, not submission. Submitting to Adjust Square does not change billing; the charge already happened when the job finished.