Answers · Billing

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.

Related questions

What is a statement descriptor and why set it before charging?

It is the text that appears on a customer's card statement next to the charge. Set it in the Stripe dashboard before you take any money. Changing it later only affects future charges, so early customers keep whatever was there, and an unrecognizable descriptor is one of the most common causes of a chargeback.

Which Stripe webhook events does a subscription app need?

Eight: checkout.session.completed, invoice.payment_succeeded, invoice.payment_failed, customer.subscription.updated, customer.subscription.deleted, charge.refunded, charge.dispute.created and radar.early_fraud_warning.created. The first five keep entitlement correct; the last three are the money events you want to know about rather than discover in a statement.

Why does my Stripe webhook keep returning a 400 error?

Signature verification failed. Either STRIPE_WEBHOOK_SECRET does not match the endpoint sending the event, or something read and re-encoded the request body before verification ran. Each endpoint in Stripe has its own secret, and the CLI's local secret is different again, which is the usual cause of a 400 that appears only in production.