Best Stripe Setup for Subscription Business: 4 Models We Run
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
- Why One Stripe Setup Doesn't Fit All
- Model 1: Tiered Subscriptions with Regional Pricing
- Model 2: Marketplace Commissions with Stripe Connect
- Model 3: Recurring Donations Tied to Entities
- Model 4: One-Time Service Payments
- Webhook Architecture That Actually Works
- Failed Payment Retry Logic and Dunning
- The Zero-Decimal Currency Bug That Cost Us Money
- Comparing the Four Models
- FAQ

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
}

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.