From the blog

Usage-Based Free Trial in Phoenix: Trial Limits Instead of Trial Days

By Liam Killingback ·

Usage-Based Free Trial in Phoenix: Trial Limits Instead of Trial Days

The 14-day free trial is a habit borrowed from seat-based SaaS, where the cost of a trial user is close to zero and the only question is whether they come back. For a usage-priced product it fits badly. An AI feature, an API or a document pipeline costs you money on every call, so a trial that is limited only by time is an open tab: one determined user can run up a real bill in a fortnight. And the user who signs up on a busy Monday, tries one thing and forgets about you until day 15 got nothing from the trial at all.

A usage-based free trial fixes both. Instead of “14 days of everything”, the customer gets “50 generations” or “$5 of credit”, and the trial ends when they have used it, not when the calendar says so. This guide builds that in Phoenix with Aurora Meter, the free MIT metering library: three trial shapes, how to convert a trial into a paid plan without losing the counter, how to show the customer where they are, and how to stop the same person claiming the trial ten times.

It sits in our usage-based billing cluster, so if you have not decided how you will charge after the trial, start there.

Time-based vs usage-based trials

There is no universally better answer, so it is worth being precise about the tradeoffs.

Time-based (14 days) Usage-based (50 units) Hybrid (14 days or 50 units)
Cost ceiling per trial none fixed fixed
Urgency to try it high low high
Fair to slow evaluators no yes partly
Fair to you on heavy users no yes yes
What ends it the clock the counter whichever comes first
Easy to explain very very fairly

Time-based trials create urgency, which is why they convert well for tools people use daily. Usage-based trials put a hard ceiling on what a trial can cost you and let the customer evaluate at their own pace, which suits products where each action has a real marginal cost. The hybrid is what most usage-priced products end up with: a quota for cost control and a deadline for urgency.

All three are a few lines on top of the same primitives, so you can change your mind later.

Setup

Add the core and run its migration (the docs cover this in full):

# mix.exs
{:aurora_meter, "~> 1.0"}
mix aurora_meter.gen.migration -r MyApp.Repo
mix ecto.migrate
# config/config.exs
config :aurora_meter,
  repo: MyApp.Repo,
  pubsub: MyApp.PubSub,
  plans: MyApp.Plans,
  default_plan: :free

Then add AuroraMeter to your supervision tree after the repo and PubSub.

Shape 1: a trial plan with hard caps

The simplest usage-based trial is just a plan. It has the features of your paid tier and smaller hard limits:

defmodule MyApp.Plans do
  use AuroraMeter.Plans

  plan :free do
    price 0
    limit :ai_generations, 5, :hard
    feature :api_access, false
  end

  plan :trial do
    price 0
    limit :ai_generations, 50, :hard
    limit :documents, 20, :hard
    feature :api_access, true
    feature :seats, 3
  end

  plan :pro do
    price 4_900
    limit :ai_generations, 2_000, :hard
    limit :documents, 1_000, :hard
    feature :api_access, true
    feature :seats, 10
  end
end

Put the organisation on it when it signs up:

def create_org(attrs) do
  with {:ok, org} <- %Org{} |> Org.changeset(attrs) |> Repo.insert(),
       {:ok, _} <- AuroraMeter.subscribe(org, :trial) do
    {:ok, org}
  end
end

And gate the expensive work with with_quota/3, which reserves a unit atomically before running your function and rolls the reservation back if it raises:

def generate(org, prompt) do
  case AuroraMeter.with_quota(org, :ai_generations, fn -> MyApp.AI.complete(prompt) end) do
    {:ok, result} -> {:ok, result}
    {:error, :limit_exceeded} -> {:error, :trial_used_up}
    {:error, :not_entitled} -> {:error, :upgrade_required}
  end
end

That is the whole trial. The counter lives in ETS, so the check costs no database read, and two concurrent requests on one node cannot both take the fiftieth generation. (Across a cluster each node compares against its own view, so a burst can overshoot by what other nodes admitted in the last broadcast interval, one second by default. For a 50 unit trial that is usually a rounding error, and the clustering post has the arithmetic if it is not.)

What “used up” should look like

A hard stop with no explanation is the worst moment in a trial. Turn :trial_used_up into a clear message with the upgrade path in it:

def handle_event("generate", %{"prompt" => prompt}, socket) do
  case MyApp.AI.generate(socket.assigns.org, prompt) do
    {:ok, result} ->
      {:noreply, assign(socket, :result, result)}

    {:error, :trial_used_up} ->
      {:noreply,
       socket
       |> put_flash(:info, "You have used all 50 trial generations. Pick a plan to keep going.")
       |> push_navigate(to: ~p"/billing")}
  end
end

Shape 2: the hybrid, a quota and a deadline

Add a deadline to the organisation:

# migration
alter table(:orgs) do
  add :trial_ends_at, :utc_datetime
  add :trial_expired_at, :utc_datetime
