Skip to main content

Billing & credits

ContentsVision tracks two completely separate notions of cost. Keeping them straight is the key to this page.

Customer billingInternal observability cost
UnitCreditsUS dollars
PurposeWhat the customer is chargedEstimating our own AI spend
Whereservices/billing.py, stored on the jobcore/observability.py, in memory
Based onFlat rates per minute / per requestActual 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:

MediaRateSetting
Video10 credits per minutecost_per_video_minute
Audio3 credits per minutecost_per_audio_minute
Photo1 credit per requestcost_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.
The field is named billed_cost_usd

The 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_usd on the claim list and detail responses. This is where a user sees what a claim has cost.
  • Team total: GET /api/billing/summary returns 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's credits_balance and buy_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_usd across 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.

SettingPurpose
ENABLE_BILLINGMaster switch for the prepaid layer (instance-wide).
BILLING_APP_DOMAINBase URL of the external billing service.
BILLING_APP_API_KEYBearer key for that service.
BILLING_EXTERNAL_APPThe 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):

  1. 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 in awaiting_grouping until the user presses Process. The frontend then calls GET /api/jobs/{id}/quote, which returns the exact cost (estimate_video/audio/photo_credits) and the team's credits_balance, and shows a confirmation modal. The quote charges nothing.

  2. Spend at confirm (this is the gate). On confirm, POST /api/jobs/{id}/process calls charge_upfront, which spends the job's credits via POST /api/v1/credits/spend (keyed on the job id), then starts the matching pipeline. If the team cannot cover it the service returns insufficient_creditsHTTP 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_upfront fails 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).

  3. Refund on failure. If processing then fails, the pipeline's error path calls refund_for_job, which returns the credits via POST /api/v1/credits/refund and sets billing_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.

  4. Buy credits. With billing on, GET /api/billing/summary also returns the team's credits_balance (a live read, not the gate) and a buy_credits_url (the billing service's /billing/buy page). 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.

Two more charge sites: sending a claim (Adjust Square + Contents Estimation)

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, keyed as-send:{claim_id}:{already_submitted} so a retried batch does not double-charge. Refunded on a synchronous create-full / room-sync failure. Priced by POST /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 by GET /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.

Job columns that track the charge

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.

Refund endpoint

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.