Metered Usage

How to add metered usage to your Stripe subscription

Before you start

Creating a metered usage subscription is similar to:

💳Adding a subscription

The main differences being:

  • In Stripe you create a Meter and you create Products/Prices that are metered

  • In Petal Pro you record and synchronise usage with Stripe

The following will guide you through the process of adding metered usage to the AI chat feature.

Starting with a fresh app

For this tutorial you will need:

  • Access to Petal Pro (you can purchase it on petal.build)

  • Elixir & Erlang installed (you can follow our installation guide to get up and running)

  • PostgreSQL running: Postgres.app works well for Mac or we have a docker-compose.yaml file that will run Postgres for you

  • An account on Stripe

  • For deployment (optional) you will need:

Download

Head over to the projects page and create a new project, then download v3.0.0. You can unzip it in your terminal with the command unzip petal_pro_3.0.0.zip.

Run

First, rename the project:

Terminal
mv petal_pro_3.0.0 sub_demo
cd sub_demo
mix rename PetalPro SubDemo

Next, start the demo app:

Terminal
mix setup
mix phx.server

Configure Stripe

Before we do anything inside the demo app, we need to add some Stripe Products. Login using your account. Make sure that Stripe is in "test mode" - this will allow you to test purchasing without a credit card.

Stripe has introduced isolated test environments through Sandboxes. We recommend using a Sandbox in place of "test mode".

Install the Stripe CLI

Follow these directions to install the Stripe CLI. Then login via the command line (the instructions to login are also on this page):

Terminal
stripe login

Commands run using the Stripe CLI default to test mode

Create a product with usage-based prices

First create the "Essential" product:

Terminal
stripe products create --name="Essential"

Take note of the id returned, it will look like prod_xxxxx.

Create the Monthly price for metered usage:

Terminal
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"

Make sure you replace prod_xxxxx with the id returned from the previous call. usage_type is set to "metered" - which means that the unit price isn't fixed. The price is 5 cents per 1000 units (rounding up - meaning a minimum of $0.05 will be charged on single use).

Note that --unit-amount is in cents, rather than dollars

Add the "Business" and "Enterprise" products

In this guide we'll stick to per month pricing to keep it simple for the end user.

Repeat the process (above) to create a "Business" and an "Enterprise" product. Use the table below as a point of reference for suggested prices:

Product
Essential
Business
Enterprise

Monthly price

$0.05 per 1000 units

$0.5 per 15,000 units

$5 per 200,000 units

Enable switching plans for the Customer Portal

When purchasing a subscription, Petal Pro will redirect to Stripe. In the case where the user switches from an existing plan to another - you need to enable some settings for the Customer Portal. Once logged into Stripe:

  1. Make sure Stripe is in Sandbox or Test mode

  2. Click on the cog on the top right (Settings)

  3. In the section titled "Billing", click on Customer Portal

  4. Expand the "Subscriptions" section

  5. Enable the setting, Customers can switch plans

  6. Using "Find a test product..." add prices from the Essential, Business and Enterprise products

If you can't add usage meter prices - you may have to remove all prices and then try again

In addition, you may want to review the "Business information" section - to customise the output of the Customer Portal.

Create a Meter

The final step is to create a Meter. A Meter allows you to record usage against a particular Subscription/Price. Think of it as a way of grouping cost associated with a feature. In this guide we're going to create a Meter for the AI chat feature:

Terminal
stripe billing meters create  \
  --display-name="AI tokens" \
  --event-name=ai_tokens \
  -d "default_aggregation[formula]"=sum

At the time of writing, billing is a recent addition to the Stripe CLI. Make sure that you're on the latest version of the Stripe CLI

Configure Petal Pro

Petal Pro comes pre-configured for Stripe integration. There are three things you need to do to test the integration locally:

  • Run the CLI web hook

  • Add Stripe settings to your development environment

  • Update the Petal Pro config with product/price details (created above) from your Stripe account

Run the CLI web hook

In order to test your Stripe integration on your dev machine, you'll need to run a web hook. This is done using the Stripe CLI (see the Install the Stripe CLI section for more information).

