All articles
Development

Recurring Payments in Web Apps: What Actually Works

May 23, 2026 6 min read

Recurring revenue is the dream — until your first failed charge silently kills a customer's account, or a webhook race condition double-bills someone. Subscription billing looks simple from the outside (charge a card every month, right?), but it touches authentication, email, tax law, accounting, and customer support. Here's how to handle recurring payments in a web app without painting yourself into a corner.

Pick a Billing Provider Before You Write a Line of Code

Don't build billing from scratch. PCI compliance alone will eat months. Use a provider that handles card storage, 3D Secure, retries, and tax.

  • Stripe Billing — Best developer experience, strong subscription primitives, great for SaaS. Fees around 2.9% + $0.30 plus 0.5% for Billing.
  • Paddle — Merchant of record. Handles global sales tax and VAT for you. Good if you sell internationally and don't want tax headaches.
  • Lemon Squeezy — Also merchant of record, simpler than Paddle, popular with indie founders.
  • Chargebee or Recurly — Heavier billing layer that sits on top of Stripe. Useful once you have complex plans, proration, and revenue recognition needs.

If you're a small team selling to customers in multiple countries, a merchant-of-record provider saves you from registering for sales tax in 40 jurisdictions. If you're US-only or B2B and have an accountant, Stripe direct is usually the right call.

Model Your Subscription Data Correctly

Your database needs to mirror what the billing provider stores, not duplicate it. The provider is the source of truth for billing state. Your app is the source of truth for entitlements.

Minimum tables you need

  1. users — with a stripe_customer_id column.
  2. subscriptions — with provider_subscription_id, status, plan_id, current_period_end, cancel_at_period_end.
  3. plans — internal plan definitions mapped to provider price IDs.
  4. invoices — optional, but useful for showing billing history in-app without calling the API every time.

Critical rule: never decide whether a user has access by checking your local status field alone. Always rely on webhook updates to keep it fresh, and treat the provider's status as canonical when in doubt.

Webhooks Are the Hardest Part

Most billing bugs come from webhook handling, not the checkout flow. The checkout works on day one. Webhooks fail on day 47 when a customer's renewal silently breaks.

Webhook rules that will save you

  • Verify the signature on every incoming webhook. Stripe sends a Stripe-Signature header — reject anything that doesn't validate.
  • Make handlers idempotent. The same event can arrive twice. Store the event ID and skip if you've already processed it.
  • Return 200 fast. Acknowledge receipt, then process asynchronously via a queue. If your handler takes 20 seconds, Stripe will retry and you'll get duplicates.
  • Handle these events at minimum: customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.paid, invoice.payment_failed, customer.subscription.trial_will_end.
  • Log everything. When a customer disputes a charge six months later, your webhook log is the only thing that will save you.

Handle Failed Payments Like You Mean It

Around 5–10% of recurring charges fail on the first attempt — expired cards, insufficient funds, fraud blocks. If you don't handle this well, you lose paying customers who wanted to stay.

Set up dunning properly

  1. Enable smart retries in Stripe (or your provider's equivalent). They use ML to pick retry times that recover more payments.
  2. Send your own emails in addition to the provider's. A branded email from your app gets opened more than a generic Stripe one.
  3. Show an in-app banner when payment is past due. Many users only notice when they log in.
  4. Decide your grace period. Most SaaS gives 7–14 days before suspending access. Cutting access immediately damages goodwill; waiting 30 days encourages abuse.
  5. Make updating the card painless. One link to Stripe's hosted Customer Portal does the job — don't build your own card update form.

Proration, Upgrades, and Downgrades

Customers will change plans. Decide your policy before someone asks support.

  • Upgrades: charge the prorated difference immediately. The user expects instant access to the higher tier.
  • Downgrades: apply at the end of the current billing period. Don't refund the difference — it creates accounting noise and invites abuse.
  • Annual to monthly: usually disallow mid-cycle, or schedule for next renewal.
  • Cancellations: use cancel_at_period_end = true instead of immediate cancellation. The user keeps access until they've used what they paid for, and many reactivate before the end date.

Don't Forget Tax

Sales tax, VAT, and GST are not optional and they're not your accountant's problem after the fact — they need to be on the invoice at checkout.

  • If you're using Stripe, turn on Stripe Tax. It calculates the right rate based on the customer's location and your registered jurisdictions.
  • If you'd rather not register in multiple countries, use a merchant of record (Paddle, Lemon Squeezy) — they handle it.
  • Collect and validate VAT numbers for B2B EU customers to apply reverse charge.
  • Store the tax amount on each invoice in your own database for reporting.

Build a Self-Serve Billing Portal

You will get crushed by support tickets if customers can't manage billing themselves. Stripe's Customer Portal handles 90% of cases out of the box:

  • Update payment method
  • View and download past invoices
  • Cancel or change plans (configurable)
  • Update billing email and tax ID

It's a single API call to generate a portal session URL. Wire it to a "Manage Billing" link in your account settings and you're done. This is one of the highest-leverage hours of work in any SaaS build.

Test With Real Edge Cases

Before launch, run through these scenarios in a test environment:

  1. Card declines at initial subscription creation.
  2. Card declines at renewal (use Stripe test cards like 4000 0000 0000 0341).
  3. Customer upgrades mid-cycle, then downgrades a day later.
  4. Webhook is delivered twice (replay it from the Stripe dashboard).
  5. Trial ends with no payment method on file.
  6. Customer disputes a charge — does your app revoke access?
  7. Subscription is cancelled in the Stripe dashboard, not in your app.

Each of these will happen in production. Better to find the bugs now than at 2am when a customer is locked out.

Get It Built Right the First Time

Subscription billing is one of those systems that's cheap to build well at the start and brutally expensive to fix later. At Axoxweb we design and build web apps with payments, auth, and billing wired in properly from day one — so you can focus on customers instead of debugging webhook race conditions. If you're planning a SaaS or any app with recurring revenue, get in touch at axoxweb.com.

PaymentsSaaSWeb Development