Metered usage
How to charge customers based on how much they actually use — instead of fixed monthly tiers.
Before you start
Metered usage is a step beyond a regular subscription. You should have read Adding a subscription first, and have a Petal Pro app running locally.
The differences with metered billing:
- In Stripe you create a Meter plus prices configured for usage rather than a fixed amount
-
In Petal Pro you call
Meters.record_event/4whenever a billable action happens, and a background worker syncs those events to Stripe
This guide walks through wiring metered usage into the built-in AI chat feature — billing customers per AI token consumed.
You’ll need:
- A working Petal Pro app
- A Stripe account with the CLI installed
-
The Stripe CLI logged in (
stripe login)
ℹ️ Information: Stripe now offers Sandboxes as an alternative to test mode. We recommend using a Sandbox so you can experiment cleanly without affecting your real account.
Configure Stripe
Create a metered product
stripe products create --name="Essential"
Save the returned id (looks like prod_xxxxx).
Now create a metered price. The key is usage_type=metered:
stripe prices create \
--unit-amount=5 \
--currency=usd \
-d "transform_quantity[divide_by]"=1000 \
-d "transform_quantity[round]"=up \
-d "recurring[interval]"=month \
-d "recurring[usage_type]"=metered \
--product="prod_xxxxx"
Translation: 5 cents per 1,000 units, rounded up. So a single use costs at least $0.05.
ℹ️ Information: --unit-amount is in cents.
Repeat the process for Business and Enterprise at different rates:
| Plan | Rate |
|---|---|
| Essential | $0.05 per 1,000 units |
| Business | $0.50 per 15,000 units |
| Enterprise | $5 per 200,000 units |
Allow plan switching
Same as a regular subscription:
- Stripe → cog → Settings → Billing → Customer Portal
- Expand Subscriptions
- Enable Customers can switch plans
- Add the metered prices from each product
ℹ️ Information: If you can't add metered prices, remove all existing prices first then re-add.
Create a Meter
A Meter groups usage events under a feature. We’re going to track AI tokens:
stripe billing meters create \
--display-name="AI tokens" \
--event-name=ai_tokens \
-d "default_aggregation[formula]"=sum
ℹ️ Information: The billing command is a recent addition to the Stripe CLI. Make sure you're on the latest version.
Configure Petal Pro
Same three steps as a regular subscription:
-
Run the webhook listener (
stripe listen --forward-to localhost:4000/webhooks/stripe) — keep it running in tmux -
Set
STRIPE_SECRETandSTRIPE_WEBHOOK_SECRETin.envrc -
Update product/price IDs in
config.exs
If you’ve already done the subscription guide, you’ve already got steps 1 and 2. For step 3, your config will look slightly different — metered prices use unit_amount and per_units instead of a flat amount, and you can drop trial_days.
In this guide we’re using monthly pricing only, so you can also remove essential-yearly from the config.
When you call Stripe to subscribe a user, you must omit quantity for metered prices — passing it returns a Stripe error.
To grab your meter and price IDs:
stripe billing meters list
stripe prices list --product=prod_xxxxx
Or just ask Claude:
I've created Essential, Business, Enterprise products in Stripe with metered
prices, plus an "ai_tokens" meter. Update config.exs with the real IDs.
Enable the CollectMeterEvents GenServer
Recording events shouldn’t block the calling process. Petal Pro queues events through CollectMeterEvents — uncomment it in lib/petal_pro/application.ex:
defmodule PetalPro.Application do
def start(_type, _args) do
children = [
...
- # PetalPro.Billing.Meters.CollectMeterEvents,
+ PetalPro.Billing.Meters.CollectMeterEvents,
...
]
...
end
end
Enable the sync worker
MeterSyncWorker is the Oban job that uploads queued events to Stripe. Uncomment it in config.exs:
config :petal_pro, Oban,
repo: PetalPro.Repo,
queues: [default: 5, billing: 5],
plugins: [
{Oban.Plugins.Pruner, max_age: 3600 * 24},
{Oban.Plugins.Cron,
crontab: [
- # {"@daily", PetalPro.Billing.Providers.Stripe.Workers.MeterSyncWorker},
+ {"@daily", PetalPro.Billing.Providers.Stripe.Workers.MeterSyncWorker},
]}
]
ℹ️ Information: Notice the billing queue alongside default. Stripe-related jobs run on the billing queue so they can be monitored separately in Oban Web.
How metered usage works
Two halves: recording events and syncing them to Stripe.
Recording usage
alias PetalPro.Billing.Meters
Meters.record_event(meter_id, customer_id, subscription_id, quantity)
Non-blocking — it queues the event through CollectMeterEvents, which writes it to the billing_meter_events table in the background.
Syncing to Stripe
Petal Pro is the source of truth for metered events. The Stripe API doesn’t let you filter usage by subscription_id, but the Petal Pro database does — which is what makes the billing page able to show upcoming charges per subscription.
MeterSyncWorker runs daily by default and uploads unprocessed events to Stripe. Events are marked processed when uploaded successfully or when an error occurs.
🚨 Heads up: Events with errors won't sync to Stripe. Check the billing_meter_events table for error messages. Clear an event's error_message to make it eligible for re-processing.
For testing in dev, you might want to run the worker more often. Switch the cron schedule:
- {"@daily", PetalPro.Billing.Providers.Stripe.Workers.MeterSyncWorker}
+ {"* * * * *", PetalPro.Billing.Providers.Stripe.Workers.MeterSyncWorker}
Or trigger one manually in IEx:
PetalPro.Billing.Providers.Stripe.Workers.MeterSyncWorker.new(%{}) |> Oban.insert()
Wiring it into AI chat
Now to make AI chat actually charge per token. The plan:
- Remove the month/year toggle on the subscribe page (we’re metered now)
- Move AI chat behind the subscription routes (we need access to the customer + subscription)
- Update the menu
- Capture token usage from the AI calls
-
Call
Meters.record_event/4from the LiveView
If you’d rather skip the manual steps, ask Claude:
Wire AI chat into metered billing: remove the interval selector from the subscribe
page, move the AI chat route into SubscriptionRoutes, update menus.ex, and add
token tracking via Meters.record_event/4 in user_ai_chat_live.
The rest of this section walks through it manually.
Remove the month/year toggle
In subscribe_live.ex, drop the interval_selector attribute:
- <BillingComponents.pricing_panels_container panels={length(@products)} interval_selector>
+ <BillingComponents.pricing_panels_container panels={length(@products)}>
Move AI chat behind subscription
Recording usage needs customer_id and subscription_id. The simplest way to have those in the LiveView’s assigns is to mount under SubscriptionRoutes.
Remove the existing AI chat route from router.ex:
live "/users/two-factor-authentication", EditTotpLive
- live "/ai-chat", UserAiChatLive
live "/orgs", OrgsLive, :index
Add it inside SubscriptionRoutes instead — both the :user and :org blocks:
live_session :subscription_authenticated_user, ... do
live "/subscribed_live", SubscribedLive
+ live "/ai-chat", UserAiChatLive
end
live_session :subscription_authenticated_org, ... do
live "/subscribed_live", SubscribedLive
+ live "/ai-chat", UserAiChatLive
end
For :user subscriptions the URL is http://localhost:4000/app/ai-chat. For :org subscriptions it becomes http://localhost:4000/app/org/your-org-slug/ai-chat. Either way the LiveView mounts with customer and subscription already in assigns.
Update the menu
Remove the unrestricted AI chat link from menus.ex and add it to the org menu (assuming you’re using :org subscriptions). Then add a get_link/2 clause for :org_chat_ai pointing to the new path.
Capture token usage
Petal Pro v4 uses Jido AI (replacing LangChain). Cost tracking happens automatically — every API call is logged to the ai_call_logs table with token counts via a Req plugin.
For metered billing, you also need that data inside the LiveView so you can call Meters.record_event/4. Configure the AI client’s token usage callback to send a message back to self().
Record the event
Add a handle_info callback in user_ai_chat_live:
@impl true
def handle_info({:token_usage, %{input: input, output: output}}, socket) do
meter = PetalPro.Billing.Meters.get_meter_by_event_name("ai_tokens")
total_tokens = input + output
PetalPro.Billing.Meters.record_event(
meter.id,
socket.assigns.customer.id,
socket.assigns.subscription.id,
total_tokens
)
{:noreply, socket}
end
Because the LiveView is mounted under the subscription routes, customer and subscription are already in assigns. We just look up the meter and record the event.
Testing it
Sign in as admin (admin@example.com / password).
- Go to Organizations → click your org → Subscribe
-
Pick a plan, complete checkout with
4242 4242 4242 4242 - Once subscribed, click AI Chat in the org menu
- Ask the AI a question
Verify the event was recorded by querying billing_meter_events. Or ask Claude:
Use IEx to show me the latest billing_meter_events
To see the upcoming charges in Stripe, run MeterSyncWorker (or wait for the cron), then go to your org → Org Settings → Billing. You should see the upcoming amount and your AI Tokens usage.
ℹ️ Information: Stripe takes a moment to update upcoming invoices — give it 30 seconds.
Deploy
Same as a regular subscription — see the subscription deployment section. The Stripe webhook events you need are the same:
-
customer.subscription.created -
customer.subscription.updated
The metered billing happens server-side via the MeterSyncWorker cron — make sure your Fly.io app is keeping its workers alive.
Going live
Same flow as the subscription guide’s “Going live” — recreate your products and the meter in Stripe live mode, copy your test-mode billing config to config/dev.exs so dev keeps working with test prices, then update config.exs with the live IDs and set STRIPE_PRODUCTION_MODE=true on Fly.