Per-second billing, priced once from the event log
Per-second billing, computed once: when a call ends the backend replays its event log, prices each component at a frozen rate, and deducts a single charge.

Updated 14 September 2026 with the June settlement changes, the money-unit fix, figures, and the production check behind it.
The cost of a Talkif call is not a running meter. Per-second billing here means the seconds are counted after the fact: nothing is summed while the call is in progress. When the bot reports that the call has ended, the backend replays every event that call produced — each language-model turn with its token counts, each synthesised sentence with its character count, the bot's connect and disconnect timestamps — prices each component against a rate it freezes into the record, adds the lines in whole micro-dollars, and deducts the total from the account's credit once. That single number is pushed to the dashboard as a call-ended event, followed by a balance-updated event, typically within a few seconds of the caller hanging up. It is final when it arrives, and four independent checks make sure it is never charged twice.
Per-second billing: seconds are floored, minutes are ceiled, and the clock is the bot's
Duration is measured between the moment the bot joined the media stream and the moment it left — the bot's own timestamps, not the carrier's. The difference is taken in whole seconds, rounding down. A call the bot never joined has a duration of zero and bills nothing that depends on duration; if a connected call is missing its disconnect timestamp, the backend falls back to the carrier-reported duration and then to the call row's own end time, because billing a connected call as zero seconds is a silent leak, not a rounding error.
What the seconds are multiplied by differs per component, and the differences follow the vendors. Telephony is billed the way carriers bill us — by the minute, rounded up. Speech-to-text is billed on the whole call's audio, prorated by the second, because the transcriber listens to the entire stream, not only to the caller. The $0.05-per-minute platform fee is prorated by the second too. Language-model cost is tokens times the model's rate; text-to-speech is characters times the voice's rate — the unit the voice vendors themselves meter in. ElevenLabs' help centre puts it in one sentence: "When you generate using the website, each text "character" you generate into speech will consume 1 credit" (ElevenLabs, Have characters changed?). We bill the same character count the bot sent to be synthesised, at our per-thousand rate. Each line is a separate entry in the charge, visible in the dashboard's cost breakdown.
Money is an integer, and the column name is a hundred times off
There are no floating-point numbers in the money path. Every amount is an i64 in units of one-millionth of a dollar: a token rate is "micro-dollars per thousand tokens", a minute rate is "micro-dollars per minute", and each line is an integer multiply followed by an integer divide. The only float in the whole computation is the ceil that turns seconds into carrier minutes, and its result is cast back to an integer before it touches a price.
The honest footnote is that every one of those columns is named *_microcents. Read literally, a microcent is a hundredth of a micro-dollar, so the name is off by a factor of 100 from what the columns hold. Nothing was ever billed wrongly — the code has always been consistent with itself — but the name had propagated into comments that were wrong, and in September 2026 we pinned the unit down in one place and checked it against production: a cohort of 239 calls summed to 54,855,274 units, against $54.6613 in the calls' stored dollar costs, which is the micro-dollar reading within a fraction of a per cent (the stored cost is rounded per call). The columns keep their name; the constant that says what they mean is now the one thing the rest of the code imports.
A missing rate is an error, not a guess
Each component needs one rate, and the lookup is a single query with a three-level fallback: a rate set for this account, else a rate for this exact model, else the provider's default. If none of the three exists — a model added to the catalogue without a price — the lookup fails, and so does the finalisation. It is retried later, with the call still unbilled, rather than billed at a number the code made up.
The rate that was found is copied into the charge alongside the quantity it applied to. The rate table can change tomorrow; yesterday's charge still says what it was priced at.
The price is replayed, not accumulated
The bot publishes its events to a durable stream as the call runs, and the backend persists transcripts and statuses from that stream as they arrive. It does not keep a running cost. When the terminal event arrives, the backend opens a fresh reader on the same stream, filtered to that one call, reads everything back from the beginning — stopping after 300 ms of quiet or 5 s at most — and aggregates tokens, characters and timestamps from scratch. If the stream has already aged out (events are kept for seven days), it falls back to the transcript stored on the call record.

