Stripe Checkout vs Subscriptions: when to use which mode
Real comparison across three projects - Maruška (subs 399/599/799 CZK), Holky s úspěchem (one-shot 990 CZK), DokladBot (199 CZK/mo). Conversion 4.7% vs 2.1%, dunning, portal, webhooks.
The most common mistake I see in early-stage SaaS founders: automatically picking subscription model because "everyone does it". Then they wrestle with dunning, vendor lock-in on Stripe Billing and 2% conversion at 4% marketing cost. From three projects where I've shipped Stripe (Maruška, Holky s úspěchem, DokladBot), I've extracted a rule: subscription only when recurring value is obvious, otherwise one-shot Checkout.
Here's the comparison with real conversion numbers.
Three projects, three billing strategies
| Project | Model | Pricing | Audience | Conversion |
|---|---|---|---|---|
| Maruška | Subscription, 3 tiers | 399 / 599 / 799 CZK/mo | Micro-businesses, regular output | 2.1 % |
| Holky s úspěchem | One-shot Checkout | 990 CZK one-time | Sporadic, course-buyer | 4.7 % |
| DokladBot | Subscription | 199 CZK/mo | Accounting firms | 3.4 % |
Maruška and DokladBot make sense as subs - the user generates value every month (scans receipts, sends reminders). Holky s úspěchem sells a digital workbook - buy once, you have it. Forcing subscription there would halve conversion because "I'm subscribing only once".
One-shot Checkout: when it wins
Holky s úspěchem sells a coaching workbook for 990 CZK. Audience: women 30-50, sporadic course buyers, they want a transaction, not a commitment.
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function createCheckoutSession(email: string) {
return stripe.checkout.sessions.create({
mode: 'payment',
payment_method_types: ['card'],
line_items: [
{
price: 'price_workbook_990',
quantity: 1,
},
],
customer_email: email,
success_url: 'https://holkysuspechem.cz/dekujeme?session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'https://holkysuspechem.cz/workbook',
locale: 'cs',
});
}mode: 'payment' = one-time payment. No subscription, no customer portal, no dunning. 5 lines of code, 4.7% conversion.
Webhook handling is also simple:
async function handleWebhook(event: Stripe.Event) {
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session;
await grantWorkbookAccess({
email: session.customer_email!,
sessionId: session.id,
});
await sendDownloadEmail(session.customer_email!);
}
}One event, one handler. Payment goes through → grant access + email. Fail → user sees Stripe error and retries.
Subscription: when it makes sense
Maruška scans receipts every month, sends VAT reminders, builds reports. Value accumulates monthly, not all at once. Subs make sense here.
export async function createSubscriptionCheckout(email: string, tier: 'basic' | 'pro' | 'business') {
const priceMap = {
basic: 'price_399_basic',
pro: 'price_599_pro',
business: 'price_799_business',
};
return stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceMap[tier], quantity: 1 }],
customer_email: email,
success_url: 'https://maruska.app/welcome?session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'https://maruska.app/pricing',
locale: 'cs',
subscription_data: {
trial_period_days: 14,
},
});
}Key difference: mode: 'subscription' + subscription_data.trial_period_days. Stripe automatically:
- Creates a customer
- Creates the subscription with trial
- Issues an invoice when trial ends
- Retries failed charges (dunning)
Dunning: the main reason not to DIY
Dunning = retry logic for failed payments. Card expires, bank declines, customer is broke. Without dunning, trial → paid conversion drops by 30%. Stripe Billing dunning runs 4 retries over 15 days with a configurable schedule:
// In the Stripe dashboard or via API:
await stripe.subscriptions.update('sub_...', {
payment_settings: {
payment_method_types: ['card', 'sepa_debit'],
save_default_payment_method: 'on_subscription',
},
});
// Smart retry rules: Stripe auto-retries 3, 5, 7, 14 days after failBuilding dunning yourself is two weeks of work. Stripe does it for free, use it.
Customer portal: saves you hours
In Maruška users want to: change card, downgrade tier, cancel, download invoice. Without a portal that's 4 different custom UI flows. Stripe has a built-in customer portal in one line:
export async function createPortalSession(customerId: string) {
return stripe.billingPortal.sessions.create({
customer: customerId,
return_url: 'https://maruska.app/settings',
locale: 'cs',
});
}You redirect the user to the URL Stripe returns. The portal handles localization, branding (logo + colors), all self-service flows. Saves me 2 weeks of dev and support tickets.
Webhook events: what to listen for
Stripe sends dozens of events. For most use-cases 4-5 are enough:
| Event | When | Action |
|---|---|---|
checkout.session.completed | After Checkout finishes (one-shot or subs) | Grant access, send email |
invoice.paid | Recurring charge succeeded | Extend access, send invoice email |
invoice.payment_failed | Recurring charge failed | Notify user, Stripe schedules retry |
customer.subscription.deleted | Cancellation | Revoke access at period_end |
customer.subscription.updated | Tier change | Update entitlements |
import type Stripe from 'stripe';
async function handleStripeWebhook(event: Stripe.Event) {
switch (event.type) {
case 'checkout.session.completed':
return onCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
case 'invoice.paid':
return onInvoicePaid(event.data.object as Stripe.Invoice);
case 'invoice.payment_failed':
return onPaymentFailed(event.data.object as Stripe.Invoice);
case 'customer.subscription.deleted':
return onSubscriptionCanceled(event.data.object as Stripe.Subscription);
case 'customer.subscription.updated':
return onSubscriptionUpdated(event.data.object as Stripe.Subscription);
default:
return; // silent skip for the rest
}
}Key: always idempotent handlers. Stripe occasionally sends the same event twice. Use event.id as an idempotency key in your DB.
Decision tree
User pays once (workbook, book, course)?
→ mode: 'payment' (one-shot Checkout)
→ 4-5% conversion is realistic
User gets value monthly (SaaS, content, software)?
→ mode: 'subscription'
→ 14-day trial
→ Customer portal
→ 2-4% conversion is realistic
Hybrid (setup fee + monthly)?
→ mode: 'subscription' + add-on `add_invoice_items`
→ Watch out for complex tax situations
Lessons
- One-shot conversion is 2× the subscription conversion in audiences that buy sporadically.
- 14-day trial > 7 days for B2B (user needs a full cycle). For consumer B2C, 7 may be enough.
- Customer portal (
billingPortal.sessions) saves 2 weeks of custom UI. Use it. - Stripe dunning > your own retry logic. 30% difference in trial → paid conversion.
- Webhook idempotency via
event.id- Stripe occasionally sends duplicates. locale: 'cs'in Checkout/Portal - users see Stripe UI in Czech, +12% conversion in my projects.{CHECKOUT_SESSION_ID}insuccess_urlgives you the session ID in the URL. Don't use{customer}placeholder - that's for existing-customer reuse.
What's next
- Maruška case study → - project with 3-tier subs
- Holky s úspěchem case study → - project with one-shot
- DokladBot case study → - single-tier subs
- WhatsApp Business API → - how Maruška pairs WhatsApp identity to Stripe customer
- Multi-tenant Postgres → - billing layer over multi-tenant DB
If you're picking a billing model for a new SaaS or migrating subs → one-shot (or the other way), drop me a line. Usually decided in a 30-minute call.