Nabeel HassanI build VoiceDash, a white-label client portal for agencies that run voice agents on Retell. Agencies...
I build VoiceDash, a white-label client portal for agencies that run voice agents on Retell. Agencies pay a monthly subscription through Stripe, and first-time customers get a 7-day free trial.
For a while, that trial had a quiet flaw. Checkout never asked first-time customers for a card. So when the seven days were up, Stripe had nothing to charge. The subscription did not convert, it lapsed, and the account lost access without anyone deciding that it should.
The fix was eight lines added and ten removed. The part worth writing about is how I got there, because no single line was wrong. Three pieces of the product each described the trial differently, and nothing forced them to agree.
Here is the shape of the original Checkout session params, trimmed to the part that matters:
const checkoutParams: Stripe.Checkout.SessionCreateParams = {
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
success_url: successUrl,
cancel_url: `${appUrl}/agency/subscribe`,
metadata: { workspaceId },
// First-time: 7-day trial, $0 today, no card.
// Returning (trial expired/canceled): pay now, card required.
...(isReturningCustomer
? { payment_method_collection: "always" as const }
: {
subscription_data: { trial_period_days: 7 },
payment_method_collection: "if_required" as const,
}),
};
payment_method_collection: "if_required" reads like a sensible default. Do not ask for a card unless you need one. And on day one you do not need one: the first invoice of a trial is $0, so Stripe happily skips the card form. That is exactly what the comment says, and exactly what happened.
The problem is that "required" is evaluated at checkout, not at the end of the trial. Nothing about that setting means "ask for a card later". It means "we did not need one today, so we never got one".
Stripe does let you decide what happens when a trial ends with no payment method on file. There is a trial_settings.end_behavior.missing_payment_method option on the subscription, with choices like canceling or pausing it. I had not set it, and more importantly, I had not designed for the case at all. In my head, a trial was a countdown that ended in a charge. In the code, it was a countdown that ended in nothing.
This is the part that bothers me more than the parameter.
When a trial started, VoiceDash sent a confirmation email. It said, in bold, "No charge today", and then explained that near the end of the trial, we would ask you to add a payment method to continue.
When I went looking for the code that did that asking, there was none. No handler for Stripe's trial-ending event, no reminder email, no banner nudging people to add a card. The email was describing a flow I had planned at some point and never built. The subscribe page had a softer version of the same promise.
So there were three sources of truth for one business rule:
Each one was internally reasonable. Read together, they described a product that did not exist: a no-card trial with a reminder flow. The code only implemented the first half.
I think this is a very normal way for billing bugs to happen in small SaaS products. Copy gets written when the plan is fresh. Code gets written when you are trying to ship. Nobody re-reads the email template when they touch the checkout route, because the email template lives in email-templates.ts and feels like marketing, not logic.
It is logic, though. A sentence like "we'll ask you to add a payment method" is a spec. If nothing implements it, it is a broken spec that customers are reading.
Once I saw the gap, the real decision was not "fix the parameter". It was choosing between two different products.
A no-card trial is lower friction at signup. More people start it. But it only works if you build the second half properly:
That is a real feature, with real edge cases, and every one of them is a place to drop a paying customer.
A card-up-front trial has more friction at signup, but the end of the trial is boring. Stripe charges the card and the subscription becomes active. The webhook handlers I already had for created, updated, paid and failed invoices cover it. If the card fails, the existing payment-failed path kicks in, marks the subscription past due and emails a link to update the card.
For a B2B tool sold to agencies, who are going to put a card down anyway if they plan to keep using it, the second model was the honest one. It is also the one that matched what I had actually built.
The new params:
const checkoutParams: Stripe.Checkout.SessionCreateParams = {
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
success_url: successUrl,
cancel_url: `${appUrl}/agency/subscribe`,
metadata: { workspaceId },
// A card is always collected up front, trial or not. First-time
// customers get 7 free days and are charged automatically when the trial
// ends; returning ones (trial used up / canceled) are charged now.
payment_method_collection: "always" as const,
...(isReturningCustomer ? {} : { subscription_data: { trial_period_days: 7 } }),
};
Two things changed in structure, not just value. The card rule is no longer inside the trial branch, so there is no combination of flags that produces "trial with no card". And the only thing that varies between new and returning customers is the trial itself.
isReturningCustomer is just whether this workspace already has a Stripe customer in the current account. Someone who has been a customer before has used their trial, so they pay immediately. I wrote about why that "in the current account" qualifier matters in Moving Stripe Accounts Turned Every Stored ID Into a Guess.
The same commit rewrote the copy. The trial email now says the card is charged when the trial ends, the account keeps running, and you can cancel before then and pay nothing. The subscribe page now says up front that we ask for a card to hold the plan. I treated those two text changes as part of the bug fix, not a follow-up, because they were part of the bug.
I treat customer-facing billing copy as code. Any sentence that describes when someone is charged, what happens at the end of a trial, or how to cancel gets checked against the code path that implements it. When I change the checkout route, I grep for the copy too.
I write the end of the trial first. The start of a trial is the easy, happy part. The end is where money moves or does not. Before shipping any trial, I want to be able to answer: on day eight, what is the subscription status, what does the customer see, and which code path got us there?
I would rather test the timeline than the call. A unit test of the params object would have passed, because the params were exactly what I wrote. What would have caught this is running a trial forward in Stripe's test mode and looking at what state it ends in. Stripe has test clocks for moving a subscription through time without waiting a week, and that is the test I should have had.
Fewer branches in billing config. The bad combination only existed because the card setting lived inside the trial branch. Pulling invariants out of conditionals is boring advice, but in billing code the boring version is the one you want.
The bug was not "I used the wrong Stripe parameter". It was that the trial was described in three places, by three versions of me, at three different times, and the version that shipped was the only one nobody read aloud.
If you run a trial in your own product, it is worth a quick check today: open your checkout code, your trial email and your pricing page side by side, and ask whether they describe the same day eight.
Has anyone here shipped a no-card trial and built the full reminder flow behind it? I am curious whether it actually converts better for B2B tools, or whether the extra signups mostly wash out at the end.