Once you are logged in via the Stripe CLI, you can run the following command:

Terminal
stripe listen --forward-to localhost:4000/webhooks/stripe
> Ready! You are using Stripe API Version [2022-11-15]. Your webhook signing secret is whsec_xxxxxxxxxxxxxxx (^C to quit)

Take note of the whsec_xxxxxxxxxxxxxxx secret. This will be used as your STRIPE_WEBHOOK_SECRET in the next section.

Add Stripe settings to your development environment

First, you'll need your STRIPE_SECRET and your STRIPE_WEBHOOK_SECRET. To get your STRIPE_SECRET:

  1. Go to the Stripe console

  2. Make sure you're in Sandbox or Test mode

  3. At the bottom left of the screen, click on Developers then click API keys

  4. Under "Standard Keys", look for the "Secret key"

  5. Click the secret key to copy it

To get the STRIPE_WEBHOOK_SECRET, see the instructions under Run the CLI web hook.

The next step is to add these as environment variables to your demo app. Make sure that you install direnv and that you're in the root of the demo project:

Terminal
cp .envrc.example .envrc

Uncomment these lines at the bottom of the file:

.envrc
# If using Stripe for payments:
export STRIPE_SECRET=""
export STRIPE_WEBHOOK_SECRET=""
export STRIPE_PRODUCTION_MODE="false"

Update the values you obtained (above) for STRIPE_SECRET and STRIPE_WEBHOOK_SECRET.

The .envrc file is listed in .gitignore. Meaning that the secrets will live on your development machine and will not be accidentally pushed to the git repo.

To activate the new environment variables:

Terminal
direnv allow

Update the Product config

The following shows how you can update the Essential plan:

config\config.exs
config :petal_pro, :billing_products, [
  %{
    id: "essential",
    name: "Essential",
    description: "Essential description",
    most_popular: true,
    features: [
      "Essential feature 1",
      "Essential feature 2",
      "Essential feature 3"
    ],
    plans: [
      %{
        id: "essential-monthly",
        name: "Monthly",
-        amount: 1900,
+        unit_amount: 5,
+        per_units: 1000,
        interval: :month,
        allow_promotion_codes: true,
-        trial_days: 7,
        items: [
-          %{price: "price_1NLhPDIWVkWpNCp7trePDpmi", quantity: 1}
+          %{price: "price_xxxxx"}
        ]
      },
-      %{
-        id: "essential-yearly",
-        name: "Yearly",
-        amount: 19_900,
-        interval: :year,
-        allow_promotion_codes: true,
-        items: [
-          %{price: "price_1NWBYjIWVkWpNCp7pw4GpjI6", quantity: 1}
-        ]
-      }
    ]
  }, 
  # Business and Enterprise config too
}

amount has been replaced with unit_amount and per_units - this controls what the user sees for each plan on the subscribe page. Eliminating trial_days means that you don't have to wait until the trial is over to see upcoming charges.

In this guide we're focusing on monthly pricing - the essential-yearly product has been removed.

When the user selects a plan, Petal Pro will use these settings to make a Stripe call. In the line where we're adding price_xxxxx we're omitting quantity. If the quantity were present it would be passed up too. In the case of a usage-based price, passing up quantity will result in a Stripe error.

Replace price_xxxxx with a valid price id. To get the list of prices for the "Essential" product:

Terminal
# To get the "Essential" Product id
stripe products search \
  --query="name:'Essential'"

# To list all Prices that belong to the "Essential" product
stripe prices list  \
  --product="essential_product_id" \
  --active=true

Repeat the above process for the Business and Enterprise plans.

Update the Meter config

Add a reference to our "AI Tokens" meter:

config/config.exs
config :petal_pro, :billing_meters, [
-  # %{
-  #   id: "mtr_test_61SNiSGOruDKkjzQ241IWVkWpNCp70L2",
-  #   name: "API Usage",
-  #   event_name: "api_meter"
-  # }
+  %{
+    id: "mtr_xxx_xxxxx",
+    name: "AI Tokens",
+    event_name: "ai_tokens"
+  }
]

To get the current list of meters:

Terminal
stripe billing meters list

