From the blog

Stripe Customer Portal in Phoenix: Self-Serve Billing That Keeps Plans in Sync

By Liam Killingback ·

Stripe Customer Portal in Phoenix: Self-Serve Billing That Keeps Plans in Sync

Sooner or later every SaaS gets the same support emails. “How do I update my card?” “Can I get last month’s invoice for my accountant?” “How do I cancel?” You can build a billing settings page for each of those, with card forms, PDF downloads, tax ID fields and a cancellation flow, or you can hand the customer to Stripe’s hosted Customer Portal and let Stripe maintain all of it.

Adding the Stripe Customer Portal to Phoenix takes about ten lines. That part is easy. The part that bites is what happens next: the customer changes their plan or cancels on Stripe’s site, not yours, and your app finds out only through a webhook. If your entitlements do not follow that webhook correctly, you get the two classic bugs of self-serve billing: the customer who cancelled but still has paid features, and the customer who upgraded, paid, and is still stuck on the free limits.

This guide covers both halves: creating portal sessions from Phoenix, configuring the portal so it offers what you actually support, deep-linking into specific flows, and keeping plan entitlements in sync with whatever the customer did while they were away. We will build it by hand first, then show how Aurora Meter Pro reduces it to a controller action and a plug.

What the portal does, and what it does not

The Customer Portal is a Stripe-hosted page, scoped to one Stripe customer, reached through a short-lived session URL your server creates. Depending on how you configure it, the customer can:

In the portal Notes
Update payment methods The single biggest support saver. Also fixes most past_due subscriptions on its own.
View and download invoices Full invoice history as hosted pages and PDFs.
Update billing details Email, address, phone, tax IDs.
Cancel a subscription Immediately or at the end of the period, optionally collecting a reason.
Switch plans Between the prices you list in the configuration, with the proration you choose.

What it does not do is know anything about your product. It does not know that the Pro plan includes 1,000 AI generations, that this customer has used 940 of them, or that downgrading to Free would put them over the limit. It moves prices on a Stripe subscription. Translating “this subscription now has that price” into “this organisation may now do these things” is your job, and it is the part this guide spends the most time on.

Configure the portal before you link to it

Open the Stripe Dashboard, go to Settings, Billing, Customer portal, and decide what to offer. Do this in test mode and again in live mode: the two have separate configurations, and creating a session against an unsaved configuration fails.

The defaults are conservative. Plan switching is typically off, and cancellation may be too, which is a reasonable starting point but surprising if you expected customers to be able to leave. The decisions worth making on purpose:

  • Cancellation: immediately, or at period end? At period end is kinder and simpler. The subscription stays active with cancel_at_period_end: true, the customer keeps what they paid for, and Stripe sends customer.subscription.deleted when the period actually ends.
  • Plan switching: which products and prices? Only list prices your app knows how to map to a plan. A price the customer can pick in the portal but your code does not recognise is a customer who paid for something your app will not grant.
  • Proration on plan changes. Stripe can prorate immediately, prorate on the next invoice, or not prorate at all. Choose deliberately, because the invoice the customer receives is the one they will email you about.

If you need different behaviour for different customers (say, legacy plans that should not see the new price list), create additional configurations through the API and pass a configuration ID (bpc_...) when creating the session. Keep the default one sensible for everyone else.

The hand-rolled version: a portal session in a controller

You need the Stripe customer ID for the current organisation. If you created the customer during Checkout, you stored it then. With stripity_stripe, the portal session is one call:

defmodule MyAppWeb.BillingController do
  use MyAppWeb, :controller

  def portal(conn, _params) do
    org = conn.assigns.current_org

    case org.stripe_customer_id do
      nil ->
        # Never paid, so there is nothing to manage. Send them to pricing.
        redirect(conn, to: ~p"/pricing")

      customer_id ->
        {:ok, session} =
          Stripe.BillingPortal.Session.create(%{
            customer: customer_id,
            return_url: url(~p"/billing"),
            configuration: Application.get_env(:my_app, :stripe_portal_configuration)
          })

        redirect(conn, external: session.url)
    end
  end
end

Three details matter more than they look:

  1. Create a session per click. The URL is short-lived and grants access to a customer’s billing. Never cache it, store it or put it in an email. Generate it when the customer asks for it.
  2. Put the route behind authentication and scope it to the organisation. The customer ID must come from the signed-in user’s organisation, never from a parameter. A portal session for the wrong customer shows someone else’s invoices and card.
  3. Use a POST. A GET /billing/portal link can be prefetched by browsers and link scanners, each prefetch creating a session. A button that posts avoids that and gets CSRF protection for free:
<.link href={~p"/billing/portal"} method="post" class="btn">
  Manage billing
</.link>
# router.ex, inside your authenticated scope
post "/billing/portal", BillingController, :portal

Deep links: send customers straight to one flow

