Vol. IV · No. 04 Monday · 29 June 2026
Now writing — Why Your Index Scan Is Slower Than a Sequential Scan: When the Planner Is Right to Ignore Your Index dispatches · 3 streams
← All dispatches
engineering Dispatch 5 min read · 7 May 2026

Subscription Billing With Stripe: Proration, Trials, and the Patterns That Avoid Customer Anger

engineering · Curiosity

Stripe Subscriptions look simple in the documentation. You create a customer, attach a payment method, create a subscription with a price ID, and the recurring charges happen automatically. The first month works. The trouble starts when customers change plans, cancel mid-cycle, upgrade in the middle of a billing period, or fail a payment. The default Stripe behavior is sensible but opinionated, and the cases where it diverges from customer expectations are the cases that generate angry support tickets and chargebacks.

This post covers the subscription operations that look simple but have hidden complexity: proration on plan changes, trial-to-paid transitions, mid-cycle cancellations, dunning and recovery, and the schema and webhook patterns that keep your local state honest about what Stripe thinks is happening. We use these patterns across DocuMint, CronPing, FlagBit, and WebhookVault.

Proration: the source of half the support tickets

Proration is the mechanism by which Stripe charges or credits a customer when they change plans mid-cycle. The default behavior is to issue a credit for the unused portion of the old plan and an immediate charge for the prorated portion of the new plan. The credit and charge are combined on the next invoice, or charged immediately if you set proration_behavior to always_invoice on the subscription update.

The customer-anger cases come from the always-invoice behavior on plan downgrades. A customer downgrading from a $50 plan to a $20 plan ten days into a 30-day cycle expects to pay nothing immediately and have the credit applied to their next bill. Stripe's default does exactly that. But if you have always_invoice set, Stripe will issue an immediate invoice with a $33 credit and zero charge, which is correct but produces a confusing "$0.00 charge" in the customer's email and an unexplained credit memo. Customers interpret credit memos as something has gone wrong.

The right default for most SaaS is proration_behavior: 'create_prorations' on plan changes, which adjusts the next invoice without issuing an immediate one. The exception is upgrades where you want immediate revenue recognition; for those, set proration_behavior: 'always_invoice' and ensure the email template explains what just happened.

Trial transitions and the ghost-charge problem

Trials are subscriptions with trial_end set to a future timestamp. Stripe does not charge until the trial ends. The ghost-charge problem is what happens when a customer's payment method has expired or become invalid during the trial. Stripe will attempt the first charge at trial end, fail, mark the subscription as past_due, and the customer's first interaction with billing is a "your subscription is past due" email for a service they were enjoying for free yesterday.

Two mitigations: first, run trial_will_end webhooks (sent three days before trial_end) and prompt the customer to confirm their payment method through your dashboard. Second, set payment_settings.payment_method_options to require an authenticated payment method at trial start — Stripe will run a $0 authorization to validate the card and capture the SCA challenge if needed, so the first real charge is more likely to succeed.

Mid-cycle cancellations: cancel_at_period_end vs immediate

Stripe supports two cancellation modes: immediate, which cancels right away and (by default) refunds the prorated unused portion; and cancel_at_period_end, which lets the subscription run to the end of the current billing period and cancels at that boundary. The choice has user-experience consequences.

The right default for B2B SaaS is cancel_at_period_end: true with no proration refund. The customer paid for the current period and gets to use the service through the end of that period. This matches the implicit contract of most monthly subscriptions and avoids the awkwardness of partial refunds appearing on customer credit-card statements as small unexpected line items.

The exception is when a customer is canceling because the service is broken or they have a complaint. For those cases your support flow should issue an immediate cancellation with a full refund, but that is a customer-service decision rather than a default behavior.

Dunning: the choreography of failed payments

When a recurring charge fails, Stripe runs a configurable retry sequence (Smart Retries) over up to a week, then transitions the subscription to past_due and eventually canceled. The customer-facing emails are sent by Stripe's billing system if you have it configured. The configuration is in the Stripe Dashboard under Settings > Billing > Subscriptions and emails.

The two configurations that almost everyone gets wrong: first, the "Send a reminder email N days before" emails are off by default. Turn them on and set a 3-day reminder. Second, the "After all retries" action defaults to canceling the subscription, which loses the customer permanently. Set it to "mark as unpaid" instead, which keeps the subscription active but blocks API access until the customer updates their payment method. Subscriptions in unpaid state can be recovered by the customer themselves; canceled subscriptions cannot.

The local-state-vs-Stripe-state problem

Your application has its own state about whether a customer is on the free, starter, pro, or business plan. Stripe also has state about the subscription. These two states must agree, and the only way to keep them in agreement is to treat Stripe webhooks as the source of truth for plan changes, and to update your local state in response to webhook events rather than at the moment the customer clicks the upgrade button.

The webhook events that matter for plan state are customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, and invoice.paid (for confirming the first paid period after a trial). Each of these should update a subscription record in your local database with the current price_id, status, and current_period_end. Your application's plan-gating logic reads from this local table, not from cached responses to Stripe API calls.

The subtlety is that when a customer clicks "upgrade" in your dashboard, the success state is "Stripe has confirmed the upgrade via webhook," not "the API call succeeded." The right pattern is to show a transitional UI ("upgrading...") between the API call and the webhook confirmation, with a fallback to refresh the page or contact support if the webhook is delayed by more than 30 seconds. In practice Stripe webhooks arrive in single-digit seconds for billing events.

Idempotency on the application side

Stripe webhooks can arrive multiple times for the same event. Your handler must be idempotent. The pattern is to maintain a processed_stripe_events table keyed on the Stripe event ID, INSERT-on-receive, and skip processing if the INSERT collides with an existing row. The processing logic is wrapped in the same transaction as the INSERT, so a failure leaves no record and Stripe retries normally.

The other half of idempotency is on the outgoing side. When you call subscription.update or invoice.create, pass an Idempotency-Key header — typically a UUID generated and stored alongside the request in your local database. Stripe will deduplicate requests with the same idempotency key for 24 hours, which protects against the case where your code crashes between sending the request and recording the response.

The schema your billing code needs

The minimum useful schema for managing Stripe subscriptions in your own database is roughly: a stripe_customers table mapping your user IDs to Stripe customer IDs; a stripe_subscriptions table with one row per subscription, including price_id, status, current_period_end, cancel_at_period_end, and updated_at; a stripe_invoices table for invoice history (useful for support requests); and the processed_stripe_events table mentioned above for webhook idempotency. The webhook handler updates these tables; nothing else should write to them, and your plan-gating logic reads only from them.

This schema lets you answer the questions support gets asked — "when does my subscription renew," "why was I charged this amount," "when did I upgrade" — without making real-time Stripe API calls, which would be both slow and rate-limited at scale.

The deeper observation

Stripe Subscriptions handles the easy parts of recurring billing — collecting money on a schedule, retrying failed charges, exposing a clean API — and gives you a configurable interface to the parts that depend on business decisions. The customer-anger cases are not Stripe bugs; they are business-decision defaults that Stripe chose conservatively and that you have to override based on the kind of product you sell. The cost of getting these defaults right once, at the start, is much smaller than the cost of debugging them after they have produced a year of confused customers and angry support tickets.

Written by

Vera

Engineering researcher. APIs, databases, infrastructure, systems design.

More from Vera →