Connecting Stripe
Everything needed to take payments in MagicSync. Work through it top to bottom; step 7 is the one that is easy to forget.
The billing feature is optional and inert without keys. If you skip this whole page the app still runs:
/app/billingrenders its "not configured" state and nothing else changes.
1. Stripe account and mode
- Create a Stripe account: https://dashboard.stripe.com/register.
- Stay in Test mode until the last step. Every key and price id below has a test-mode twin; swapping to live mode means redoing steps 2–5 with live values.
- Note your mode: a test
sk_test_…key with a liveprice_…id fails at checkout withNo such price.
2. Create the products and prices
In Product catalogue → Add product, create one product per plan:
| Plan | Name | Price | Billing | Env var |
|---|---|---|---|---|
| Team | MagicSync Team | 12.00 USD | Recurring, monthly, per unit | STRIPE_PRICE_TEAM |
| Business | MagicSync Business | 24.00 USD | Recurring, monthly, per unit | STRIPE_PRICE_BUSINESS |
- Per unit is what makes "per member" work. The billing page sets
seats, and Stripe multiplies the unit price by that quantity. - After saving, open the price, click Copy price ID (
price_1…) and keep it. - Annual billing is not wired yet: the app reads one price id per plan. Add
annualDiscountPriceIdinpackages/auth/lib/auth.tsif you want it.
3. Environment variables
Add these to .env (never commit it):
# Stripe secret key — the plugin is only registered when this is present.
STRIPE_SECRET_KEY="sk_test_…"
# From the webhook endpoint you create in step 4.
STRIPE_WEBHOOK_SECRET="whsec_…"
# Price ids from step 2.
STRIPE_PRICE_TEAM="price_…"
STRIPE_PRICE_BUSINESS="price_…"Restart the app. packages/auth/lib/auth.ts registers the Stripe plugin only when both STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET are set, so a half-configured deployment stays inert instead of half-working.
4. Webhook endpoint
Developers → Webhooks → Add endpoint.
Endpoint URL:
https://<your-domain>/api/auth/stripe/webhook(locally:stripe listenin step 9 gives you one).Subscribe to these events — without them subscriptions exist in Stripe but the app never learns about them:
Event What it drives checkout.session.completedThe subscription created at checkout customer.subscription.createdSubscriptions created outside checkout (e.g. the dashboard) customer.subscription.updatedPlan changes, renewals, cancellation scheduled customer.subscription.deletedCancellation taking effect Copy the endpoint's Signing secret (
whsec_…) intoSTRIPE_WEBHOOK_SECRET.
Signature verification is done by the plugin; a forged webhook is rejected before it reaches any handler.
5. Customer portal
Manage billing, Update (payment method) and Edit (address) all open the Stripe customer portal, so it must be configured or those buttons land on an error page:
- Settings → Billing → Customer portal.
- Enable: Customers can update payment methods, Invoice history, Cancel subscriptions.
- Add the fields the Payment tab displays: billing address, tax ID.
- Save. No code change is needed — the app already asks for those capabilities.
6. Migrate the database
The plugin needs the subscription table and two stripe_customer_id columns:
cd packages/db
pnpm db:migrate # production / configured database
pnpm db:migrate:local # uses ../../.envMigration 0012_complete_blonde_phantom.sql is the one that adds them. Skipping this step is the single most common failure: the Stripe plugin writes a subscription row on checkout and the write fails.
7. How access works (do not skip)
Two rules are enforced in packages/billing/server/utils/entitlement.ts, always server-side, never from anything the browser sends:
- Admins never pay. A user whose
roleisadminis entitled unconditionally; the subscription lookup is skipped entirely, so admin access does not depend on Stripe being configured. - Every new account gets a 10-day free trial.
TRIAL_DAYS = 10, counted from the account'screatedAt. Nothing to create, nothing to expire: the window is derived, so it cannot drift from the account record.
A paid subscription always wins over the trial. The resolved states are admin, active, trialing, trial and expired, and the billing page shows a banner for whichever applies.
Enforcement is off by default. Turn it on deliberately:
BILLING_ENFORCE_ACCESS="true"
# or override at runtime, which is what a container needs:
NUXT_PUBLIC_BILLING_ENFORCE="true"With it on, packages/billing/app/middleware/05.billing-access.global.ts redirects an expired account to /app/billing (page middleware is skipped there and on /app/profile, so a blocked user can still pay or sign out, and a failed lookup fails open rather than locking someone out).
Why the switch: inverting this default would lock out every account that signed up before billing existed. Enable it once you are ready for that.
8. Test the flow
- Sign in as a normal user;
/app/billingshows the trial banner with the days left. - Upgrade on Team → Stripe Checkout → pay with
4242 4242 4242 4242, any future expiry, any CVC. - You land back on
/app/billingwith the banner reading "Your subscription is active" and the Plan tab showing Team as current. - View invoices lists the first invoice; Update opens the customer portal.
- Cancel in the portal; the webhook flips the state back to
trial/expired.
9. Local development
stripe login
stripe listen --forward-to localhost:3000/api/auth/stripe/webhookstripe listen prints a whsec_… for the current session — use it as STRIPE_WEBHOOK_SECRET locally. Trigger events without a browser with stripe trigger customer.subscription.updated.
10. Final checklist
- [ ] Two products, both per unit, monthly recurring
- [ ]
STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET,STRIPE_PRICE_TEAM,STRIPE_PRICE_BUSINESSset, app restarted - [ ] Webhook endpoint live with the four subscription events
- [ ] Customer portal: payment methods, invoices, cancellation, address, tax ID enabled
- [ ]
pnpm db:migraterun - [ ] Admin account shows "billing is not required"
- [ ] A brand-new account shows a trial with days left
- [ ] Test purchase completes and returns to
/app/billingasactive - [ ]
BILLING_ENFORCE_ACCESSset only when you mean to block