Do I have to handle failed payments and refunds myself?
No. Stripe retries failed charges, emails customers, and processes refunds through its dashboard. Your app subscribes to invoice.payment_failed, invoice.payment_succeeded, charge.refunded and customer.subscription.deleted, and keeps one local Subscription row in sync. That is the whole of your responsibility.
The division of labour
| Stripe does | Your app does |
|---|---|
| Retry schedule and dunning email | Move a row to past_due and back |
| Refund mechanics and the money movement | Revoke access if the refund means they should lose it |
| Chargeback representment workflow | Know that a dispute happened |
| Card updates via the billing portal | Nothing |
Everything in the right column happens in one controller,
app/controllers/webhooks/stripe_controller.rb, and writes to one model,
app/models/subscription.rb.
Refunds are issued in the dashboard
There is no refund button to build. You refund in Stripe, charge.refunded arrives, and your handler
decides what that means. For a subscription, usually nothing: the subscription continues or was
already canceled separately. For a one-time purchase it usually means revoking, or you have refunded
someone who keeps the product.
The part that is genuinely yours
Idempotency. Stripe retries deliveries, so the same event can arrive twice, and a handler that increments or appends rather than setting will double up. The activation path is written as an idempotent upsert for this reason:
Subscription.activate!(account, plan: :pro)
Running it twice leaves the same row. Any handler you add should have that property, and it is worth checking by replaying an event from the Stripe dashboard rather than assuming.
Verify the four events end to end
bin/dev
stripe listen --forward-to localhost:3000/webhooks/stripe
stripe trigger invoice.payment_failed
stripe trigger charge.refunded
With stripe listen forwarding to your local app, those two exercise the branches that are hardest
to reach by clicking, and they are the ones most likely to be wrong because nobody tests them.
What you should not build
A reconciliation job that polls Stripe for subscription state. It is tempting after a missed webhook, but it hides the real problem, which is that the endpoint is failing. Fix the endpoint; Stripe retries for three days and backfills on its own.