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 20 second block / per request | Actual AI token usage |
This page is about the first one (credits). The second is covered in observability.
Billing counts AI requests that actually answered. A request that failed (a bad key, the provider down) gave the customer nothing, so it is excluded from photo_request_count, and a job where every AI call failed is marked error and has its up-front charge refunded rather than completing empty. See the AI pipeline.
The credit rates
Flat rates, set in config.py and environment-overridable:
| Media | Rate | Setting |
|---|---|---|
| Video | 3 credits per 20 seconds (9 a minute) | cost_per_video_block |
| Audio | 1 credit per 20 seconds (3 a minute) | cost_per_audio_block |
| Photo | 1 credit per request | cost_per_photo_request |
The length of a block is itself a setting, billing_block_seconds (default 20). Video and audio were priced per whole minute until September 2026; blocks replaced minutes so a short clip is not charged for a full minute it never used.
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 20 second blocks before charging, so a 20 second clip is one block and a 25 second clip is two.
Examples:
- A 20-second video bills as 1 block = 3 credits.
- A 25-second video bills as 2 blocks = 6 credits.
- A one-minute video bills as 3 blocks = 9 credits.
- A 90-second voice memo bills as 5 blocks = 5 credits.
- A photo job analyzed in 4 groups bills as 4 credits.
Only groups that actually reach the AI are counted, so an item the user named costs nothing: a typed name means the item is built straight from the photo with no request made (StagedPhoto.manual_name, see the photo pipeline). The same principle covers a whole inventory typed in by the insured through their own link: those items are created directly on a job that is already complete with zero billed credits, because they told us what each one is.
Reading the rest of a busy photo
A photo request earns one credit and gets an output budget sized to that credit, so a photo holding more items than the budget can list comes back short (see the AI pipeline). The finished upload then offers another pass over just those photos, and because billing counts AI calls, that pass is priced exactly like any other: one credit per photo re-read, added to the job's photo_request_count. Three passes over one photo cost three credits, and three is the ceiling (photo_max_passes).
The offer is priced per pass, never per item. The model tells us only THAT its answer was cut off, never how much it had left to say, so the copy says "9 photos had more items than one pass could read. Read the rest? 9 credits" and never names a number of remaining items.
It runs through the same quote-and-confirm flow as starting a job: GET /api/jobs/{id}/read-the-rest/quote prices it, and POST /api/jobs/{id}/read-the-rest spends the credits up front (402 when the team is short) before the pass starts, refunding them if it fails. The spend is idempotent per round, so a double-click cannot charge twice.
billed_cost_usd holds a whole number of credits, not dollars. billed_minutes holds the clip length in whole minutes and is no longer what the charge comes from (billed_blocks is); it stays populated so the analytics series built on it keeps meaning the same thing.
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
Environment: Stage | Production
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 environment / company / user / media type plus the error, and no credits or revenue.
Environment is read from APP_ENV on the box that sent it: production (or prod) posts Production, stage (or staging) posts Stage, and anything else, including the local default, posts its own title-cased name so a message from a developer's machine can never be mistaken for a real one. Stage and production each need their own webhook if you want them in separate channels.
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:routes/upload.pyfor the adjuster's upload,services/media_duration.pyfor the Contents Capture importer and the insured link's submit) 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 20 second blocks / 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.
- The insured's link can never spend a credit or surface a billing error. Photos and videos arriving through an insured inventory link are parked at
awaiting_groupingandawaiting_confirmation, so the charge happens where it always did: when the adjuster presses Process and confirms the quote. The submit probes each video and voice note's length so that quote is the real one; before 2026-09-14 it did not, and homeowner media was processed for zero credits. The team that owns the claim pays, resolved through the usualresolve_billing_identitychain (job → room → claim → team). This is deliberate: the homeowner is not the customer, must never see anything about credits, and must never be shown a402for a balance that is not theirs.