I've set up Stripe integrations probably a dozen times over the past few years, and here's what I've learned: the getting-started tutorial is the easy part. The hard part is running four completely different pricing models in the same organization, handling 30+ country currencies without rounding errors, and making sure your webhook handlers don't silently fail at 3 AM on a Saturday.

This isn't another "create a checkout session in 5 minutes" post. We're going to walk through four production pricing models we've built and operated -- tiered subscriptions with regional pricing, marketplace commissions via Stripe Connect, recurring donations tied to specific entities, and one-time service payments. Each one has its own set of gotchas, and I'll share the specific code patterns and configuration decisions that saved us from painful bugs.

Table of Contents

Best Stripe Setup for Subscription Business: 4 Models We Run

Why One Stripe Setup Doesn't Fit All

Stripe's documentation is excellent for single-model businesses. You pick subscriptions or one-time payments, follow the guide, and you're live. But most real businesses don't stay that simple for long.

We operate across multiple products: a SaaS platform with tiered subscriptions, a marketplace that takes commissions from providers, a charity initiative with recurring sponsorships, and a consultation booking system with one-time payments. Each of these lives under the same Stripe account but requires fundamentally different configurations for products, pricing, webhooks, and customer management.

The biggest mistake I see teams make is trying to force all their billing into one model. A subscription-first architecture breaks when you need one-time payments. A checkout-session-only approach falls apart when you need recurring billing with proration. You need to think about your Stripe setup as a portfolio of billing patterns.

If you're building something similar -- especially on a headless architecture with Next.js or Astro on the frontend -- the patterns here will save you weeks of debugging.

Model 1: Tiered Subscriptions with Regional Pricing

This is the most complex model we run, and it's the one that taught us the most painful lessons. The setup: four tiers (Free, Basic, Pro, Premium) with pricing that varies across 30+ countries.

The Product Structure in Stripe

In Stripe, each tier is a separate Product. Each Product has multiple Prices -- one per currency/region combination. This is important: don't try to use a single Price and do currency conversion yourself. Stripe's multi-currency pricing is purpose-built for this.

// Regional pricing configuration
const REGIONAL_PRICING = {
  pro: {
    USD: { monthly: 2900, yearly: 29000 },  // $29/mo, $290/yr
    EUR: { monthly: 2700, yearly: 27000 },  // €27/mo, €270/yr
    GBP: { monthly: 2300, yearly: 23000 },  // £23/mo, £230/yr
    JPY: { monthly: 4200, yearly: 42000 },  // ¥4,200/mo -- NOT ¥42.00!
    KRW: { monthly: 38000, yearly: 380000 }, // ₩38,000/mo
    INR: { monthly: 190000, yearly: 1900000 }, // ₹1,900/mo
    BRL: { monthly: 14900, yearly: 149000 }, // R$149/mo
  },
  // ... repeat for basic, premium
};

Notice those JPY and KRW values? I'll get to that bug in detail later, but the short version: these are zero-decimal currencies. When you pass 4200 for JPY, Stripe interprets it as ¥4,200 -- not ¥42.00. If you multiply by 100 like you do for USD, you just charged someone ¥420,000 ($2,800) instead of ¥4,200 ($28). Ask me how I know.

Regional Trial Logic

Not every region gets a free trial. We learned this the hard way with certain Southeast Asian markets where trial abuse was significantly higher than other regions. Our configuration looks like this:

const TRIAL_CONFIG = {
  default_trial_days: 14,
  excluded_regions: ['VN', 'PH', 'ID', 'TH', 'MM', 'KH', 'LA'],
  reduced_trial_regions: {
    IN: 7,
    BR: 7,
  },
};

function getTrialDays(countryCode) {
  if (TRIAL_CONFIG.excluded_regions.includes(countryCode)) {
    return 0;
  }
  return TRIAL_CONFIG.reduced_trial_regions[countryCode] 
    ?? TRIAL_CONFIG.default_trial_days;
}

This gets passed into the subscription creation:

const subscription = await stripe.subscriptions.create({
  customer: customerId,
  items: [{ price: regionalPriceId }],
  trial_period_days: getTrialDays(customer.address.country),
  payment_behavior: 'default_incomplete',
  payment_settings: {
    save_default_payment_method: 'on_subscription',
  },
  expand: ['latest_invoice.payment_intent'],
});

