DEV Community

AI Video
AI Video

Posted on

Billing AI video by the rendered second (without double-charging when webhooks race)

At TalkPix you upload a portrait, type a script or upload your own audio, and get back a lip-synced talking video. Users pay with credits, and the price depends on how long the video is.

That sounds simple until you notice that you don't know how long the video is until it exists. A typed script has an estimated length. An uploaded recording can run for minutes. The provider renders asynchronously and tells you it's done through a webhook, which may arrive late, twice, or never.

This is the billing flow we ended up with, and the bugs that shaped it.

Rule 1: nothing renders before it's paid for

The first version had a free short preview. It cost real inference money and, when we measured it, didn't convert. Now there is exactly one rule in the generate endpoint: if the wallet can't cover the estimate, return 402 and don't call the provider.

// app/api/generate (simplified)
const estimate = estimateCredits({ seconds: estimatedSeconds, resolution });
const reserved = await reserveCredits({ userId, amount: estimate, jobId });
if (!reserved.ok) {
  return Response.json({ error: "insufficient_credits" }, { status: 402 });
}
await startPrediction({ jobId, webhook: `${BASE_URL}/api/webhooks/provider` });
Enter fullscreen mode Exit fullscreen mode

Reserving before the provider call matters. If you charge after, a user can start ten renders with credits for one.

Rule 2: balances only change inside Postgres functions

Every balance change is a security definer function that locks the wallet row. Application code never does update credit_wallets set balance = balance - x.

create or replace function reserve_credits(p_user uuid, p_amount int, p_job uuid)
returns int language plpgsql security definer as $$
declare bal int;
begin
  select balance into bal from credit_wallets where user_id = p_user for update;
  if bal < p_amount then
    raise exception 'insufficient_credits';
  end if;
  update credit_wallets set balance = balance - p_amount where user_id = p_user
    returning balance into bal;
  insert into credit_transactions (user_id, amount, type, related_job_id)
    values (p_user, -p_amount, 'generation_reserve', p_job);
  return bal;
end $$;
Enter fullscreen mode Exit fullscreen mode

for update serializes two renders started by the same user at the same moment. The transaction row gives you an audit trail for every support question that starts with "where did my credits go?"

Rule 3: bill the video you actually delivered

When the render finishes, the finalizer:

  1. copies the MP4 into our own storage (provider URLs expire),
  2. measures the real duration of the file,
  3. computes the true price, compares it with what was reserved,
  4. refunds the difference, or debits the extra.
const actual = priceFor(measuredSeconds, resolution);
const delta = actual - job.reservedCredits;
if (delta < 0) await refundCredits({ jobId, amount: -delta, reason: "duration_reconcile" });
if (delta > 0) await reconcileGenerationDebit({ jobId, amount: delta, reason: "duration_reconcile" });
Enter fullscreen mode Exit fullscreen mode

The interesting case is delta > 0. Reservations block overdrafts, which is what you want before a render. But for a long uploaded recording, the reservation was based on an estimate, the render has already been delivered, and the wallet may no longer cover the difference. Refusing to record that would make a delivered video impossible to bill.

So exactly one function is allowed to take a balance below zero: the reconcile for a completed, wallet-funded job. Everything else still blocks. A negative balance stops new renders until it's topped up.

-- only a completed job may create debt
select id into done_job from generation_jobs
 where id = p_job and user_id = p_user and status = 'completed';
if done_job is null then
  raise exception 'A completed job is required.';
end if;
Enter fullscreen mode Exit fullscreen mode

Rule 4: the webhook and the poller will both finish the job

The provider's webhook is the fast path. But webhooks get lost, so the result page also polls, and a background sweep asks about anything stuck. That means three different code paths can finalize the same job, sometimes at the same moment. Every one of them has to be safe to run twice.

Three things make that true:

One function finalizes. Webhook, poll and sweep all call the same finalizeJob(). There is no second implementation to drift.

Claim the transition atomically. The status change is a conditional update, and only the caller that actually changed the row continues:

const { data: claimed } = await db
  .from("generation_jobs")
  .update({ status: "completed", video_url, duration_seconds })
  .eq("id", jobId)
  .in("status", ["queued", "processing"]) // only from an unfinished state
  .select("id");

if (!claimed?.length) return; // another finalizer already won; do not bill again
Enter fullscreen mode Exit fullscreen mode

Make each money function idempotent on its own. Even if two callers get past the claim (say, a retry after a timeout), the database refuses to apply the same adjustment twice:

-- inside reconcile_generation_debit, after locking the wallet row
if exists (
  select 1 from credit_transactions
   where related_job_id = p_job
     and metadata ->> 'reason' = 'duration_reconcile'
) then
  return current_balance; -- already applied; retries are free
end if;
Enter fullscreen mode Exit fullscreen mode

Refunds work the same way: refund_credits flips a credits_refunded flag on the job in the same transaction, and does nothing if it's already set.

Rule 5: a completed job never becomes a failed one

The failure mode to design against: the reconcile call times out after the video was delivered, the error bubbles up through a generic catch, and the job gets marked failed and refunded. The user would get the video and their credits back.

So once a job is complete, a billing error is logged loudly for retry, and the job stays complete. Because the reconcile function is idempotent, retrying it later is safe.

What the user sees

All of this exists so the product can make simple promises:

  • you see the credit price before anything renders,
  • a render that fails returns its credits automatically,
  • you're billed for the video you received, at 720p or 1080p,
  • credits are one-time packs that never expire.

The same flow now backs everything on the platform: talking and singing photos, product video ads, and the AI video templates.

Checklist if you're building this

  • Reserve before you call the provider; return 402 if you can't.
  • Change balances only inside row-locking database functions, and log every change as a transaction.
  • Measure the delivered file, then settle the difference.
  • Allow debt in exactly one place: a completed, paid job.
  • Assume webhook, poll and sweep all finalize the same job. One finalizer, an atomic claim, idempotent money functions.
  • Never let a billing error undo a successful delivery.

If you want to see the result from the user's side, the talking photo studio shows the credit estimate before you render, and pricing lists the packs.

Questions about any of this are welcome in the comments.

Top comments (0)