Pricing from the log rather than from counters means there is nothing to drift. A pod that restarted mid-call, a duplicate delivery, an event processed twice: none of it changes what the replay sees, because the replay is the source. Before anything is charged, the total also has to pass a set of sanity guards — no call over $100, no call over $5 a minute, and a handful of consistency checks of the form "if there were tokens, there is a language-model line; if there was a carrier leg, there is a telephony line". A guard failure stops the charge and raises an error for a human, because a $0 telephony line on a two-minute phone call is a bug, not a discount.
Charged once: four layers that say so
Stripe's documentation describes the contract an idempotency key makes: "Stripe's idempotency works by saving the resulting status code and body of the first request made for any given idempotency key, regardless of whether it succeeds or fails. Subsequent requests with the same key return the same result, including 500 errors" (Stripe API reference, Idempotent requests). Our charge service makes the same promise to the rest of the backend, and the call path stacks four independent versions of it.
The first is a claim on the call itself: before finalisation starts, one UPDATE stamps a claim on the call row, and it succeeds only if the call is not yet finalised and either unclaimed or claimed more than five minutes ago. Two pods that receive the same terminal event race for it, and one loses. The second is the cost record: inserting it returns false on a duplicate, and a finaliser that sees false skips the charge and only marks the call done. The third is the charge's own key — call_finalization: followed by the call id — which the charge service looks up before computing anything, returning the original result if it exists. The fourth is the database: a unique constraint on (account, key), so that even two charges computed concurrently cannot both land.
Inside the charge, everything is one transaction: the balance is locked, the charge row and its line items are written, the credit buckets are consumed, the balance transaction and status history are recorded, and the whole thing commits or none of it does.

The claim is the only state that can go backwards. A finaliser that dies after claiming leaves a five-minute-old stamp; the next terminal event, or the next pod to look, takes the claim over and finds either no cost row — and prices the call — or a cost row already there, and only marks the call done.
Settling into the red, on purpose
Until June 2026 a charge that exceeded the available balance failed, and the finaliser retried it forever. The retry was pointless: the call had already happened, and the providers had already billed us for the tokens and minutes. Refusing to record the charge did not recover that cost; it only left a completed call with no price on it.
Now a call-related charge is allowed to overdraft. The shortfall is recorded as a negative credit bucket — a debt — and netted against the next top-up. What prevents unbounded loss moved to where it can actually work: admission. An account below the minimum balance (by default $1.00, netted against any outstanding debt) cannot start another call, on any path — API, dashboard, inbound, campaign or schedule — until it tops up. There is no mid-call cut-off; a call that is running finishes, and the admission gate is the control.
When a charge does have credit to draw on, the buckets are consumed in a fixed order — subscription credit, then promotional, then bonus, then purchased — and within a tier the one expiring soonest goes first. The consumption is a single statement with a running total, not a loop of reads and writes, so two concurrent charges on the same account cannot interleave. Bonus and promotional credit cannot be spent on phone numbers; those can only come from purchased credit and can never overdraft.

Auto-recharge, and the race it had
After the transaction commits, the charge service spawns a check: if auto-recharge is on and the balance has fallen under the account's threshold, trigger a top-up. It is rate-limited — at most five attempts a day, at least an hour between them.
In July 2026 that cooldown was not enough, and the bug was ours. A single charged call spawned the check twice — once from the charge service's post-charge hook and once, redundantly, from the finaliser itself. The cooldown guard was an insert conditioned on "no attempt in the last hour", which under Postgres's default isolation two concurrent inserts can both pass; both enqueued a top-up, and each created its own off-session card payment. One call, two charges to the customer's card. The fix has three parts: the redundant spawn is gone; a partial unique index allows at most one pending or in-progress attempt per account, so the losing racer's insert is dropped by the database rather than by a check the database cannot see; and the card payment now carries its own idempotency key. The guard lives in the database on purpose — it holds across pods, and it holds without advisory locks, which our cross-region write setup does not support.
What reaches the dashboard
The finaliser ends by broadcasting two events to every dashboard session open on the account. call-ended carries the call's final cost; balance-updated carries the new balance. A call's cost field is empty until that moment — there is no "estimated" state shown to the customer, because the estimate would be wrong in a way the final number is not. The per-call breakdown, with each line's quantity, rate and subtotal, is the same record the charge wrote, read back through the billing API.
Developers
Turn detection that doesn't talk over you
How our voice agent's turn detection decides a caller has finished, when an interruption is real and when to stay quiet, and the incidents behind it.
Integrations
Meta lead ads, called on arrival and reported back
Connect Meta lead ads to Talkif: each lead is called within your calling hours, and received, contacted, qualified and converted go back to Meta.
Developers
Webhook SSRF, closed by a sender that holds nothing
Webhook SSRF closed twice: the process with your secrets never sends, and the process that sends can reach only the public internet.
Developers
Voice agent function calling with bound parameters
Voice agent function calling on Talkif: describe the endpoint once, then bind each parameter to the model, the call or a fixed value no caller can spoof.
Questions about this piece? Write to us.



