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 20 second block / per requestActual AI token usage

This page is about the first one (credits). The second is covered in observability.

A failed AI call is never billed

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:

MediaRateSetting
Video3 credits per 20 seconds (9 a minute)cost_per_video_block
Audio1 credit per 20 seconds (3 a minute)cost_per_audio_block
Photo1 credit per requestcost_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.

Two columns keep misleading names

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_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
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.

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: routes/upload.py for the adjuster's upload, services/media_duration.py for the Contents Capture importer and the insured link's submit) 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_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_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.
  • 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_grouping and awaiting_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 usual resolve_billing_identity chain (job → room → claim → team). This is deliberate: the homeowner is not the customer, must never see anything about credits, and must never be shown a 402 for a balance that is not theirs.