We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
From the blog
Stripe Dunning in Phoenix: Handle Failed Payments Without Locking Out Good Customers
By Liam Killingback ·
Stripe Dunning in Phoenix: Handle Failed Payments Without Locking Out Good Customers
A good share of SaaS churn is not customers deciding to leave. It is a card that expired, a bank that declined a renewal it did not recognise, or a 3D Secure prompt nobody saw. The customer still wants the product. The payment simply failed, and what your app does next decides whether you keep them.
That next part is called dunning: retrying the charge, telling the customer, and deciding when (and whether) to take access away. Stripe does a lot of it for you. What it cannot do is decide what your Phoenix app shows, which features keep working, and what happens to the usage a customer keeps generating while their invoice is open. That is your code, and it is easy to get wrong in both directions: lock people out on the first decline and you turn a billing hiccup into a cancellation; never lock anyone out and you give the product away.
This guide covers Stripe dunning in Phoenix end to end: what Stripe does when a renewal fails, the subscription statuses you will see, a grace-period policy, the webhook handler that keeps entitlements in sync, the in-app banner, what to do about metered usage, and how to test the whole thing without waiting a month.
What happens in Stripe when a renewal fails
When a subscription renews, Stripe creates an invoice and tries to pay it with the customer’s default payment method. If that fails:
-
Stripe sends
invoice.payment_failed. -
The subscription moves from
activetopast_due. - Stripe retries the payment on the schedule you configured in the Dashboard’s revenue recovery settings. Smart Retries picks retry times based on what tends to succeed; you can also set a fixed schedule.
-
If a retry succeeds, you get
invoice.paidand the subscription goes back toactive. - If every retry fails, Stripe does whatever you told it to at the end: cancel the subscription, mark it unpaid, or leave it past due.
There is a second failure that is not a decline: the bank wants the customer to authenticate (Strong Customer Authentication in Europe, for example). Stripe sends invoice.payment_action_required, and the customer has to complete the step themselves, usually through the invoice’s hosted page. Retrying on its own will not fix that one, so it deserves its own message.
Stripe can also email customers about failed payments and expiring cards for you. Decide early whether Stripe or your app sends those emails. Sending both is the most common dunning mistake we see, and two slightly different “your payment failed” emails look like phishing.
The statuses that matter
| Status | What it means | Should they keep the paid plan? |
|---|---|---|
active |
Paid and current | Yes |
trialing |
In a free trial | Yes |
past_due |
The latest invoice failed; Stripe is retrying | Yes, for now |
unpaid |
Retries ran out; the invoice is still open | No |
canceled |
The subscription ended | No |
incomplete |
The first payment never succeeded | No |
incomplete_expired |
The first payment window closed | No |
paused |
A trial ended with no payment method, or you paused it | No |
The row that matters most is past_due. It is tempting to treat it as “not paying, so no access”. Resist that. During the retry window, most of these customers will pay once their card is updated, and the ones who would pay are exactly the ones who will be annoyed most by a sudden lockout. Keep them entitled while Stripe is retrying, and let the final status decide.
This is the rule Aurora Meter uses: a subscription grants its plan while its status is active, trialing or past_due, and anything else falls back to your :default_plan. You do not write a downgrade job. Changing the status is the downgrade.
Choose your end state: unpaid or canceled
What Stripe does when retries run out is the single most important dunning setting, so pick it deliberately.
Cancel the subscription. Simple and final. The customer drops to your free plan (or loses access entirely). If they come back, they start a new subscription through checkout. Good for low-price plans where a returning customer going through checkout again is no hardship.
Mark it unpaid. The subscription and its open invoice stay. Access is revoked, but the customer can pay the open invoice and the same subscription becomes active again, with the same price, the same billing date and no new checkout. This is kinder to customers on annual or grandfathered prices (see our post on grandfathered pricing for why that matters), and it is our default recommendation for B2B.
Leave it past due. Stripe stops retrying but changes nothing. Only choose this if you have your own process for chasing invoices, because otherwise a customer can stay entitled indefinitely.
The webhook handler
Your handler has one job: make your local copy of the subscription match Stripe, and let the status drive access. Three rules keep it correct.
- Fetch current state. Webhooks can arrive late and out of order. Use the event as a signal, then retrieve the subscription from Stripe and write what is true now.
- Be idempotent. Stripe delivers at least once. Record the event id and ignore repeats.
- Keep notification separate from entitlement. The status update decides access; the email or banner is a side effect you trigger from specific events.
With stripity_stripe and the Aurora Meter core, the entitlement half looks like this:
defmodule MyApp.Billing.Webhooks do
alias MyApp.{Accounts, Billing}
@subscription_events ~w(customer.subscription.created customer.subscription.updated
customer.subscription.deleted customer.subscription.paused
customer.subscription.resumed)
def handle(%Stripe.Event{id: event_id} = event) do
Billing.once(event_id, fn -> dispatch(event) end)
end
defp dispatch(%Stripe.Event{type: type, data: %{object: %{id: sub_id}}})
when type in @subscription_events do
sync_subscription(sub_id)
end
defp dispatch(%Stripe.Event{type: "invoice.payment_failed", data: %{object: invoice}}) do
sync_subscription(invoice.subscription)
Billing.Dunning.payment_failed(invoice)
end
defp dispatch(%Stripe.Event{type: "invoice.payment_action_required", data: %{object: invoice}}) do
sync_subscription(invoice.subscription)
Billing.Dunning.action_required(invoice)
end
defp dispatch(%Stripe.Event{type: "invoice.paid", data: %{object: invoice}}) do
sync_subscription(invoice.subscription)
Billing.Dunning.recovered(invoice)
end
defp dispatch(_event), do: :ok
defp sync_subscription(nil), do: :ok
defp sync_subscription(sub_id) do
{:ok, sub} = Stripe.Subscription.retrieve(sub_id)
org = Accounts.get_org_by_stripe_customer!(sub.customer)
{:ok, _} =
AuroraMeter.Storage.put_subscription(%{
tenant_key: MyApp.Tenant.to_key(org),
plan_id: Billing.plan_for_price(hd(sub.items.data).price.id),
status: sub.status,
provider_subscription_id: sub.id,
provider_customer_id: sub.customer
})
:ok
end
end
(Field names follow recent stripity_stripe versions; on newer Stripe API versions the invoice’s subscription id has moved under parent, so read it from wherever your pinned API version puts it.)
Billing.once/2 is a small helper that inserts the event id into a table with a unique index and only runs the function when the insert succeeds. put_subscription/1 evicts the cached plan on every node, so the next AuroraMeter.check/2 or with_quota/4 sees the new status immediately.
Notice what is missing: there is no branch that says “if past due, downgrade”. The status is written, and the entitlement rule does the rest. When the customer updates their card and a retry succeeds, invoice.paid writes active, and access is back without anyone touching it.
Telling the customer: banner first, email second
A failed payment needs to reach the customer in the place they will see it. For an active user that is your app, not their inbox.
A banner on every page
Add an on_mount hook that reads the subscription status and assigns a dunning state the layout can render:
defmodule MyAppWeb.DunningHook do
import Phoenix.Component
def on_mount(:default, _params, _session, socket) do
state =
with %{current_org: org} when not is_nil(org) <- socket.assigns,
%{status: status} <- AuroraMeter.Storage.get_subscription(MyApp.Tenant.to_key(org)) do
dunning_state(status)
else
_ -> :ok
end
{:cont, assign(socket, :dunning, state)}
end
defp dunning_state("past_due"), do: :retrying
defp dunning_state("unpaid"), do: :suspended
defp dunning_state(_), do: :ok
end
<div :if={@dunning == :retrying} class="bg-amber-50 border-b border-amber-200 px-4 py-2 text-sm">
Your last payment did not go through. We will try again automatically, or you can
<a href={~p"/billing/update-card"} class="underline">update your card now</a>.
</div>
<div :if={@dunning == :suspended} class="bg-red-50 border-b border-red-200 px-4 py-2 text-sm">
Your plan is paused because an invoice is unpaid.
<a href={~p"/billing/pay"} class="underline">Pay the open invoice</a> to restore it.
</div>
Point “update your card” straight at the Stripe Customer Portal’s payment method flow rather than its home page; our Stripe Customer Portal in Phoenix post shows the flow_data deep link. Point “pay the open invoice” at the invoice’s hosted_invoice_url, which handles both card updates and 3D Secure.
Keep the wording calm. “Your payment failed” next to a red exclamation mark reads like an accusation; “did not go through, we will try again” reads like what it is.
Emails, if Stripe is not sending them
If you send your own, three are enough:
- On the first failure: what happened, that you will retry, and a link to update the card.
-
On
invoice.payment_action_required: the bank needs them to confirm, with the hosted invoice link. Say clearly that retrying will not fix this one. - Before the end of the retry window: a final reminder with the date access changes.
Send them from Billing.Dunning with Oban jobs keyed on the invoice id, so a redelivered webhook cannot send the same email twice.
What happens to usage during dunning
This is the part generic dunning guides never cover, because most of them assume a flat subscription. If you meter usage, a failed payment raises real questions.
Metering keeps running
A past_due customer is still entitled, so AuroraMeter.track/3 and with_quota/4 behave exactly as they did yesterday. That is what you want: their work keeps flowing while Stripe retries. Metering is not entitlement anyway; track counts regardless of plan, so you never lose the record of what was used.
Limits change when the status does
When the subscription goes to unpaid or canceled, the tenant falls back to :default_plan. If your free plan has a hard cap that the customer is already above, the next gated call returns {:error, :limit_exceeded}. Handle that as a billing state, not a generic error:
case AuroraMeter.with_quota(org, :ai_generations, fn -> MyApp.AI.generate(prompt) end) do
{:ok, result} ->
{:ok, result}
{:error, reason} when reason in [:limit_exceeded, :not_entitled] ->
if socket.assigns.dunning == :suspended,
do: {:error, "Your plan is paused until the open invoice is paid."},
else: {:error, "You have reached your plan's limit."}
end
A customer who hits a limit because their card failed should be told about the card, not invited to upgrade.
Do not lose the last period’s usage
On a plan with metered overage, the customer may have used units in the final period that have not been billed yet. If you stop reporting the moment the subscription ends, that usage is never charged. Report it before you stop.
Aurora Meter Pro handles this for you: its usage reporter keeps reporting to Stripe Billing Meters for past_due subscriptions, and makes one final report for a subscription that ended canceled, unpaid or paused, stamped inside the period it belongs to. Pro also aligns billing periods to the Stripe subscription, so the period you meter is the period Stripe invoices.
Prepaid credit is a separate story
If customers top up a prepaid balance, a failed subscription renewal does not touch the credit they already bought. A failed automatic top-up is its own event. Pro handles payment_intent.payment_failed for auto top-ups (the failure is recorded once, even if Stripe redelivers it), and the core’s low-balance threshold (telemetry, a PubSub message and an optional handler, once per crossing) gives you the hook to warn the customer before they run dry.
Testing dunning without waiting a month
Dunning is time-based, which makes it easy to ship untested. Stripe gives you two tools that make it quick.
A card that attaches but fails. In test mode, 4000 0000 0000 0341 can be saved to a customer, but every charge on it is declined. Subscribe with 4242 4242 4242 4242, then swap the default payment method to the 0341 card, and the next renewal fails.
Test clocks. Create the customer on a test clock, subscribe, then advance the clock past the renewal date. Stripe generates the invoice, fails it, runs the retries as you advance further, and sends every webhook along the way. You can watch a customer go active, past_due, unpaid in a few minutes, with your local app receiving each event through stripe listen.
Then cover the decisions in ExUnit, with no Stripe at all:
defmodule MyApp.Billing.DunningTest do
use MyApp.DataCase, async: false
setup do
org = insert(:org)
{:ok, _} = AuroraMeter.subscribe(org, :pro)
%{org: org, key: MyApp.Tenant.to_key(org)}
end
test "past_due keeps the paid plan", %{org: org, key: key} do
{:ok, _} = AuroraMeter.Storage.put_subscription(%{tenant_key: key, plan_id: "pro", status: "past_due"})
assert AuroraMeter.plan(org).id == :pro
end
test "unpaid falls back to the default plan", %{org: org, key: key} do
{:ok, _} = AuroraMeter.Storage.put_subscription(%{tenant_key: key, plan_id: "pro", status: "unpaid"})
assert AuroraMeter.plan(org).id == :free
end
test "paying the invoice restores the plan", %{org: org, key: key} do
{:ok, _} = AuroraMeter.Storage.put_subscription(%{tenant_key: key, plan_id: "pro", status: "unpaid"})
{:ok, _} = AuroraMeter.Storage.put_subscription(%{tenant_key: key, plan_id: "pro", status: "active"})
assert AuroraMeter.plan(org).id == :pro
end
end
Add one test that the same Stripe event id processed twice sends one email, and you have covered the bugs that cost real money. Our post on testing usage-based billing in ExUnit has more patterns for periods and quotas.
A dunning checklist
- Retries configured in Stripe, with Smart Retries or a schedule you chose on purpose.
-
An end state chosen deliberately:
unpaidfor B2B and annual plans,canceledfor cheap self-serve plans. - One sender for failed-payment emails, Stripe or you, never both.
-
past_duekeeps the paid plan; anything outsideactive,trialingandpast_duefalls back. - Webhooks fetch current state, are idempotent, and never downgrade directly.
-
An in-app banner for
past_dueandunpaid, linking to the card form and the hosted invoice. -
payment_action_requiredgets its own message. - Limit errors during suspension talk about the invoice, not an upgrade.
- The final period’s metered usage is reported before reporting stops.
-
A test clock run from
activetounpaidand back, at least once before launch.
Summary
Stripe dunning in Phoenix is mostly a policy decision with a small amount of code behind it. Stripe retries and, if you let it, emails. Your app’s job is to keep the subscription status in sync, keep paying-in-spirit customers entitled while the retries run, tell them clearly inside the app, and make sure no usage goes unbilled when a subscription does end.
The free Aurora Meter core (MIT) gives you the entitlement rule out of the box: status in, plan out, with past_due treated as entitled and everything else falling back to your free plan. If you would rather not write the webhook sync and the end-of-period reporting yourself, Aurora Meter Pro does both, along with Stripe Checkout, Billing Meters and quota alerts. It is licensed per app from $99 a month, with a 14-day free trial (one per customer).
Building the whole product rather than just the billing? The Aurora API Starter is a complete metered-API SaaS on Phoenix built on the Aurora Meter core.