Proration Behavior

When someone upgrades from Basic to Pro mid-cycle, you need to decide: do they pay the difference immediately, or at the next billing cycle? We use create_prorations with immediate payment:

const updatedSubscription = await stripe.subscriptions.update(subscriptionId, {
  items: [{
    id: existingItemId,
    price: newPriceId,
  }],
  proration_behavior: 'create_prorations',
  payment_behavior: 'pending_if_incomplete',
});

For downgrades, we schedule the change for the end of the billing period. Nobody wants a surprise credit calculation on their invoice.

Customer Portal

Stripe's Customer Portal is underrated. Instead of building your own subscription management UI, configure the portal and redirect users there:

const portalSession = await stripe.billingPortal.sessions.create({
  customer: customerId,
  return_url: `${process.env.APP_URL}/settings/billing`,
});

Configure it in the Stripe Dashboard to allow plan changes, cancellation (with a cancellation reason survey -- the data is gold), and payment method updates. This alone saved us probably 40 hours of frontend development.

Model 2: Marketplace Commissions with Stripe Connect

Our marketplace model uses Stripe Connect to facilitate payments between customers and service providers. The platform takes a commission on every transaction. This is the Stripe Connect setup that most tutorials gloss over.

Provider Onboarding

Every provider on the marketplace needs a Stripe Express account. The onboarding flow creates the account and redirects them to Stripe's hosted onboarding:

const account = await stripe.accounts.create({
  type: 'express',
  country: provider.country,
  email: provider.email,
  capabilities: {
    card_payments: { requested: true },
    transfers: { requested: true },
  },
  business_type: 'individual',
  metadata: {
    provider_id: provider.id,
    platform: 'fme',
  },
});

const accountLink = await stripe.accountLinks.create({
  account: account.id,
  refresh_url: `${process.env.APP_URL}/provider/onboarding/refresh`,
  return_url: `${process.env.APP_URL}/provider/onboarding/complete`,
  type: 'account_onboarding',
});

The key detail: refresh_url is where Stripe sends users if the link expires. This happens more often than you'd think -- if someone starts onboarding on their phone, gets distracted, and comes back later. Always handle this gracefully by generating a new link.

Commission Structure

When a customer books a service, we create a PaymentIntent with an application_fee_amount:

const paymentIntent = await stripe.paymentIntents.create({
  amount: bookingAmountInCents,
  currency: 'usd',
  application_fee_amount: Math.round(bookingAmountInCents * 0.15), // 15% platform fee
  transfer_data: {
    destination: providerStripeAccountId,
  },
  metadata: {
    booking_id: booking.id,
    provider_id: provider.id,
    customer_id: customer.id,
  },
});

