---
title: "Stripe Checkout, subscription mode end-to-end | Recipe — StudioMeyer Academy"
description: "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."
canonical: "https://studiomeyer.academy/en/recipes/10.1-stripe-checkout"
language: en
markdown_versions: ["https://studiomeyer.academy/en/recipes/10.1-stripe-checkout.md"]
publisher: "StudioMeyer Academy, https://studiomeyer.academy (llms.txt: https://studiomeyer.academy/llms.txt)"
---

\\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](https://studiomeyer.academy/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](https://studiomeyer.academy/login?next=/recipes/10.1-stripe-checkout) 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.

### Step 1: Install the Stripe SDK + a setup script

```bash
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.

```javascript
// 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:

```bash
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`.

### Step 2: The checkout endpoint (50 lines)

```typescript
// 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).

### Step 3: Wire the route

```typescript
// 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:

```html
<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>
```

### Step 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":

```typescript
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'`.

### Step 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.

```typescript
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.

### Step 6: Test in Stripe test mode

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.

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

### Step 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.

[← Breaking changes, evolving a p](https://studiomeyer.academy/recipes/9.6-breaking-changes) [Magic-link mail. Brevo SMTP, s →](https://studiomeyer.academy/recipes/10.2-magic-link-mail)

Phase 10

Stripe billing, magic-link mail, Supabase basics

[Alle Phasen →](https://studiomeyer.academy/recipes)

Concepts

[Multi-tenant SaaS](https://studiomeyer.academy/concepts/multi_tenant) [Stripe Checkout](https://studiomeyer.academy/concepts/stripe_checkout)

## Sitemap

Public pages of this site as Markdown: [sitemap](https://studiomeyer.academy/sitemap.md). Curated entry point: [llms.txt](https://studiomeyer.academy/llms.txt), full text: [llms-full.txt](https://studiomeyer.academy/llms-full.txt).