The portal’s home page is fine for “manage billing”. For specific actions, a portal session can open directly on one flow using flow_data. Your “Update card” banner on a past_due account should go straight to the card form, not to a menu:

Stripe.BillingPortal.Session.create(%{
  customer: customer_id,
  return_url: url(~p"/billing"),
  flow_data: %{type: "payment_method_update"}
})

And a “Cancel subscription” button in your own settings page can open Stripe’s cancellation flow for a specific subscription, then bring the customer back:

Stripe.BillingPortal.Session.create(%{
  customer: customer_id,
  return_url: url(~p"/billing"),
  flow_data: %{
    type: "subscription_cancel",
    subscription_cancel: %{subscription: org.stripe_subscription_id},
    after_completion: %{type: "redirect", redirect: %{return_url: url(~p"/billing?cancelled=1")}}
  }
})

Deep links make the portal feel like part of your app rather than a detour, and they are the easiest way to recover failed payments: an email that says “your card was declined” with a button that lands on the card form converts far better than one that lands on a dashboard.

The part that matters: the portal changes things you only hear about later

Everything the customer does in the portal happens on Stripe. Your app learns about it through webhooks, delivered at least once, possibly late, and not in order. The events that matter for entitlements:

Event What the customer did What your app should do
customer.subscription.updated Switched plan, scheduled a cancellation, or a renewal happened Re-derive the plan from the subscription’s prices and status
customer.subscription.deleted The subscription actually ended Fall back to the free plan
invoice.payment_failed A renewal was declined Keep access, show a banner, deep-link to the card form
customer.updated Changed billing email or details Update your copy if you keep one

Two rules make the handler correct.

Rule one: status decides the plan. A subscription should grant its paid plan while it is active, trialing or past_due, and nothing else. Keeping past_due entitled is deliberate: a failed renewal is usually an expired card, and locking the customer out while Stripe retries is how a billing hiccup becomes a cancellation. When Stripe gives up, the status moves to unpaid or canceled, and that is when access ends. We covered status-aware entitlements in detail in plan-based feature gating in Phoenix.

Rule two: do not trust the payload, fetch the current state. Because events arrive out of order, an old updated event saying “active on Pro” can arrive after the deleted event. If you apply payloads as they come, the cancelled customer gets Pro back. The fix is to treat the webhook as a signal (“something changed on this subscription”) and retrieve the subscription from Stripe before deciding anything:

defmodule MyApp.Billing.Webhooks do
  alias MyApp.Accounts

  @entitled ["active", "trialing", "past_due"]

  def handle(%Stripe.Event{type: "customer.subscription." <> _, data: %{object: %{id: id}}}) do
    # The payload might be stale. Ask Stripe what is true now.
    {:ok, sub} = Stripe.Subscription.retrieve(id)
    org = Accounts.get_org_by_stripe_customer!(sub.customer)

    plan =
      if sub.status in @entitled,
        do: plan_for_prices(sub.items.data),
        else: :free

    {:ok, _} = AuroraMeter.subscribe(org, plan)

    Accounts.update_billing!(org, %{
      stripe_subscription_id: sub.id,
      subscription_status: sub.status,
      cancel_at_period_end: sub.cancel_at_period_end,
      current_period_end: period_end(sub)
    })

    :ok
  end

  def handle(_event), do: :ok

  # Older API versions keep the period on the subscription, newer ones on each item.
  defp period_end(sub) do
    ts = Map.get(sub, :current_period_end) || Map.get(hd(sub.items.data), :current_period_end)
    ts && DateTime.from_unix!(ts)
  end

  # Every price you list in the portal must map to exactly one plan.
  defp plan_for_prices(items) do
    prices = Map.new(Application.fetch_env!(:my_app, :stripe_prices), fn {plan, price} -> {price, plan} end)

    Enum.find_value(items, :free, fn item -> Map.get(prices, item.price.id) end)
  end
end

AuroraMeter.subscribe/2 assigns the plan in the free Aurora Meter core, and from that moment every AuroraMeter.check/2 and with_quota/4 call enforces the new plan’s limits. There is no cache to bust: the plan lookup is cached in ETS and evicted on every subscription write. If you are not using Aurora Meter, replace that line with whatever writes your plan column, but keep the shape: fetch, derive from status and price, write.

There is still a race in this handler: two events for the same subscription, processed concurrently on two nodes, can each fetch, and the slower write wins even if it fetched first. For most apps the window is tiny, and the next event corrects it. If it matters, serialise per customer with a Postgres advisory lock around the fetch and write.

One version note, which is why period_end/1 looks in two places: on recent Stripe API versions the billing period bounds (current_period_start, current_period_end) live on the subscription items rather than on the subscription. Read them from wherever your pinned API version puts them.

Showing a scheduled cancellation

When the customer cancels at period end, nothing about their access changes yet: the status is still active. Your UI should still acknowledge it, both so they know it worked and so they have an obvious way back:

