Recipe-Inhalt ist auf Englisch. Englisches Original lesen →
\n `);\n}"},{"@type":"HowToStep","position":5,"name":"Customer portal (self-service cancel/update)","text":"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.\n\n```typescript\napp.post('/api/billing/portal', requireAuth, async (req, res) => {\n const tenant = req.tenant;\n if (!tenant.stripe_customer_id) {\n return res.status(400).json({ error: 'no Stripe customer for this tenant' });\n }\n\n const params = new URLSearchParams();\n params.set('customer', tenant.stripe_customer_id);\n params.set('return_url', `${process.env.BASE_URL}/dashboard`);\n\n const r = await fetch('https://api.stripe.com/v1/billing_portal/sessions', {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,\n 'Content-Type': 'application/x-www-form-urlencoded',\n },\n b"},{"@type":"HowToStep","position":6,"name":"Test in Stripe test mode","text":"Switch `STRIPE_SECRET_KEY` to a `sk_test_...` key. Use the [Stripe test cards](https://docs.stripe.com/testing): `4242 4242 4242 4242` for success, `4000 0000 0000 9995` for insufficient funds, any future date and CVC.\n\nEnd-to-end:\n\n1. POST `/api/billing/checkout` → get `url`\n2. Open `url` in a browser → fill in test card → \"Subscribe\"\n3. Stripe redirects to `/billing/success?session_id=...`\n4. Webhook fires (10.5 wires this) → `tenants.plan = 'pro'`\n5. Refresh dashboard → tenant is on Pro"},{"@type":"HowToStep","position":7,"name":"Verify","text":"Run `academy_validate_step`. The validator checks `package.json` has `bin` or `main`, the routes are an HTTP-mode concern.\n\nFor 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).\n\n## Common traps\n\n- **`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\").\n- **Testing with live keys**, every checkout becomes a real €19 charge. Always switch to test keys for development.\n- **Forgetting `client_reference_id`**, webhooks have no easy way to know which tenant the checkout belong"}]}
← Alle Recipes
Phase 10 · Multi-Tenant SaaS·7 steps

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.

7 steps0%
Du liest ohne Account. Mit Login speichern wir Step-Fortschritt + Notes.

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 of Stripe() SDK at the call site. The SDK is fine, but raw fetch is one less dependency in your runtime, faster cold-start, and trivially mockable in tests.
  • client_reference_id AND metadata[tenantId] AND subscription_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:

  1. POST /api/billing/checkout → get url
  2. Open url in a browser → fill in test card → "Subscribe"
  3. Stripe redirects to /billing/success?session_id=...
  4. Webhook fires (10.5 wires this) → tenants.plan = 'pro'
  5. 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.

Writing a README that actuallyMagic-link mail. Brevo SMTP, s