From the blog

Stripe Auto Top-Up in Phoenix: Recharge Prepaid Credits Without Charging Twice

By Liam Killingback ·

Stripe Auto Top-Up in Phoenix: Recharge Prepaid Credits Without Charging Twice

Prepaid credit is the easiest pricing model to explain and one of the hardest to run well. Customers buy $25 of credit, every API call or AI generation spends a little, and when the balance runs out the work stops. That last part is the problem. A customer whose batch job dies at 3am because their balance hit zero does not think “fair enough”, they think your product is unreliable.

Stripe auto top-up is the fix: when the balance drops below a threshold, charge the card the customer saved last time, off-session, and add the credit back. OpenAI, Twilio and most AI APIs work this way. It looks like a cron job and a PaymentIntent.create. It is actually one of the most error-prone pieces of billing code you can write, because the failure mode is charging a real person twice.

This guide covers how Stripe auto top-up works in a Phoenix app: saving the card, detecting the threshold crossing, charging off-session, crediting from the webhook, handling declines versus timeouts, and reversing credit on refunds. It shows the shape of a hand-rolled version and then the same thing with Aurora Meter Pro, which ships it.

The pieces of an auto top-up system

Before any code, it helps to name the moving parts, because each one is a separate place to get it wrong:

  1. A ledger. The balance is the sum of append-only entries (grants, debits, holds, reversals), not a mutable integer column.
  2. A saved payment method. Collected during a normal, on-session checkout, with permission to charge it later.
  3. A trigger. Something notices the balance went below the threshold.
  4. The charge. An off-session PaymentIntent for the configured amount.
  5. The grant. Credit added to the ledger when Stripe confirms the money moved, exactly once.
  6. Failure policy. What counts as the card failing, when to stop trying, and how to tell the customer.
  7. Reversals. Refunds and chargebacks take credit back out, and must not trigger another top-up.

If you already meter usage against a prepaid balance, you have item 1. Our post on pay-as-you-go billing in Elixir walks through the ledger in Aurora Meter’s free core: integer micro-dollars, holds for work of unknown cost, and settlement. This post is about items 2 to 7.

Step 1: save the card on the first top-up

Auto top-up charges a card with nobody at the keyboard, so the first purchase has to happen on-session and has to ask Stripe to keep the card for later. With a payment-mode Checkout Session that means setup_future_usage: "off_session" on the PaymentIntent. When the session completes, the PaymentIntent’s payment_method is the card you will charge next time.

In Aurora Meter Pro that first purchase is one call, with the amount in cents because this is the Stripe boundary:

def top_up(conn, %{"cents" => cents}) do
  org = conn.assigns.current_org

  {:ok, url} =
    AuroraMeter.Pro.Credits.checkout(org, String.to_integer(cents),
      success_url: url(~p"/billing?topped_up=1"),
      cancel_url: url(~p"/billing")
    )

  redirect(conn, external: url)
end

The amount is clamped to configured minimum and maximum values, so a crafted form cannot buy one cent or a million dollars of credit. AuroraMeter.Pro.Credits.presets_cents() returns the buttons for your billing page.

Never credit from the redirect

The customer lands back on success_url before you know anything for certain. Do not grant credit there: anyone can visit a URL, and the payment may still be processing. Credit is granted when payment_intent.succeeded arrives at your webhook, keyed on the PaymentIntent id:

Stripe: pi_3abc succeeded
  -> grant(tenant, 25_000_000, reference: "stripe:pi_3abc")

Because the reference is the PaymentIntent id, a webhook delivered twice grants once, and a webhook delivered a week late still grants once. On the success page, say “your top-up is being confirmed” and let a live balance update itself over PubSub when the grant commits.

Step 2: turn on auto top-up

Two numbers per customer: the threshold below which you charge, and the amount you charge.

case AuroraMeter.Pro.Credits.update_auto_top_up(org, %{
       auto_top_up_enabled: true,
       threshold_micro: 5_000_000,  # when available credit drops below $5.00
       amount_cents: 2_500          # charge $25.00
     }) do
  {:ok, _account} ->
    :ok

  {:error, :no_payment_method} ->
    # they have never completed a top-up, so there is no card to charge
    {:error, "Buy credit once first, then turn on auto top-up."}

  {:error, %Ecto.Changeset{} = changeset} ->
    {:error, changeset}
end

Notice the units: the threshold is in micro-dollars because it is compared with the ledger, and the amount is in cents because it goes to Stripe. Mixing those two up is a classic bug, so the API keeps them apart by name.