end
{:ok, org} =
  org
  |> Ecto.Changeset.change(trial_ends_at: DateTime.add(DateTime.utc_now(), 14, :day) |> DateTime.truncate(:second))
  |> Repo.update()

Now declare a plan for an expired trial. Because Aurora Meter 1.0 denies any feature a plan does not declare (:undeclared_feature_policy defaults to :deny), the cleanest expired plan is one that declares almost nothing:

plan :trial_expired do
  price 0
  feature :api_access, false
end

On that plan, with_quota(org, :ai_generations, ...) returns {:error, :not_entitled}, which is exactly the signal your UI needs: this is not “you hit a limit”, it is “you need a plan”. The customer can still sign in, see their data and export it, which matters more than people expect for trust.

Moving orgs onto it is a small Oban cron job:

defmodule MyApp.Billing.ExpireTrials do
  use Oban.Worker, queue: :default

  import Ecto.Query
  alias MyApp.{Repo, Accounts.Org}

  @impl Oban.Worker
  def perform(_job) do
    now = DateTime.utc_now()

    from(o in Org, where: o.trial_ends_at <= ^now and is_nil(o.trial_expired_at))
    |> Repo.all()
    |> Enum.each(&expire/1)
  end

  defp expire(org) do
    if AuroraMeter.plan(org).id == :trial do
      {:ok, _} = AuroraMeter.subscribe(org, :trial_expired)
    end

    org |> Ecto.Changeset.change(trial_expired_at: DateTime.truncate(DateTime.utc_now(), :second)) |> Repo.update!()
  end
end
# config
plugins: [{Oban.Plugins.Cron, crontab: [{"*/15 * * * *", MyApp.Billing.ExpireTrials}]}]

The plan(org).id == :trial guard is there so the job never downgrades someone who converted in the last fifteen minutes. And because the job only moves the plan, it is safe to run twice: a second run finds trial_expired_at set and skips the row.

Shape 3: a credit-based trial for pay-as-you-go products

If your product is priced per unit of cost (LLM tokens, minutes of transcription, API calls with different prices), a fixed count of units is the wrong trial. A $5 starting balance is clearer for both of you, and Aurora Meter’s credit ledger has the exact primitive for it: a promotional grant with an expiry.

alias AuroraMeter.Credits
alias AuroraMeter.Credits.Money

def start_credit_trial(org) do
  Credits.grant(org, Money.from_cents(500),
    reference: "trial:#{org.id}",
    category: :promotional,
    expires_at: DateTime.add(DateTime.utc_now(), 30, :day)
  )
end

Three properties make this a good trial mechanism rather than a hack:

  • reference is an idempotency key. Calling start_credit_trial/1 twice for the same org (a retried signup, a double click) returns the original grant and credits nothing. The trial cannot be funded twice from your own code.
  • Promotional credit is spent first. When the customer later buys credit, their trial balance is consumed before their own money, and a refund never eats into the promotion.
  • Expiry is bookkeeping, not a race. Credit past its expires_at is not spendable even before the expiry sweep (Credits.expire_due/1, run from a cron) gets to it, so a trial that ends at midnight ends at midnight.

Spending works through with_credits/4, which holds an estimate, runs the work and settles the actual cost:

def complete(org, job) do
  Credits.with_credits(org, estimate_micro(job), "job:#{job.id}", fn ->
    {:ok, output, tokens} = MyApp.AI.run(job)
    {:ok, output, tokens * micro_per_token()}
  end)
end
# => {:ok, output} | {:error, :insufficient_credits}

{:error, :insufficient_credits} is the end of this trial. The full ledger story, including holds and settlement, is in the prepaid credits post.

If every customer on a plan should start with the same balance, the plan DSL can declare it instead of your signup code, with recurring_credits and category: :promotional. A one-off trial grant from signup is simpler to reason about, so start there.

Converting a trial without losing the count

When a trial customer pays, move them to the paid plan:

AuroraMeter.subscribe(org, :pro)

The change is visible on the next check on every node, because the plan cache is evicted on every subscription write. Nothing else moves. Usage counters are kept per tenant, per feature, per period, not per plan, so a customer who used 38 trial generations this month and upgrades to Pro has 1,962 of their 2,000 left, not 2,000. That is usually what you want: the trial was part of this month’s usage. If you would rather give them a clean slate, raise the Pro limit by the trial allowance in a plan version, or simply accept the 38 as goodwill and say so on the billing page.

With Aurora Meter Pro the subscribe call is made for you. Pro’s Stripe webhook keeps the subscription in sync, and a subscription in Stripe’s trialing, active or past_due status grants its plan, while canceled or unpaid falls back to your :default_plan. One consequence is worth knowing before you design the trial: if you run the trial as a Stripe trial on the paid price, the customer is entitled to the paid plan’s full limits during it. If you want trial-sized limits, keep the trial as its own plan in your app and only send the customer to Stripe Checkout when they convert.