The 15% commission goes to the platform. The remaining 85% (minus Stripe's processing fee) goes to the provider's Express account.

Payout Scheduling

By default, Stripe pays out Express accounts on a rolling basis. We override this to weekly payouts, which gives us a buffer for refunds and disputes:

await stripe.accounts.update(providerStripeAccountId, {
  settings: {
    payouts: {
      schedule: {
        interval: 'weekly',
        weekly_anchor: 'friday',
      },
    },
  },
});

Friday payouts mean providers see money in their bank accounts by Monday. It's a small thing but it matters enormously for provider satisfaction and retention.

Connect-Specific Webhooks

With Stripe Connect, you receive webhooks for both your platform AND your connected accounts. You need a separate webhook endpoint for Connect events:

// Regular webhook endpoint
app.post('/webhooks/stripe', handlePlatformWebhooks);

// Connect webhook endpoint
app.post('/webhooks/stripe-connect', handleConnectWebhooks);

The Connect webhook handler needs to verify the event differently and check the account field:

async function handleConnectWebhooks(req, res) {
  const event = stripe.webhooks.constructEvent(
    req.body,
    req.headers['stripe-signature'],
    process.env.STRIPE_CONNECT_WEBHOOK_SECRET // Different secret!
  );
  
  const connectedAccountId = event.account;
  // Now handle the event in context of the connected account
}

Best Stripe Setup for Subscription Business: 4 Models We Run - architecture

Model 3: Recurring Donations Tied to Entities

This one's for an animal charity initiative we're building -- think "sponsor a specific animal" with monthly recurring donations. The donor picks an animal, sets a monthly amount, and gets photo updates.

Entity-Linked Subscriptions

The trick here is linking a Stripe subscription to a specific entity (animal) in your database. We do this entirely through metadata:

const subscription = await stripe.subscriptions.create({
  customer: donorCustomerId,
  items: [{
    price_data: {
      currency: 'usd',
      product: sponsorshipProductId,
      unit_amount: donorChosenAmount, // Donor picks their amount
      recurring: {
        interval: 'month',
      },
    },
  }],
  metadata: {
    entity_id: animal.id,
    entity_type: 'animal',
    entity_name: animal.name,
    sponsor_email: donor.email,
  },
});

Using price_data instead of a pre-created Price lets donors choose their own monthly amount. This is cleaner than creating hundreds of Price objects.

Monthly Update Emails

When invoice.paid fires for a sponsorship subscription, we trigger the monthly update flow:

async function handleSponsorshipInvoicePaid(invoice) {
  const subscription = await stripe.subscriptions.retrieve(invoice.subscription);
  const entityId = subscription.metadata.entity_id;
  
  // Queue monthly update email with latest photos
  await emailQueue.add('sponsorship-update', {
    donorEmail: subscription.metadata.sponsor_email,
    entityId,
    invoiceAmount: invoice.amount_paid,
    invoicePdf: invoice.invoice_pdf,
  });
}

The email includes the invoice PDF (Stripe generates these automatically), recent photos of the sponsored animal, and a care update. It's a small touch that dramatically reduces churn on recurring donations.

Handling Donation Cancellations

When someone cancels their sponsorship, you need to handle it differently than a SaaS cancellation. There's no "downgrade" -- it's cancel or nothing. But you want to make it easy to re-subscribe later:

async function handleSponsorshipCancellation(subscription) {
  const entityId = subscription.metadata.entity_id;
  
  // Mark sponsorship as inactive, not deleted
  await db.sponsorships.update({
    where: { stripeSubscriptionId: subscription.id },
    data: { 
      status: 'inactive',
      cancelledAt: new Date(),
    },
  });
  
  // Send "we'll miss you" email with easy re-subscribe link
  await sendCancellationEmail(subscription.metadata.sponsor_email, entityId);
}

Model 4: One-Time Service Payments

The simplest model, but there are still details that matter. This pattern is for consultation bookings where someone pays once and gets a service -- no recurring billing.

Checkout Session with Booking Data

const session = await stripe.checkout.sessions.create({
  mode: 'payment',
  line_items: [{
    price: consultationPriceId,
    quantity: 1,
  }],
  customer_email: customer.email,
  metadata: {
    booking_id: booking.id,
    service_type: 'consultation',
    appointment_date: booking.date.toISOString(),
    practitioner_id: booking.practitionerId,
  },
  success_url: `${process.env.APP_URL}/booking/confirmed?session_id={CHECKOUT_SESSION_ID}`,
  cancel_url: `${process.env.APP_URL}/booking/${booking.id}`,
  expires_after: 1800, // 30 minutes
  payment_intent_data: {
    metadata: {
      booking_id: booking.id,
    },
  },
});

Two things to note. First: expires_after prevents abandoned checkout sessions from lingering. A booking slot shouldn't be held forever. Second: we duplicate the booking_id in payment_intent_data.metadata because the PaymentIntent metadata is separate from the Checkout Session metadata. When you receive the payment_intent.succeeded webhook, you'll want that booking ID right there.

Payment + Booking Confirmation

On checkout.session.completed, we confirm the booking and send everything in one shot:

async function handleCheckoutComplete(session) {
  const bookingId = session.metadata.booking_id;
  
  // Confirm the booking
  const booking = await db.bookings.update({
    where: { id: bookingId },
    data: { 
      status: 'confirmed',
      paymentSessionId: session.id,
      paidAt: new Date(),
    },
  });
  
  // Send confirmation to customer
  await sendBookingConfirmation(session.customer_email, booking);
  
  // Notify practitioner
  await notifyPractitioner(booking.practitionerId, booking);
}

Webhook Architecture That Actually Works

Across all four models, webhooks are the backbone. Here's the architecture we've settled on after too many debugging sessions:

const WEBHOOK_HANDLERS = {
  'checkout.session.completed': handleCheckoutComplete,
  'invoice.paid': handleInvoicePaid,
  'invoice.payment_failed': handlePaymentFailed,
  'customer.subscription.created': handleSubscriptionCreated,
  'customer.subscription.updated': handleSubscriptionUpdated,
  'customer.subscription.deleted': handleSubscriptionDeleted,
  'account.updated': handleConnectAccountUpdated,
  'payment_intent.succeeded': handlePaymentSucceeded,
};

async function webhookHandler(req, res) {
  let event;
  try {
    event = stripe.webhooks.constructEvent(
      req.rawBody, // You need the raw body, not parsed JSON
      req.headers['stripe-signature'],
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    console.error('Webhook signature verification failed:', err.message);
    return res.status(400).send();
  }

  // Idempotency: check if we've already processed this event
  const processed = await db.webhookEvents.findUnique({
    where: { stripeEventId: event.id },
  });
  if (processed) {
    return res.status(200).json({ received: true, duplicate: true });
  }

  const handler = WEBHOOK_HANDLERS[event.type];
  if (handler) {
    try {
      await handler(event.data.object, event);
      await db.webhookEvents.create({
        data: { stripeEventId: event.id, type: event.type, processedAt: new Date() },
      });
    } catch (err) {
      console.error(`Error processing ${event.type}:`, err);
      return res.status(500).send(); // Stripe will retry
    }
  }

  res.status(200).json({ received: true });
}

The idempotency check is critical. Stripe will retry failed webhooks, and you absolutely do not want to process the same event twice -- especially for things like creating bookings or triggering payouts.

Failed Payment Retry Logic and Dunning

Stripe has built-in Smart Retries, but you should layer your own dunning logic on top:

async function handlePaymentFailed(invoice) {
  const attemptCount = invoice.attempt_count;
  const subscription = await stripe.subscriptions.retrieve(invoice.subscription);
  
  if (attemptCount === 1) {
    // First failure: gentle nudge
    await sendEmail(invoice.customer_email, 'payment-failed-soft', {
      updatePaymentUrl: await createPortalLink(invoice.customer),
    });
  } else if (attemptCount === 2) {
    // Second failure: more urgent
    await sendEmail(invoice.customer_email, 'payment-failed-urgent', {
      updatePaymentUrl: await createPortalLink(invoice.customer),
      daysUntilCancellation: 7,
    });
  } else if (attemptCount >= 3) {
    // Final warning
    await sendEmail(invoice.customer_email, 'payment-failed-final', {
      updatePaymentUrl: await createPortalLink(invoice.customer),
    });
  }
}

Configure Stripe's retry schedule in Dashboard → Settings → Subscriptions and emails → Manage failed payments. We use 3 retries over 14 days before cancellation.

The Zero-Decimal Currency Bug That Cost Us Money

This deserves its own section because it's a bug that bites everyone eventually. Stripe uses cents (smallest currency unit) for most currencies. $29.00 becomes 2900. But some currencies don't have decimal places.

Here are the zero-decimal currencies that matter:

Currency Code Example: "$29 equivalent" What you pass to Stripe
Japanese Yen JPY ¥4,200 4200 (NOT 420000)
Korean Won KRW ₩38,000 38000 (NOT 3800000)
Vietnamese Dong VND ₫700,000 700000
Chilean Peso CLP $25,000 25000
Paraguayan Guarani PYG ₲200,000 200000

Here's the utility function we use everywhere:

const ZERO_DECIMAL_CURRENCIES = [
  'BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW',
  'MGA', 'PYG', 'RWF', 'UGX', 'VND', 'VUV', 'XAF',
  'XOF', 'XPF',
];

function toStripeAmount(amount, currency) {
  const curr = currency.toUpperCase();
  if (ZERO_DECIMAL_CURRENCIES.includes(curr)) {
    return Math.round(amount); // Already in smallest unit
  }
  return Math.round(amount * 100);
}

function fromStripeAmount(stripeAmount, currency) {
  const curr = currency.toUpperCase();
  if (ZERO_DECIMAL_CURRENCIES.includes(curr)) {
    return stripeAmount;
  }
  return stripeAmount / 100;
}

Use this everywhere. In your checkout creation, in your webhook handlers, in your dashboard displays. Everywhere. The one time you forget is the time you charge someone 100x what they expected.

Comparing the Four Models

Aspect Tiered Subs Marketplace Recurring Donation One-Time Payment
Stripe Product Multiple Products, multiple Prices per product Single Product per service type Single Product, dynamic pricing Single Product, fixed Price
Billing Mode subscription payment with Connect subscription payment
Webhook Complexity High (lifecycle events) High (Connect events) Medium Low
Currency Handling Regional pricing matrix Provider's currency Donor's currency Single currency
Trial Support Yes, region-dependent N/A N/A N/A
Proration Yes, on upgrades N/A N/A N/A
Refund Complexity Prorated calculations Platform fee reversal Simple full refund Simple full refund
Customer Portal Essential Not needed Nice to have Not needed
Stripe Fees (2025) 2.9% + 30¢ 2.9% + 30¢ + 0.5% Connect 2.9% + 30¢ 2.9% + 30¢

FAQ

How many Stripe Products should I create for tiered pricing?

One Product per tier. So if you have Free, Basic, Pro, and Premium, that's four Products. Each Product then has multiple Prices -- one per currency and billing interval combination. A Pro tier with monthly and yearly billing across 10 currencies means 20 Price objects on that single Product. It sounds like a lot, but Stripe handles this well and it keeps your catalog organized.

Can I use Stripe Checkout for subscriptions with regional pricing?

Yes, but you need to determine the customer's region before creating the Checkout Session so you can pass the correct Price ID. We use IP geolocation (via Cloudflare headers) to pre-select the currency, then let the customer confirm or change it. Don't rely on Checkout's automatic currency -- you want control over which Price they see.

What's the difference between Stripe Connect Express and Custom accounts?

Express accounts let Stripe handle the onboarding, identity verification, and dashboard for your providers. Custom accounts give you full control but require you to build all of that yourself. For most marketplaces, Express is the right choice. We've never had a case where the loss of control justified the engineering cost of Custom. Express accounts also handle tax reporting (1099s in the US) automatically, which is a massive compliance win.

How do I handle failed subscription payments without losing customers?

Layer three things: Stripe's Smart Retries (enabled in Dashboard), custom dunning emails triggered by invoice.payment_failed webhooks, and a grace period before cancellation. We give 14 days across 3 retry attempts. The first email is friendly ("hey, your card might have expired"), the second is urgent, and the third is a final warning. Include a direct link to the Customer Portal where they can update their payment method. This alone recovers about 30-40% of failed payments.

Do I need separate webhook endpoints for Stripe Connect?

Yes. Platform events and Connect account events use different webhook secrets and different event structures. Connect events include an account field identifying which connected account the event relates to. Register two endpoints in your Stripe Dashboard: one for platform events, one for Connect events. This separation also makes debugging much easier.

What are zero-decimal currencies and why should I care?

Zero-decimal currencies like JPY (Japanese Yen) and KRW (Korean Won) don't use fractional units. When Stripe says "amount in smallest currency unit," for USD that's cents (2900 = $29.00), but for JPY it's yen (4200 = ¥4,200). If you multiply by 100 like you do for USD, you charge ¥420,000 instead of ¥4,200. Always use a helper function that checks the currency before converting. Stripe maintains the official list of zero-decimal currencies in their docs.

Should I use Stripe Billing's Customer Portal or build my own?

Use the Customer Portal for subscription management unless you have very specific UI requirements. It handles plan changes, cancellations, payment method updates, and invoice history out of the box. You can customize the branding and configure which actions are allowed. Building your own portal means handling proration calculations, payment method tokenization, and SCA/3D Secure flows yourself. The portal is free -- it's included in your Stripe subscription fees.

How do I test regional pricing and currency handling locally?

Stripe's test mode supports all currencies. Create test Prices in each currency you plan to support, then use Stripe CLI to forward webhooks to your local server: stripe listen --forward-to localhost:3000/webhooks/stripe. For zero-decimal currency testing specifically, create a JPY Price and verify the amounts in your webhook handler logs before going live. We also maintain a test suite that runs toStripeAmount and fromStripeAmount against every supported currency -- it's caught issues more than once.

If you're building a subscription-based product and need help with the billing architecture, or if you're integrating Stripe with a headless CMS setup, get in touch. We've built these patterns across multiple headless CMS projects and can help you skip the expensive mistakes. Check our pricing page for engagement models -- we do both project-based builds and ongoing advisory.