update_auto_top_up also mirrors the threshold into the core ledger’s per-tenant low-balance threshold, which is how the trigger in the next step knows where the line is.

Step 3: detect the crossing, and the accounts already below it

The obvious trigger is “after every debit, if balance < threshold, charge”. That has two problems.

First, it fires on every debit below the line, not once. A customer making a thousand small calls while a charge is in flight would queue a thousand charges. You want an edge-triggered event: one notification when the balance crosses the threshold, not one per write while it stays below.

Second, edge triggers miss things. If the balance crossed while auto top-up was switched off, or while your app was down, or while Stripe was unreachable, there is no second crossing. The account sits below the threshold forever and the customer eventually hits zero. You also need a level-triggered sweep that looks for accounts that are below the line right now.

Aurora Meter’s core ledger provides the edge: the low-balance handler fires once per crossing, with a crossing_id, and stays quiet until the balance recovers and crosses again. Pro hooks into it and adds the sweep:

# config/config.exs
config :aurora_meter,
  credits_low_balance_handler: &AuroraMeter.Pro.Credits.on_low_balance/1

config :my_app, Oban,
  queues: [aurora_meter: 5],
  plugins: [
    {Oban.Plugins.Cron,
     crontab: [
       {"*/5 * * * *", AuroraMeter.Pro.Credits.AutoTopUpSweeper}
     ]}
  ]

You want both. The hook gives you a fast reaction to the crossing; the sweeper is the safety net for every case the hook cannot see.

Step 4: charge off-session, exactly once

This is the step where hand-rolled implementations usually go wrong, so it is worth slowing down.

The naive version:

# Do not ship this.
def charge(account) do
  Stripe.PaymentIntent.create(%{
    amount: account.amount_cents,
    currency: "usd",
    customer: account.customer_id,
    payment_method: account.payment_method_id,
    off_session: true,
    confirm: true
  })
end

Now imagine the request times out. Did Stripe charge the card? You do not know. If you retry without an idempotency key, you might charge twice. So you add an idempotency key, and the question becomes: what is the key?

A key built from the attempt (tenant, amount, a time window) feels right and is subtly broken. Any retry that is allowed by your cooldown is, by definition, in a later window, so it gets a different key, and Stripe treats it as a brand new charge. Aurora Meter Pro shipped exactly this bug in an earlier version, and the fix is instructive:

  1. Write the attempt down before charging. The amount, the currency, the idempotency key and the start time are persisted on the account before the request goes out.
  2. Retry with what was written, not what is configured now. If the customer changes their top-up amount from $25 to $50 while an uncertain charge is pending, the retry still sends $25 with the original key. Same parameters, same key, so Stripe returns the original result instead of creating a second charge.
  3. Once you know the PaymentIntent id, retrieve it; never recreate it. Recovery asks Stripe what happened to pi_... rather than trying again.
  4. Respect the idempotency window. Stripe keeps idempotency keys for at least 24 hours. If an outcome is still unknown after about 23 hours, a retry with the same key is no longer safe, so Pro stops and returns :payment_reconciliation_required rather than charging again. A person (or the reconciler) establishes what happened from Stripe first.

That last rule is the one that feels over-cautious until the day it saves you. “We could not tell whether we charged you, so we charged you again” is not a sentence you want in a support reply.

The worker is callable directly as well as through Oban, which is handy in a console:

AuroraMeter.Pro.Credits.AutoTopUpWorker.attempt(tenant_key)
# => {:ok, %AuroraMeter.Schema.CreditTransaction{}}
#  | {:skip, reason}   # no account, disabled, no card, in cooldown, already above threshold
#  | {:error, reason}

A :skip is the guards saying no, and it is not a failure. It does not count towards switching auto top-up off.

Step 5: decide what counts as a failure

Every auto top-up system needs a “stop trying” rule: after N failures, switch auto top-up off and tell the customer their card needs attention. The interesting question is what counts as a failure.

What came back Verdict What should happen
A decline (card_error, HTTP 402) Definitive: the card said no Count it; clear the key
A timeout or dropped connection Indeterminate Log it, do not count it; keep the key
A 5xx or 429 from Stripe Indeterminate Log it, do not count it; keep the key
status: "processing" Not a failure Skip; the succeeded webhook will grant it

Counting timeouts and 5xx responses against the card is a real bug with a nasty blast radius: ten minutes of Stripe being unwell would switch off auto top-up for every customer with a low balance, and email each of them to say their card failed. Only Stripe’s definitive answer about the card should count.

