Stripe Checkout, subscription mode end-to-end
From Stripe dashboard product to working checkout in your MCP SaaS: setup script, /api/billing/checkout endpoint, success_url handling, and the customer portal for self-service.
Stripe Checkout, subscription mode end-to-end
Stripe Checkout is the lowest-effort way to take subscription money. You redirect to a Stripe-hosted page, they collect the card, you get a webhook. This recipe wires the full flow into an MCP SaaS, from product setup to success redirect to customer portal.
Schritt 1: Install the Stripe SDK + a setup script
npm install stripe
Then write a script that creates products + prices idempotently. Anti-pattern is clicking through the Stripe dashboard, you cannot reproduce it in a fresh account.
// scripts/stripe-setup.mjs
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2025-09-30.clover',
});
// metadata.app keeps your products separate from any other Stripe usage on the account
const PRODUCTS = [
{ key: 'pro_monthly', name: 'My SaaS Pro (Monthly)', amount: 1900, recurring: 'month' },
{ key: 'pro_yearly', name: 'My SaaS Pro (Yearly)', amount: 19000, recurring: 'year' }, // ~17% off
];
async function ensureProduct(spec, existing) {
const found = existing.find((p) => p.metadata?.tier === spec.key);
if (found) return found;
return stripe.products.create({
name: spec.name,
metadata: { app: 'mysaas', tier: spec.key },
tax_code: 'txcd_10000000', // SaaS / Online Service (used for EU VAT)
});
}
async function ensurePrice(product, spec) {
const prices = await stripe.prices.list({ product: product.id, active: true, limit: 50 });
const match = prices.data.find((pr) =>
pr.unit_amount === spec.amount &&
pr.currency === 'eur' &&
pr.recurring?.interval === spec.recurring,
);
if (match) return match;
return stripe.prices.create({
product: product.id,
unit_amount: spec.amount,
currency: 'eur',
recurring: { interval: spec.recurring },
tax_behavior: 'inclusive',
});
}
(async () => {
const existing = (await stripe.products.list({ limit: 100, active: true }))
.data.filter((p) => p.metadata?.app === 'mysaas');
for (const spec of PRODUCTS) {
const product = await ensureProduct(spec, existing);
const price = await ensurePrice(product, spec);
console.log(`STRIPE_PRICE_${spec.key.toUpperCase()}=${price.id}`);
}
})();
Run once:
STRIPE_SECRET_KEY=sk_live_... node scripts/stripe-setup.mjs
# → STRIPE_PRICE_PRO_MONTHLY=price_1ABC...
# → STRIPE_PRICE_PRO_YEARLY=price_1XYZ...
Idempotency on metadata.tier means re-running the script is safe. Paste the output into your .env.
Schritt 2: The checkout endpoint (50 lines)
// src/api/billing-checkout.ts
import { logger } from '../lib/logger.js';
const STRIPE_API = 'https://api.stripe.com/v1';
export interface CheckoutInput {
tenantId: string;
email: string;
plan: 'monthly' | 'yearly';
baseUrl: string;
}
export async function createCheckoutSession(p: CheckoutInput): Promise<{ url: string }> {
const priceId = p.plan === 'yearly'
? process.env.STRIPE_PRICE_PRO_YEARLY
: process.env.STRIPE_PRICE_PRO_MONTHLY;
if (!priceId) throw new Error('STRIPE_PRICE_* not configured');
const params = new URLSearchParams();
params.set('mode', 'subscription');
params.set('payment_method_types[0]', 'card');
params.set('line_items[0][price]', priceId);
params.set('line_items[0][quantity]', '1');
params.set('customer_email', p.email);
params.set('client_reference_id', p.tenantId);
params.set('metadata[tenantId]', p.tenantId);
params.set('subscription_data[metadata][tenantId]', p.tenantId);
params.set('success_url', `${p.baseUrl}/billing/success?session_id={CHECKOUT_SESSION_ID}`);
params.set('cancel_url', `${p.baseUrl}/upgrade?canceled=1`);
params.set('allow_promotion_codes', 'true');
const r = await fetch(`${STRIPE_API}/checkout/sessions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
});
if (!r.ok) {
const body = await r.text();
logger.error('stripe checkout failed', { status: r.status, body });
throw new Error(`Stripe ${r.status}: ${body.slice(0, 200)}`);
}
const data = (await r.json()) as { url: string };
return data;
}
Two design choices worth defending:
fetch()instead ofStripe()SDK at the call site. The SDK is fine, but rawfetchis one less dependency in your runtime, faster cold-start, and trivially mockable in tests.client_reference_idANDmetadata[tenantId]ANDsubscription_data[metadata][tenantId]. Three places because Stripe surfaces them in different events. You will need them in webhooks (10.5).
Schritt 3: Wire the route
// src/server.ts (HTTP mode)
app.post('/api/billing/checkout', requireAuth, async (req, res) => {
const tenant = req.tenant; // set by requireAuth middleware
const { plan } = req.body as { plan?: 'monthly' | 'yearly' };
if (plan !== 'monthly' && plan !== 'yearly') {
return res.status(400).json({ error: 'plan must be monthly or yearly' });
}
try {
const session = await createCheckoutSession({
tenantId: tenant.id,
email: tenant.email,
plan,
baseUrl: process.env.BASE_URL!,
});
res.json({ url: session.url });
} catch (err) {
logger.error('checkout endpoint failed', { tenantId: tenant.id, err: String(err) });
res.status(500).json({ error: 'checkout failed' });
}
});
The frontend then does:
<button onclick="upgrade('monthly')">Upgrade to Pro €19/mo</button>
<script>
async function upgrade(plan) {
const r = await fetch('/api/billing/checkout', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + apiKey, 'Content-Type': 'application/json' },
body: JSON.stringify({ plan }),
});
const { url } = await r.json();
window.location.href = url;
}
</script>
Schritt 4: Handle the success_url redirect
After payment, Stripe redirects to BASE_URL/billing/success?session_id=cs_test_.... Do not trust this redirect for the entitlement, webhooks (10.5) are the source of truth. Use the success page only to show "we're processing your subscription, refresh in a few seconds":
app.get('/billing/success', async (req, res) => {
const sessionId = req.query.session_id as string | undefined;
if (!sessionId) return res.redirect('/upgrade?error=missing_session');
res.send(`
<h1>Almost there!</h1>
<p>Stripe is finalizing your subscription. This usually takes 5-30 seconds.</p>
<p>Refresh this page when you receive the welcome email, or <a href="/dashboard">go to your dashboard</a>.</p>
<script>setTimeout(() => location.href = '/dashboard', 8000);</script>
`);
});
The webhook handler (10.5) is what actually sets tenants.plan = 'pro'.
Schritt 5: Customer portal (self-service cancel/update)
For paid customers to manage their own subscription (change card, cancel, update billing email), Stripe gives you a hosted portal. Don't build this yourself.
app.post('/api/billing/portal', requireAuth, async (req, res) => {
const tenant = req.tenant;
if (!tenant.stripe_customer_id) {
return res.status(400).json({ error: 'no Stripe customer for this tenant' });
}
const params = new URLSearchParams();
params.set('customer', tenant.stripe_customer_id);
params.set('return_url', `${process.env.BASE_URL}/dashboard`);
const r = await fetch('https://api.stripe.com/v1/billing_portal/sessions', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
});
const { url } = (await r.json()) as { url: string };
res.json({ url });
});
Configure the portal once in the Stripe dashboard: Settings → Billing → Customer portal. Enable subscription cancellation, payment method update, billing history. Save. Done.
Schritt 6: Test in Stripe test mode
Switch STRIPE_SECRET_KEY to a sk_test_... key. Use the Stripe test cards: 4242 4242 4242 4242 for success, 4000 0000 0000 9995 for insufficient funds, any future date and CVC.
End-to-end:
- POST
/api/billing/checkout→ geturl - Open
urlin a browser → fill in test card → "Subscribe" - Stripe redirects to
/billing/success?session_id=... - Webhook fires (10.5 wires this) →
tenants.plan = 'pro' - Refresh dashboard → tenant is on Pro
Schritt 7: Verify
Run academy_validate_step. The validator checks package.json has bin or main, the routes are an HTTP-mode concern.
For the Stripe-specific checks: open the Stripe dashboard, confirm one product per metadata.tier, one active price per product, one webhook endpoint pointing at your BASE_URL/api/billing/webhook (10.5 wires the receiver).
Common traps
tax_behavior: 'inclusive'vs'exclusive', pick at price creation; you cannot change it later. Inclusive is what most EU SaaS show ("€19 incl. VAT"); exclusive is what most US SaaS show ("$19 + tax").- Testing with live keys, every checkout becomes a real €19 charge. Always switch to test keys for development.
- Forgetting
client_reference_id, webhooks have no easy way to know which tenant the checkout belonged to. Set it on every checkout. - Skipping the customer portal, every "how do I cancel?" email is your fault, not Stripe's. The portal takes 5 minutes to enable.
- Hardcoding amounts in the code, keep them in
STRIPE_PRICE_*env vars so dashboard pricing changes don't require redeploys.
What good looks like
One npm script creates everything (idempotent). One /api/billing/checkout POST returns a URL. One /billing/success page handles the redirect with a "we're processing" message. One /api/billing/portal POST returns a portal URL. Webhooks (10.5) update the tenant's plan column. Total code: maybe 150 lines.
If you find yourself building forms to enter card details, you took a wrong turn. Stripe Checkout is for that.