Find the meter that has the event_name of ai_tokens. Use it's id to replace mtr_xxx_xxxxx.

Enable the CollectMeterEvents GenServer

The CollectMeterEvents GenServer is used to record meter usage events without blocking the calling process. Uncomment the CollectMeterEvents GenServer to enable it:

lib\petal_pro\application.ex
defmodule PetalPro.Application do  
  def start(_type, _args) do
    children = [
      ...
      
      # Start the meter collection GenServer
-      # PetalPro.Billing.Meters.CollectMeterEvents,
+      PetalPro.Billing.Meters.CollectMeterEvents,

      ...
    ]
    
    opts = [strategy: :one_for_one, name: PetalPro.Supervisor]
    Supervisor.start_link(children, opts)
  end  
end

Enable the synchronisation job

Synchronisation with Stripe is handled by the MeterSyncWorker Oban job. Uncomment the Oban configuration to enable it:

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.Workers.ExampleWorker}
-       # {"@daily", PetalPro.Billing.Providers.Stripe.Workers.MeterSyncWorker},
+       {"@daily", PetalPro.Billing.Providers.Stripe.Workers.MeterSyncWorker},
       # {"* * * * *", EveryMinuteWorker},
       # {"0 * * * *", EveryHourWorker},
       # {"0 */6 * * *", EverySixHoursWorker},
       # {"0 0 * * SUN", EverySundayWorker},
       # More examples: https://crontab.guru/examples.html
     ]}
  ]

Note that there's two Oban queues. Billing/Stripe jobs utilise the billing queue - including the MeterSyncWorker job. Oban Web allows you to view/isolate separate queues!

Handling metered usage

There are two steps with regards to handling metered usage:

  1. Record the usage

  2. Sync the usage with Stripe

Record the usage

Metered usage is stored in the Petal Pro database. To record an event, use the record_event function:

alias PetalPro.Billing.Meters

Meters.record_event(meter_id, customer_id, subscription_id, quantity)

record_event is non-blocking and enqueues the event using the CollectMeterEvents GenServer. In the background, the CollectMeterEvents GenServer writes the event to the database as soon as it can.

Sync the usage with Stripe

When it comes to metered usage events, Petal Pro is considered the source of truth. For example, when you record an event you specify the subscription_id. You can't filter by subscription_id when retrieving meter usage events via the Stripe API. However, you can when you return meter usage events from the database. This is used in the billing page to match meter usage events to the upcoming invoice.

Synchronisation with Stripe is handled by the MeterSyncWorker Oban job - which will upload unprocessed events to Stripe. A metered usage event is considered processed if:

  • The event has been uploaded successfully (it will be marked as sent)

  • There is an error (it will be marked with the error)

Metered events are processed in batches to avoid excessive uploads using the Stripe API. How often the synchronisation is run is based on the Oban configuration.

By default, this will happen once a day. However, for testing in development you might find it useful to increase the frequency. The following will run the Oban job every minute:

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

Updating the chat feature

Let's implement the following things to update the AI chat feature so that it can record metered usage:

  • Remove month/year toggle from the subscribe page

  • Change the user AI chat route - so it can load subscriptions

  • Update the menu

  • Update the Langchain calls to return usage

  • Adjust the AI chat feature so that it can consume the usage

Remove month/year toggle from the Subscribe page

Just remove the interval_selector from the pricing_panels_container:

subscribe_live.ex
      <.container class="my-12">
        <.h2 class="mb-8 text-center">{gettext("Choose a plan")}</.h2>

-        <BillingComponents.pricing_panels_container panels={length(@products)} interval_selector>
+        <BillingComponents.pricing_panels_container panels={length(@products)}>
          <%= for product <- @products do %>
            <BillingComponents.pricing_panel
              id={"pricing-product-#{product.id}"}

Change the user AI chat route

Recording metered usage requires a customer and subscription id:

alias PetalPro.Billing.Meters

Meters.record_event(meter_id, customer_id, subscription_id, quantity)

The simplest way to get this data is through the SubscriptionRoutes. First, remove the existing route:

router.ex
      live "/users/org-invitations", UserOrgInvitationsLive
      live "/users/two-factor-authentication", EditTotpLive