The processing row matters for bank debits (ACH, SEPA, BECS). The money is on its way, and treating it as failed would start a second debit for the same top-up as soon as the cooldown ended.

After auto_top_up_max_failures definitive failures (three by default), Pro disables auto top-up and records why. Only an explicit call turns it back on:

{:ok, _account} = AuroraMeter.Pro.Credits.re_enable_auto_top_up(org)

Changing the threshold does not re-arm a stopped card, and neither does a successful manual top-up. Both of those used to, and both meant a customer could silently re-arm a card that had just been refunded or disputed.

Step 6: tell the customer what happened

Every one of these events deserves a message, and they are different messages. Pro sends them to one handler:

# config/config.exs
config :aurora_meter_pro, credits_handler: &MyApp.Billing.credit_event/1
defmodule MyApp.Billing do
  require Logger

  def credit_event({:granted, txn}) do
    MyApp.Mailer.receipt(txn.tenant_key, txn.amount)
  end

  def credit_event({:low_balance, %{tenant_key: key, available: available}}) do
    MyApp.Mailer.low_balance(key, available)
  end

  def credit_event({:auto_top_up_failed, key, reason, attempt}) do
    Logger.warning("auto top-up attempt #{attempt} failed for #{key}: #{inspect(reason)}")
  end

  def credit_event({:auto_top_up_disabled, key, reason}) do
    MyApp.Mailer.auto_top_up_off(key, reason)
  end

  def credit_event(_other), do: :ok
end

Keep the catch-all clause. New event kinds can be added in minor versions, and an unmatched one should never crash a webhook.

A practical copy tip: the low-balance email for a customer with auto top-up on should say “we will add $25 shortly”, not “you are running low”. The second reads like an alarm for something that is about to fix itself.

Step 7: refunds and chargebacks

You refund a $25 top-up from the Stripe dashboard. charge.refunded arrives. The credit must come back out of the ledger, and two things are easy to get wrong.

The reversal must never be refused for want of balance. The customer may already have spent the credit. The money has left your Stripe account regardless, so the honest record is a negative balance, which is a debt. Pro writes a reversal that can go below zero.

The reversal must not trigger a top-up. Taking $25 out of the balance can push it across the threshold, and the low-balance hook would cheerfully charge the very card you are refunding. Pro switches auto top-up off before writing the reversal, so the crossing finds nothing to do.

Disputes follow the same logic: charge.dispute.funds_withdrawn reverses the credit, and charge.dispute.funds_reinstated (you won) restores it. Refunds can also arrive before the grant they reverse, for example when Radar’s early fraud warning auto-refunds within minutes and the events retry on different schedules. A reversal for a payment with no grant yet is refused with a non-2xx response so Stripe redelivers it, instead of being acknowledged and lost.

Subscribe your webhook endpoint to checkout.session.completed, payment_intent.succeeded, payment_intent.payment_failed, charge.refunded, the refund.* events and the two dispute events above.

Should you build Stripe auto top-up yourself?

You can. The Stripe primitives are all public, and if your credit model is simple and your volume is low, a careful afternoon gets you something that works most of the time. The honest list of what “careful” includes:

  • an append-only ledger with a uniqueness constraint on the payment reference;
  • edge-triggered crossings plus a level-triggered sweep;
  • persisted charge parameters and idempotency keys, written before the request;
  • a stop rule that never counts timeouts, 5xx responses or processing payments;
  • refunds and disputes that can drive a balance negative and switch auto top-up off first;
  • tests against independent database connections, because a sandboxed test transaction hides most of the concurrency bugs above.

If that list looks like a project rather than an afternoon, that is because it is one. Aurora Meter Pro ships all of it on top of the free, MIT-licensed core: Stripe checkout for top-ups, auto top-up with the charge-once rules above, refund and dispute reconciliation, plus metered usage reporting to Stripe Billing Meters and a credit-aware usage dashboard. It is licensed per app from $99/mo with a 14-day free trial (one trial per customer); see pricing.

Start with the free core either way. The ledger, holds, settlement and low-balance crossings are all in Aurora Meter on GitHub under MIT, and the core docs cover installing it in a Phoenix app. If you sell AI features, our guide to LLM token billing in Elixir shows how to put a price on each request before you worry about refilling the balance. And if you want a whole metered API product to start from, the Aurora API Starter template from PhxTemplates is built on the same core.