<div :if={@org.cancel_at_period_end} class="alert alert-warning">
  Your plan ends on <%= Calendar.strftime(@org.current_period_end, "%-d %B %Y") %>.
  You keep everything until then.
  <.link href={~p"/billing/portal"} method="post">Keep my plan</.link>
</div>

The portal lets customers renew a subscription that is scheduled to cancel, so “Keep my plan” can simply open the portal home.

Should you let customers switch plans in the portal?

For a simple product with flat monthly prices, yes: it is the least code you will ever write for upgrades and downgrades. For a usage-based product, think harder.

Stripe places restrictions on which subscriptions the portal can switch, and they have historically involved subscriptions with multiple items and certain usage-based prices. A hybrid plan (a flat fee plus a metered overage price on the same subscription) is exactly that shape. Check Stripe’s current portal limitations for your configuration before promising self-serve plan changes on a metered plan.

There is a product question too. A mid-period change on a metered plan raises “which allowance applies to the usage already recorded?”, and the portal cannot ask your app. The pattern that tends to work for usage-based SaaS:

  • Portal for cards, invoices, billing details and cancellation. These never need your app’s opinion.
  • Your own upgrade page for plan changes. Show the customer their current usage against each plan’s limits (you have the numbers, Stripe does not), then run the change through Checkout or your own server-side subscription update, and settle metered usage before moving the subscription to a new price.

If you are still deciding how to price in the first place, our comparison of metered vs seat-based pricing covers the hybrid shape and what it means for plan changes.

Or let Aurora Meter Pro do it

Everything above is a few hundred lines once you add signature verification, idempotent event handling, ordering, advisory locks and tests. Aurora Meter Pro ships it. The portal becomes one controller action:

def portal(conn, _params) do
  case AuroraMeter.Billing.portal_url(conn.assigns.current_org, return_url: url(~p"/billing")) do
    {:ok, url} -> redirect(conn, external: url)
    {:error, :no_customer} -> redirect(conn, to: ~p"/pricing")
  end
end

{:error, :no_customer} means the tenant never checked out, so there is nothing to manage. To pin which features the portal offers, set the configuration once:

config :aurora_meter,
  provider: AuroraMeter.Pro.Stripe,
  period_source: AuroraMeter.Pro.Period,
  default_plan: :free

config :aurora_meter_pro,
  stripe_prices: %{pro: "price_flat_pro", scale: "price_flat_scale"},
  stripe_metered_prices: %{scale: ["price_scale_overage"]},
  webhook_secret: {:system, "STRIPE_WEBHOOK_SECRET"},
  stripe_portal_configuration: {:system, "STRIPE_PORTAL_CONFIGURATION"}

And the sync half is the webhook plug, mounted before Plug.Parsers:

# endpoint.ex
plug AuroraMeter.Pro.Webhook

What that plug does for the portal case is exactly the discipline described above, done carefully:

  • It fetches current state. Subscription webhooks call AuroraMeter.Billing.sync_subscription/1, which retrieves the subscription from Stripe while holding a per-tenant advisory lock, so an old “active” event arriving after a cancellation cannot resurrect the plan. If Stripe cannot be reached, it asks for redelivery rather than falling back to the stale payload.
  • It recognises portal plan changes by price. Each Stripe price maps to exactly one plan (and plan version), and Pro refuses to boot if two plans claim the same price, so a switch made in the portal resolves to one plan, deterministically.
  • Status grants the plan. active, trialing and past_due keep the paid plan; anything else falls back to :default_plan. Cancellation revokes access on its own, with no downgrade job.
  • Periods follow the subscription. With AuroraMeter.Pro.Period, usage buckets to the subscription’s own billing window, so a customer who subscribed on the 17th gets a quota that resets on the 17th.

The test for the “never paid” branch needs no Stripe at all, because a tenant with no subscription row has no customer:

test "an org that never paid is sent to pricing", %{conn: conn} do
  conn = conn |> log_in_org(org_fixture()) |> post(~p"/billing/portal")
  assert redirected_to(conn) == ~p"/pricing"
end

Summary

The Stripe Customer Portal in Phoenix is a quick win: one session call, one redirect, and a whole category of billing support email disappears. Configure it deliberately (cancellation mode, which prices can be switched to, proration), create a fresh session per click behind authentication, and use flow_data deep links for card updates and cancellations.

Then spend your real effort on the webhook side. Treat every subscription event as a signal to fetch current state, derive the plan from status and price, keep past_due entitled, and let canceled fall back to free. Do that and whatever the customer does in the portal, your entitlements follow.

If you want that sync handled for you, Aurora Meter Pro is licensed per app from $99 a month with a 14-day free trial (one trial per customer), and the Pro preview shows the dashboards and billing flows it adds. The metering and entitlements engine underneath is the free, MIT-licensed core: read the docs or grab it on GitHub. And if you are starting a new product from scratch, the PHX SaaS Starter gives you organisations, roles, Stripe billing and an admin dashboard on Phoenix out of the box.