-      live "/ai-chat", UserAiChatLive

      live "/orgs", OrgsLive, :index
      live "/orgs/new", OrgsLive, :new

Then add the following to SubscriptionRoutes:

subscription_routes.ex
      scope "/app", PetalProWeb do
        pipe_through [:browser, :authenticated, :subscribed_user]

        get "/subscribed", PageController, :subscribed

        live_session :subscription_authenticated_user,
          on_mount: [
            {PetalProWeb.UserOnMountHooks, :require_authenticated_user},
            {PetalProWeb.OrgOnMountHooks, :assign_org_data},
            {PetalProWeb.SubscriptionPlugs, :subscribed_user}
          ] do
          live "/subscribed_live", SubscribedLive
+          live "/ai-chat", UserAiChatLive
        end
      end

      scope "/app", PetalProWeb do
        pipe_through [:browser, :authenticated, :subscribed_org]

        scope "/org/:org_slug" do
          get "/subscribed", PageController, :subscribed

          live_session :subscription_authenticated_org,
            on_mount: [
              {PetalProWeb.UserOnMountHooks, :require_authenticated_user},
              {PetalProWeb.OrgOnMountHooks, :assign_org_data},
              {PetalProWeb.SubscriptionPlugs, :subscribed_org}
            ] do
            live "/subscribed_live", SubscribedLive
+            live "/ai-chat", UserAiChatLive
          end
        end
      end

For a :user based subscription, it'll look the same:

http://localhost:4000/app/ai-chat

But for an :org based subscription, the url will look something like this:

http://localhost:4000/app/org/abbott-quigley/ai-chat

In either case, the Live View will be mounted with the current Customer and Subscription.

Update the menu

For this guide, we're using :org based subscriptions. In menus.ex remove the :user_chat_ai menu item:

menu.ex
  def main_menu_items(nil), do: []

  # Signed in main menu
-  def main_menu_items(current_user), do: build_menu([:dashboard, :orgs, :subscribe, :user_ai_chat], current_user)
+  def main_menu_items(current_user), do: build_menu([:dashboard, :orgs, :subscribe], current_user)

  # Signed out user menu
  def user_menu_items(nil), do: build_menu([:sign_in, :register], nil)

Then update the org menu:

org_layout_component.ex
          [
            get_link(:org_dashboard, org),
            get_link(:org_settings, org),
-            get_link(:org_subscribe, org)
+            get_link(:org_subscribe, org),
+            get_link(:org_chat, org)
          ],
          & &1
        )

And add the get_link function:

org_layout_component.ex
  defp get_link(:org_chat, org) do
    if Customers.entity() == :org do
      %{
        name: :org_chat,
        path: ~p"/app/org/#{org.slug}/ai-chat",
        label: gettext("AI Chat"),
        icon: "hero-command-line"
      }
    end
  end

Update the Langchain calls to return usage

The following adds the on_llm_token_usage callback. To enable it, include_usage must be set to true:

user_ai_chat_live/langchain.ex
            %{
              on_llm_new_delta: fn llm_chain, delta ->
                send(live_view_pid, {:chat_delta, delta, llm_chain})
+              end,
+              on_llm_token_usage: fn llm_chain, usage ->
+                send(live_view_pid, {:usage, usage, llm_chain})
              end
            }
          ],
-          stream: true
+          stream: true,
+          stream_options: %{include_usage: true}
        })
    }
    |> LLMChain.new!()

on_llm_token_usage sends a message to the calling Live View.

Adjust the AI chat feature

Finally, add the following handle_info callback in user_ai_chat_live:

user_ai_chat_live.ex
  @impl true
  def handle_info({:usage, %Elixir.LangChain.TokenUsage{input: input, output: output}, _assistant}, socket) do
    customer = socket.assigns.customer
    subscription = socket.assigns.subscription

    meter = PetalPro.Billing.Meters.get_meter_by_event_name("ai_tokens")

    PetalPro.Billing.Meters.record_event(meter.id, customer.id, subscription.id, input + output)

    {:noreply, socket}
  end

