Billing Setup
Wire Stripe to the Zutra pricing page — products, prices, and the customer portal.
Overview
Zutra uses Stripe Payment Links as the integration model — no SDK, no server-side session management. Each plan in src/config/pricing.ts has a ctaHref that points to a Stripe Payment Link URL. Click “Start 14-day trial” on the Pro card → user lands on Stripe’s hosted checkout → comes back to /success after paying.
This is intentional: Payment Links let you launch billing in 5 minutes without writing webhook handlers or managing subscription state. When you outgrow it (custom trials, metered usage, dunning flows), replace the ctaHref with a call to your own /api/checkout route.
1. Get your Stripe keys
In Stripe Dashboard → Developers → API keys:
# .env
PUBLIC_STRIPE_KEY=pk_live_... # safe to expose to the browser
STRIPE_SECRET_KEY=sk_live_... # server-side only — never PUBLIC_
Use pk_test_ and sk_test_ keys during development.
2. Create products and prices
In Stripe Dashboard → Products:
- Click Add product
- Name it (e.g. “Pro Plan”)
- Add two recurring prices:
- One monthly (e.g. $49/month)
- One annual (e.g. $39/month, billed yearly)
- Toggle Free trial period to 14 days if you want a trial
- Save
Repeat for each plan in src/config/pricing.ts (Starter is typically free, so it skips Stripe).
3. Create Payment Links
For each paid product:
- In the product page, click Create payment link
- Configure:
- Quantity: 1
- Collect billing address: yes (required for tax)
- After payment → redirect to
https://yourdomain.com/success?plan=pro
- Click Create link
- Copy the URL — it starts with
https://buy.stripe.com/...
4. Wire plan CTAs
Add the Payment Link URL to .env:
PUBLIC_STRIPE_PRO_LINK=https://buy.stripe.com/xxxxxxx
Then in src/config/pricing.ts, the Pro plan already references this variable:
{
id: "pro",
cta: "Start 14-day trial",
ctaHref: import.meta.env.PUBLIC_STRIPE_PRO_LINK ?? `${SITE.appUrl}/signup?plan=pro`,
}
The ?? fallback means the build works even if the env var is missing — the button just sends users to your signup page instead of Stripe.
To add Payment Links for other plans:
PUBLIC_STRIPE_STARTER_LINK=https://buy.stripe.com/xxxxxxx
PUBLIC_STRIPE_ENTERPRISE_LINK=https://buy.stripe.com/xxxxxxx
Then add matching fields to Plan interface in src/config/pricing.ts and reference them in each plan’s ctaHref.
5. The /success page
src/pages/success.astro renders after Stripe redirects back. It uses the SuccessReceipt component which:
- Reads the
?plan=proquery param to show which plan was bought - Displays an order ID, billing date, and “Go to Dashboard” CTA
- Renders correctly even if the user never paid (it’s just a static receipt UI)
To make it dynamic (show real receipt data), pass the Stripe session_id query param to your backend, fetch the session via the Stripe API, and render the real values.
6. Customer Portal
For self-serve subscription management (cancel, update card, download invoices):
- In Stripe Dashboard → Settings → Billing → Customer Portal, click Activate
- Configure the branding and policies you want
- Copy the portal URL — it starts with
https://billing.stripe.com/p/login/... - Add to
.env:
PUBLIC_STRIPE_PORTAL_URL=https://billing.stripe.com/p/login/xxxxxxx
- Wire it to your “Manage billing” button — typically in
src/pages/dashboard/settings.astroor a billing settings sub-page.
7. Webhooks (optional, for advanced flows)
If you eventually need to react to subscription lifecycle events (renewal, cancellation, payment failure) — for example to flip a subscription_status column in your database — add a webhook endpoint:
- In Stripe Dashboard → Developers → Webhooks → Add endpoint
- URL:
https://yourdomain.com/api/webhooks/stripe - Events to listen for:
checkout.session.completedcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_failed
- Copy the signing secret to
.env:
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxx
- Create
src/pages/api/webhooks/stripe.tsto verify and route events. The project doesn’t ship this file — it’s intentionally left to you when you need it. A minimal handler:
import type { APIRoute } from 'astro';
import Stripe from 'stripe';
const stripe = new Stripe(import.meta.env.STRIPE_SECRET_KEY);
export const POST: APIRoute = async ({ request }) => {
const sig = request.headers.get('stripe-signature')!;
const body = await request.text();
const event = stripe.webhooks.constructEvent(
body,
sig,
import.meta.env.STRIPE_WEBHOOK_SECRET
);
// handle event.type ...
return new Response(null, { status: 200 });
};
Production checklist
- Stripe live keys (
pk_live_,sk_live_) set in production env - Payment Link URLs set for all paid plans
-
after_paymentredirect on each Payment Link points to production/success?plan=... - Customer Portal activated and URL set
- Webhook endpoint registered (only if you need server-side events)
Next steps
- Stripe Integration — quick-reference card for the Payment Link flow
- Configuration Reference — how
PRICINGconfig drives the pricing UI - Forms & API Routes — webhook patterns and other backend routes