Pro Metered Usage
Viewing latest docs.
Switch version: v3

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/4 whenever 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

shell
stripe products create --name="Essential"

Save the returned id (looks like prod_xxxxx).

Now create a metered price. The key is usage_type=metered:

shell
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:

  1. Stripe → cog → SettingsBillingCustomer Portal
  2. Expand Subscriptions
  3. Enable Customers can switch plans
  4. 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:

shell
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:

  1. Run the webhook listener (stripe listen --forward-to localhost:4000/webhooks/stripe) — keep it running in tmux
  2. Set STRIPE_SECRET and STRIPE_WEBHOOK_SECRET in .envrc
  3. 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:

shell
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:

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/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

elixir
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:

config/config.exs
- {"@daily", PetalPro.Billing.Providers.Stripe.Workers.MeterSyncWorker}
+ {"* * * * *", PetalPro.Billing.Providers.Stripe.Workers.MeterSyncWorker}

Or trigger one manually in IEx:

elixir
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:

  1. Remove the month/year toggle on the subscribe page (we’re metered now)
  2. Move AI chat behind the subscription routes (we need access to the customer + subscription)
  3. Update the menu
  4. Capture token usage from the AI calls
  5. Call Meters.record_event/4 from 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:

subscribe_live.ex
-        <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:

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:

subscription_routes.ex
      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:

elixir
@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).

  1. Go to Organizations → click your org → Subscribe
  2. Pick a plan, complete checkout with 4242 4242 4242 4242
  3. Once subscribed, click AI Chat in the org menu
  4. 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 SettingsBilling. 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.