This consumes the message sent from the Langchain helper. Because the code is inside the Live View and it's protected by the subscription routes, we can grab the customer and the subscription as an assign. The input and output parameters represent the number of tokens that have been used. Once we lookup the meter we have enough information to call record_event.

Ready to go!

In the web app, login as the admin user.

The default user name for the admin user is [email protected]. The default password is password

Navigate to the Organisations page:

http://localhost:4000/app/orgs

Click on your Organisation, then click "Subscribe". You'll see the following page:

Subscribe page for an Organisation

Choose a plan to subscribe to. Then use Stripe to complete the process - you can use 4242 4242 4242 4242 as your credit card for testing.

You must have the Stripe web hook running so that you can complete this process

Once you have completed the process, go back to the Organisations page and click on "AI Chat":

Org AI Chat!

Ask a question - this will trigger the code that collects metered usage. Now go to the Admin page and click on "Oban Web":

Oban Web

Make sure that the MeterSyncWorker job has successfully completed.

Finally, go to your Organisation, then click on "Org Settings" and "Billing". You should see something similar to this:

Organisation Billing page

If everything went well, you should see an "Upcoming" amount and "AI Tokens" usage data . If you configure multiple Meters, they will be automatically grouped on this screen.

It takes time for the upcoming invoice to be updated in Stripe. If you're quick, give it at least 30 seconds!

Deployment with Fly.io

Once your demo is ready, you can push it to production. To do so, there's three steps:

  • Push the demo app to Fly.io

  • Setup webhook endpoint on Stripe

  • Configure Fly.io environment variables

Push the demo app to Fly.io

To start with Fly.io, use the following guide:

🌎Deploy to Fly.io

Setup webhook endpoint on Stripe

You can use the Stripe CLI to run the webhook when working on your own dev machine. However, for a hosted service, you need to configure an endpoint in the Stripe console:

  1. Open the Stripe console

  2. Make sure you're in "Test mode"

  3. Click on Developers at the top of the page

  4. Then click on Webhooks

  5. Click on Add endpoint

For the Endpoint url enter the following (replace your-host-name with the name of your fly application):

https://your-host-name.fly.dev/webhooks/stripe

Further down the page under the heading "Select events to listen to" - click on + Select events. Add the following events to the list:

  • customer.subscription.created; and

  • customer.subscription.updated

Finally, click the Add endpoint button.

Configure Fly.io environment variables

The demo app running in Fly.io needs to be configured for Stripe. We need two settings. First, to obtain the STRIPE_SECRET:

  1. In Stripe, make sure you're in "Test mode"

  2. Click on Developers

  3. Click on API Keys

  4. Click on Reveal test key

  5. Click the key to copy it. This is for our STRIPE_SECRET environment variable

Next, to obtain the STRIPE_WEBHOOK_SECRET:

  1. In Developers click on Webhooks

  2. Click on the endpoint you just created

  3. Under "Signing secret" click on Reveal

  4. Copy the secret

Now at the command line, run the following command:

Terminal
fly secrets set _
  STRIPE_SECRET="sk_test_xxx" _
  STRIPE_WEBHOOK_SECRET="whsec_xxx" _
  STRIPE_PRODUCTION_MODE="false"

Once Fly has finished deploying you're new secrets, you should be able to purchase a subscription using the Fly demo app!

Using the CLI with "Live mode"

The Stripe CLI defaults to "Test mode". To work in "Live mode" you'll need to pass in an extra parameter. For example, if you wanted to list products in live mode you would do the following:

stripe products list --live

Not all Stripe CLI commands support the --live flag. But it will work with the commands listed in this tutorial.

Recreate the Stripe Products

You'll need to repeat the Configure stripe section - re-creating the products in "Live mode". This means that you'll end up creating the same products, but they will have different ids. Before you update the configuration in /config/config.exs, I suggest that you copy the following section to /config/dev.exs:

config :petal_pro, :billing_products, [
  %{
    id: "essential",
    name: "Essential",
    description: "Essential description",
    most_popular: true,
    # ...
  }

That way you can stick with "Test mode" on your dev machine and then use /config/config.exs for your production server.

Last updated

Was this helpful?