Your database is the meter
Paddle sells metered billing. What it actually sells is a way to put an amount on a bill. The counting is yours. Here is the ledger, the rollup, and the single API call we bill AI resolutions with.
Kal, the AI agent in SupportWire, resolves support conversations on its own. That is the product, and it is also the thing that costs us money every time it runs, so it is priced per unit: $0.49 per AI resolution after 50 included on One, with overage billed monthly.
Simple pricing page. Considerably less simple to bill, because our merchant of record is Paddle, and Paddle does not do metering for you.
What Paddle gives you, and what it doesn't#
Paddle sells metered billing as a product. Its metered billing page promises "flexible metered billing that you can set up in minutes" and "no more lost revenue from uncharged or overdue overages." Read it closely, though, and notice what it is actually offering. Every mechanism it names is a way to move an amount onto a bill:
- the Subscription Modifiers API: "increase or decrease the next payments due by a flat amount"
- the Subscription Update API: broader changes to the subscription itself
- the Charges API: "sell add-ons on top of a recurring subscription, billed to the card and account on file," independent of the subscription's cycle
Not one of them is a way to record usage. There is no meter API, no event ingestion endpoint, no aggregation. Nothing counts for you, nothing dedupes for you, and nothing decides what a "unit" is. The clearest tell sits on the same page: for usage-based pricing, Paddle points you at a partnership with m3ter, a separate metering-and-pricing engine that ingests the raw usage events. If aggregation were native, there would be nothing to partner about.
Two more things worth knowing before you design around that page. Subscription Modifiers is Paddle Classic vocabulary. On Paddle Billing the equivalent is updating subscription items, and Classic's modifiers, coupons and charges have been reshaped into different entities. And "Paddle manages proration for you by default" is a selling point for plan changes and an active hazard for usage: proration is precisely what you do not want applied to a closed month's overage.
So in practice you get two usable paths for a number you have already computed yourself: change the quantity of a recurring line item on the subscription (PATCH /subscriptions/{id}), which is what we already do for seat-style power-ups; or create a one-time charge (POST /subscriptions/{id}/charge), with effective_from set to immediately or next_billing_period.
Counting, idempotency, allowances, corrections, the audit trail: all yours. Paddle is the last twenty lines.
We chose to build that half ourselves rather than add m3ter. What follows is what that costs.
The shape we landed on#
Four steps, then Paddle:
- Kal resolves (confirmed or assumed)
- Ledger row (one row per resolution)
- Monthly snapshot (net minus included)
- Paddle charge (one-time, immediately)
Idempotency is a different guarantee at each layer:
| Layer | Guard | What it protects |
|---|---|---|
| Ledger | unique (activity_id) | A duplicate write is a no-op |
| Snapshot | unique (org, month) | The rollup is replayable |
| Charge | reported_to_paddle | The card is charged once |
0 1 1 * *: one Oban job per paid org. Three layers, three different idempotency guarantees. Only the last one protects the customer's card; the first two protect the number that reaches it.
Each layer has a different job: the ledger records what happened, the snapshot records what we decided to bill, and the Paddle call records what we actually charged. When a customer disputes an invoice line six weeks later, you can walk all three.
01. The ledger
Start with a table. Ours is ai_resolution_ledger, and it does one thing: every time Kal resolves a conversation, it gets a row. Which org, which conversation, which resolve activity, what month it belongs to, and what a resolution cost at the moment it was recorded. Prices change, and old rows should not silently reprice themselves when they do.
Rows are append-only. Nothing is updated, nothing is deleted; a correction is another row. That is what makes the table answerable six weeks later, when a customer asks what a line on their invoice was for.
Idempotency is structural, not defensive. Each row is keyed to the MARKED_AS_RESOLVED activity message that caused it, with a unique index on resolve_activity_message_id. A duplicate write does not raise into the resolution flow: it degrades to a no-op:
{:error, %Ecto.Changeset{errors: errors}} = error ->
if Keyword.has_key?(errors, :resolve_activity_message_id) do
{:ok, :already_charged}
else
error
end02. What actually counts as a resolution
This is the part that makes or breaks trust in usage pricing, and it is entirely a product decision. Paddle has no opinion about it. We bill in two cases. Confirmed: the customer signals it worked (rating ≥ 4, a "this helped" button, or the agent asserting is_resolved) and the charge lands immediately. Assumed: the AI gave a real answer, not a greeting, not a handoff, and the customer went quiet for 24 hours. An Oban job armed at answer time fires, re-checks that the conversation is still open, still :answered, still unassigned, and charges then. If a newer answer moved the anchor, the job re-arms itself for the remainder rather than cancelling and rescheduling.
Every assumed charge passes through one policy gate, and only one:
defp billable?(_activity, :confirmed), do: true
defp billable?(%Message{} = activity, :assumed) do
case CompanySettings.fetch_organization_setting_by_org_id(activity.organization_id) do
%{bill_resolution_mode: :confirmed} -> false
%{suppress_charge_on_human_reply: true} -> not human_replied?(activity.conversation_id)
_ -> true
end
endCustomers can switch assumed billing off entirely, or suppress it whenever a human teammate touched the conversation. Usage pricing only survives contact with customers if they can see the count and control what feeds it.
03. The month-end rollup
A cron at 0 1 1 * * fans out one Oban job per paid org. Each job sums the ledger for the closed month, subtracts the included allowance, and upserts a snapshot:
def rollup(org_id, %Date{} = month) do
bd = Resolutions.breakdown_for_month(org_id, month)
included = Billing.get_effective_limit(org_id, @power_up)
overage = max(bd.net - included, 0)
...
endget_effective_limit/2 is where the allowance comes from: base plan limit, plus purchased power-up quantity, plus seats removed mid-period, plus any grandfathered grace. Overage is net - included floored at zero, times 49 cents. The snapshot upserts on (organization_id, billing_month, power_up_name), so the whole rollup is safe to re-run, which matters, because you will re-run it by hand at some point.
- Resolutions recorded
- 1,825
- Included (subtract)
- −50
- Billable overage
- 1,775
- One-time charge to Paddle
- $869.75
This is the rollup the cron computes. Paddle only sees the last line.
04. Handing it to Paddle
payload = %{
effective_from: "immediately",
items: [%{price_id: price_id, quantity: snapshot.overage_qty}]
}
Subscriptions.create_charge(subscription.uid, payload)That is the whole integration. The per-unit amount lives on the Paddle price; we send a quantity and let Paddle multiply. The interesting code is the four guards standing in front of it:
cond do
snapshot.reported_to_paddle -> {:ok, :already_reported}
snapshot.overage_qty <= 0 -> {:ok, :no_overage}
not real_paddle_price?(price_id) -> {:ok, :skipped_no_paddle_price}
true -> report_overage(snapshot, price_id)
endThe gotchas worth writing down#
One-time charge, not a line item. Bill the overage as a one-time charge, not as a quantity on a recurring line item. Our first pass did the latter: it reused the existing power-up path and upserted a line item with quantity = overage, and that is wrong in two ways. A line item inherits the subscription's cycle, so on a yearly base plan last month's usage would sit there waiting for the annual renewal, or proration would invent a number nobody can explain. And a line item persists: a quantity from a closed month keeps re-billing every renewal until something overwrites it. What we ship instead is POST /subscriptions/{id}/charge with effective_from: immediately, against whichever active base subscription the org has. Nothing in that path branches on interval, so a yearly customer is charged for last month on the first of this month, exactly like a monthly one.
Charge prices must be non-recurring. Paddle requires billing_cycle: null on any price you pass to /charge. If you created your usage price as a monthly recurring price, the intuitive thing to do when it sits next to your other monthly power-ups, the call is rejected. Check this in sandbox before you wire the cron.
Idempotency at every layer. A unique index on the ledger row, a unique key on the snapshot, a reported_to_paddle boolean before the API call. Skip any one of them and the failure is not a stack trace: it is a customer charged twice.
Client-generated UUIDs + on_conflict. Our primary keys are UUIDv7 generated in the app. An on_conflict upsert returns the struct carrying the phantom insert ID rather than the stored row's ID, so a later Repo.update on that struct raises Ecto.StaleEntryError. The fix is to re-read by the natural key after upserting:
{:ok, Repo.get_by!(MonthlyUsageSnapshot,
organization_id: org_id, billing_month: month, power_up_name: @power_up)}It cost an afternoon. It would have cost considerably more had mark_reported/2 been the thing that blew up in production, after the charge went through.
If you are on Paddle#
Paddle being a merchant of record, handling sales tax, fraud and chargebacks globally, is worth a lot, and it is why we are there. The tradeoff is that Paddle's billing primitives are thinner than a pure payments processor's, and metering is the clearest example. "Set up in minutes" is true of the part Paddle owns; the part it does not own is the meter, and that is where the months go.
Which is fine, as long as you plan for it. Build the ledger first, make it auditable and idempotent, keep the money math on your side, and treat the API call as the small, boring, heavily-guarded thing it should be.
Sources: Paddle metered billing · Create a one-time charge · Paddle × m3ter · Classic → Billing concepts
Frequently asked questions
Nothing. Paddle sells ways to put an amount on a bill: item updates and one-time charges. There is no meter API, no event ingestion, and no aggregation. You count usage, apply allowances, and send a quantity. Paddle multiplies by the price and collects. That is why they point usage pricing at m3ter.
A ledger table, one row per resolution. Org, conversation, activity, month, and the unit price at write time so old rows do not reprice later. Rows are append-only. Corrections are more rows. A unique index on the resolve activity makes a duplicate write a no-op instead of a second charge.
Two cases. Confirmed: a rating of 4 or higher, a this-helped click, or the agent marking the thread resolved. The charge lands immediately. Assumed: Kal gave a real answer, not a greeting or handoff, and the customer stays quiet for 24 hours. Orgs can turn assumed billing off or suppress it after a human reply.
A recurring line item inherits the subscription cycle. On a yearly plan, last month's usage waits for renewal, or proration invents a number nobody can explain. The quantity also persists and re-bills until something overwrites it. A one-time charge with effective_from immediately bills the closed month on the first of the next month, for monthly and yearly plans.
Three layers. The ledger has a unique index on the resolve activity, so duplicate writes are a no-op. The monthly snapshot upserts on org and month, so the rollup is replayable. A reported_to_paddle flag sits in front of the Paddle call. Skip any layer and the failure is a customer charged twice, not a stack trace.
Updated August 2026