Show the customer where they are

A usage trial only works if the customer can see the counter. The core ships a LiveView component that reads the live ETS value over PubSub:

# mount/3
if connected?(socket), do: AuroraMeter.LiveView.subscribe(socket.assigns.org)
<.usage_meter tenant={@org} feature={:ai_generations} />

For your own copy, quota/2 returns everything a banner needs:

def trial_banner(org) do
  case AuroraMeter.quota(org, :ai_generations) do
    %{kind: :hard, used: used, limit: limit, remaining: remaining} ->
      "Trial: #{used} of #{limit} generations used, #{remaining} left."

    _ ->
      nil
  end
end

Pair it with a nudge at 80 percent, sent once per period, not on every request. The quota alerts post builds exactly that, and Pro ships it as a handler callback.

One trial per customer

Any free allowance attracts people who sign up again with a new address. You will not stop all of it, and you should not punish genuine users trying to stop it, but three cheap layers handle most of it.

1. One claim per account, enforced by the database. Record every trial claim in its own table with a unique index, and let the insert decide:

create table(:trial_claims) do
  add :org_id, references(:orgs), null: false
  add :email_key, :string, null: false
  add :card_fingerprint, :string
  timestamps(updated_at: false)
end

create unique_index(:trial_claims, [:email_key])
create unique_index(:trial_claims, [:card_fingerprint], where: "card_fingerprint IS NOT NULL")

2. Normalise the email before you compare it. jane+trial2@gmail.com and j.ane@gmail.com are the same Gmail inbox:

def email_key(email) do
  [local, domain] = email |> String.trim() |> String.downcase() |> String.split("@", parts: 2)
  local = local |> String.split("+", parts: 2) |> hd()

  local =
    if domain in ["gmail.com", "googlemail.com"],
      do: String.replace(local, ".", ""),
      else: local

  domain = if domain == "googlemail.com", do: "gmail.com", else: domain
  local <> "@" <> domain
end

Only strip dots for providers that ignore them. On most other domains j.ane and jane are different people.

3. If you take a card, use its fingerprint. Stripe gives every card a fingerprint that is stable across customers. Store it on the claim at checkout, and the unique index refuses a second trial on the same card however many email addresses are involved.

Give TrialClaim.changeset/2 a unique_constraint(:email_key) (and one for :card_fingerprint), so a duplicate comes back as an error tuple instead of raising. Then claim the trial in the same transaction that starts it:

def claim_trial(org, email) do
  Ecto.Multi.new()
  |> Ecto.Multi.insert(:claim, TrialClaim.changeset(%TrialClaim{}, %{org_id: org.id, email_key: email_key(email)}))
  |> Ecto.Multi.run(:plan, fn _repo, _ -> AuroraMeter.subscribe(org, :trial) end)
  |> Repo.transaction()
  |> case do
    {:ok, _} -> :ok
    {:error, :claim, _changeset, _} -> {:error, :trial_already_used}
    {:error, _step, reason, _} -> {:error, reason}
  end
end

{:error, :trial_already_used} should put the customer on :free, not lock them out. Someone who genuinely forgot they tried you a year ago is a sales lead, not an abuser.

Measure the trial, not just the conversion

The usage counter is also your best trial analytics. AuroraMeter.history/3 gives daily usage per tenant, so you can answer the questions that decide the trial size:

AuroraMeter.history(org, :ai_generations, days: 14)
# => [%{date: ~D[2026-09-23], value: 4}, %{date: ~D[2026-09-24], value: 11}, ...]
  • How many trial users never reach the first meaningful unit? That is an onboarding problem, and no trial length will fix it.
  • At what usage does conversion jump? If customers who reach 20 generations convert at three times the rate of those who stop at 5, the trial should make reaching 20 easy.
  • How many hit the cap and leave? If that number is large, the cap is too low to show the value, or the upgrade path at the cap is broken.

Summary

A usage-based free trial in Phoenix is three decisions:

  1. Pick the shape. A capped trial plan for fixed-unit products, a hybrid for urgency plus cost control, or promotional credit for pay-as-you-go pricing.
  2. Enforce it where the work happens. with_quota/3 and with_credits/4 gate the expensive call itself, atomically, without a database read on the hot path.
  3. Make it fair. Show the counter, nudge before the end, convert without resetting surprises, and let the database enforce one trial per customer.

The metering, plans, credits and LiveView components above are all in the free core: read the Aurora Meter docs or grab it from GitHub (MIT). When the trial converts and Stripe should start charging, Aurora Meter Pro adds Checkout, webhook sync, Billing Meter reporting and quota alerts, licensed per app from $99/mo. It has a 14-day free trial of its own, claimed once per customer, which, yes, is a time-based trial: a library you install costs us nothing per call. If you are starting the whole SaaS from zero, phx_saas gives you a Phoenix starter with auth, Stripe and email wired in already.