· 6 min de lectura

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.

PaymentsSaaSStripe

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

ProjectModelPricingAudienceConversion
MaruškaSubscription, 3 tiers399 / 599 / 799 CZK/moMicro-businesses, regular output2.1 %
Holky s úspěchemOne-shot Checkout990 CZK one-timeSporadic, course-buyer4.7 %
DokladBotSubscription199 CZK/moAccounting firms3.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 fail

Building 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:

EventWhenAction
checkout.session.completedAfter Checkout finishes (one-shot or subs)Grant access, send email
invoice.paidRecurring charge succeededExtend access, send invoice email
invoice.payment_failedRecurring charge failedNotify user, Stripe schedules retry
customer.subscription.deletedCancellationRevoke access at period_end
customer.subscription.updatedTier changeUpdate 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} in success_url gives you the session ID in the URL. Don't use {customer} placeholder - that's for existing-customer reuse.

What's next

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.