This document outlines the comprehensive plan for integrating Stripe payments with SynthStack, including user authentication via Supabase, subscription management, and credit-based usage.
┌─────────────────────────────────────────────────────────────────────┐
│ Frontend (Vue/Quasar) │
├─────────────────────────────────────────────────────────────────────┤
│ Auth Flow │ Subscription Flow │ Credit Flow │ Community │
│ - Supabase │ - Pricing Page │ - Usage │ - Uploads │
│ - OAuth │ - Checkout │ - Purchase │ - Moderation │
└───────┬───────┴─────────┬───────────┴───────┬───────┴───────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ API Gateway (Fastify) │
├─────────────────────────────────────────────────────────────────────┤
│ /auth/* │ /billing/* │ /credits/* │ /community/* │
│ - Login │ - Create checkout │ - Use │ - Upload │
│ - Register │ - Webhook handler │ - Purchase │ - Moderate │
│ - OAuth CB │ - Portal session │ - Balance │ - Reports │
└───────┬───────┴─────────┬───────────┴───────┬───────┴───────────────┘
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌─────────────────────────────────┐
│ Supabase │ │ Stripe │ │ PostgreSQL │
│ - Auth │ │ - Checkout │ │ - Users │
│ - OAuth │ │ - Webhooks │ │ - Subscriptions │
│ - Sessions │ │ - Portal │ │ - Credits │
└───────────────┘ │ - Invoices │ │ - Community uploads │
└───────────────┘ │ - Moderation logs │
└─────────────────────────────────┘
Current API env vars and tiers:
| Tier | Env Var | Example Price ID | Credits/Day | Price |
|---|---|---|---|---|
| Free | - | - | 10 | $0 |
| Maker | STRIPE_PRICE_MAKER |
price_1SmoJoCBrYnyjAOOrEyKLXgz | 30 | $12.99/mo ($116.91/yr) |
| Pro | STRIPE_PRICE_PRO |
price_1SmoyiCBrYnyjAOOTZbX7tpl | 100 | $24.99/mo ($224.91/yr) |
| Agency | STRIPE_PRICE_AGENCY |
price_1Smp4ZCBrYnyjAOOlCWqbRrs | 500 | $39.99/mo ($359.91/yr) |
Note: the marketing tier Agency is stored as
subscription_tier='unlimited'in the database. The API may accept eitheragencyorunlimitedand normalizes to the DB-safe value.
# Stripe
STRIPE_SECRET_KEY=YOUR_STRIPE_SECRET_KEY
STRIPE_PUBLISHABLE_KEY=YOUR_STRIPE_PUBLISHABLE_KEY
STRIPE_WEBHOOK_SECRET=YOUR_STRIPE_WEBHOOK_SECRET
# Stripe Price IDs - Monthly (used by API Gateway)
STRIPE_PRICE_MAKER=price_1SmoJoCBrYnyjAOOrEyKLXgz
STRIPE_PRICE_PRO=price_1SmoyiCBrYnyjAOOTZbX7tpl
STRIPE_PRICE_AGENCY=price_1Smp4ZCBrYnyjAOOlCWqbRrs
# Stripe Price IDs - Yearly (25% discount)
STRIPE_PRICE_MAKER_YEARLY=price_1SmorLCBrYnyjAOObE3vITjH
STRIPE_PRICE_PRO_YEARLY=price_1SmozfCBrYnyjAOOFVksu8TN
STRIPE_PRICE_AGENCY_YEARLY=price_1Smp9NCBrYnyjAOOnSZQW843
# Lifetime License (one-time purchase with GitHub repo access)
STRIPE_PRICE_LIFETIME=price_1SmmNGCBrYnyjAOOjpcxHmRG
STRIPE_PROMO_EARLYCODE=EARLYCODE
# GitHub Organization Management (for lifetime license buyers)
GITHUB_ORG_NAME=manicinc
GH_PAT=YOUR_GITHUB_PAT # PAT with admin:org + repo scopes
GITHUB_TEAM_SLUG=synthstack-pro
GITHUB_PRO_REPO=manicinc/synthstack-pro
# Supabase
SUPABASE_URL=https://xxx.supabase.co
SUPABASE_ANON_KEY=eyJ...
SUPABASE_SERVICE_KEY=eyJ...
# Frontend
VITE_STRIPE_PUBLISHABLE_KEY=YOUR_STRIPE_PUBLISHABLE_KEY
VITE_SUPABASE_URL=https://xxx.supabase.co
VITE_SUPABASE_ANON_KEY=eyJ...- Sign Up → Create user in Supabase → Create user row in PostgreSQL → Set default tier
- Sign In → Supabase session → Fetch user profile + subscription from PostgreSQL
- OAuth (Google/GitHub) → Supabase handles → Create/fetch user profile
-- Trigger to sync Supabase auth.users to our app_users table
CREATE OR REPLACE FUNCTION sync_user_from_auth()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.app_users (id, email, display_name, subscription_tier, credits_remaining)
VALUES (
NEW.id,
NEW.email,
COALESCE(NEW.raw_user_meta_data->>'display_name', split_part(NEW.email, '@', 1)),
'free',
10
)
ON CONFLICT (id) DO UPDATE SET
email = EXCLUDED.email,
updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;// POST /api/v1/billing/checkout
// Body: { tier: 'maker' | 'pro' | 'agency', isYearly?: boolean, promoCode?: string }
async function createCheckout(userId: string, tier: string, isYearly = false, promoCode?: string) {
const session = await stripeService.createCheckoutSession({
userId,
email: user.email,
tier,
isYearly,
promoCode,
trialDays: 3, // ← Free trial before first charge (0 to disable)
})
return session.url
}Important: Trial periods are configured in code, NOT in the Stripe Dashboard.
| Setting | Location | Default | Notes |
|---|---|---|---|
trialDays |
billing.ts line 335 |
3 days | Passed to Stripe at checkout |
| Trial ending email | customer.subscription.trial_will_end webhook |
3 days before end | Sends reminder email |
To change the trial period:
// packages/api-gateway/src/routes/billing.ts
const session = await stripeService.createCheckoutSession({
userId,
email,
tier: tier as SubscriptionTier,
isYearly,
promoCode,
trialDays: 3, // ← Change this value (0 to disable trials)
});How trials work:
- Customer subscribes → Card is validated but NOT charged
- Subscription status =
trialingfor the trial period - After trial ends → Stripe automatically charges the card
- Subscription status =
active
Trial behaviors:
- No payment collected during trial (card is authorized only)
- User has full access to paid features during trial
customer.subscription.trial_will_endwebhook fires 3 days before end- If payment fails after trial, status becomes
past_due
### Webhook Events to Handle
| Event | Action |
|-------|--------|
| `checkout.session.completed` | Create subscription, update user tier, add credits |
| `customer.subscription.updated` | Update tier if plan changed |
| `customer.subscription.deleted` | Downgrade to free tier, reset credits |
| `invoice.payment_succeeded` | Reset monthly credits, log transaction |
| `invoice.payment_failed` | Mark subscription as past_due, send email |
| `customer.subscription.trial_will_end` | (Optional) notify user of trial ending |
### Customer Portal
```typescript
// POST /api/v1/billing/portal
async function createPortalSession(userId: string) {
const user = await getUser(userId);
const session = await stripe.billingPortal.sessions.create({
customer: user.stripe_customer_id,
return_url: `${FRONTEND_URL}/app/subscription`,
});
return session.url;
}
Credits are configured in the API gateway tier config (packages/api-gateway/src/services/stripe.ts).
| Tier | Credits/Day | Max File Size |
|---|---|---|
| Free | 10 | 10 MB |
| Maker | 30 | 50 MB |
| Pro | 100 | 200 MB |
Agency (DB: unlimited) |
500 | 500 MB |
// Deduct credit for generation
async function useCredit(userId: string, amount: number = 1) {
// Check balance
const user = await getUser(userId);
// Check daily reset
if (new Date() > user.credits_reset_at) {
await resetDailyCredits(userId);
}
if (user.credits_remaining < amount) {
throw new InsufficientCreditsError();
}
// Deduct and log
await db.query(`
UPDATE app_users SET credits_remaining = credits_remaining - $1, lifetime_credits_used = lifetime_credits_used + $1
WHERE id = $2
`, [amount, userId]);
await logCreditTransaction(userId, -amount, 'generation');
}Directus automatically creates an admin UI for these tables:
users- View/edit users, subscription status, ban usersuploaded_models- View uploads, approve/reject, flag contentcommunity_model_metadata- Edit model detailscomments- Moderate comments, hide/deletemoderation_reports- Review and resolve reportsmoderation_log- Audit trail of all actionscreator_profiles- Verify creators, edit profiles
- Admin - Full access to everything
- Moderator - Can moderate content, resolve reports, but cannot edit users
- Support - Can view users and content, but cannot take action
- Content flagged by AI or user report
- Appears in
moderation_reports(status: pending) - Moderator reviews in Directus
- Takes action: approve, remove, warn, ban
- Action logged in
moderation_log
- Install Stripe SDK:
pnpm add stripe - Create
/billing/checkoutendpoint - Create
/webhooks/stripeendpoint - Create
/billing/portalendpoint - Create
/billing/subscriptionendpoint - Implement credit deduction middleware
- Add daily credit reset cron job
- Set up Supabase JWT verification
- Create user sync trigger
- Install Stripe.js:
pnpm add @stripe/stripe-js - Update PricingPage with Stripe checkout buttons
- Add subscription management in AccountPage
- Show credit balance in app header
- Handle successful subscription redirect
- Add "Manage Subscription" button (Portal)
- Create Products (Basic, Pro, Enterprise)
- Create Prices (monthly + yearly for each)
- Configure Customer Portal
- Set up Webhook endpoint
- Enable test mode for development
- Configure collections permissions
- Create Admin role
- Create Moderator role
- Set up webhook for report notifications
- Configure email templates
| Scenario | Card Number |
|---|---|
| Success | 4242 4242 4242 4242 |
| Decline | 4000 0000 0000 0002 |
| Requires Auth | 4000 0025 0000 3155 |
| Insufficient Funds | 4000 0000 0000 9995 |
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Forward webhooks to local
stripe listen --forward-to localhost:3003/api/v1/subscriptions/webhook
# Trigger test events
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed- Use Stripe test keys and test price IDs (prefix
price_). - Point Stripe webhook to dev API:
http://localhost:3003/api/v1/webhooks/stripe. - Stripe CLI forwarding:
stripe listen --forward-to localhost:3003/api/v1/webhooks/stripe. - For portal sessions, set
return_urlto your local app (e.g.,http://localhost:3050/app/subscription).
- Webhook Signature Verification - Always verify
stripe-signatureheader - Idempotency - Handle duplicate webhook events gracefully
- User Verification - Verify user owns the subscription being modified
- Rate Limiting - Limit checkout creation to prevent abuse
- Credit Validation - Always check credits before expensive operations
Set up alerts for:
- Failed webhook deliveries
- High rate of failed payments
- Unusual credit usage patterns
- Moderation queue backlog
- User reports spike
- Usage-based billing for API access
- Team/organization subscriptions
- Annual plan discounts
- Referral credits
- Creator revenue sharing
- Tip jar for creators
SynthStack supports one-time lifetime license purchases with automatic GitHub repository access provisioning. When a customer purchases a lifetime license, they receive:
- Welcome email with GitHub username submission link
- Automated GitHub organization invitation
- Read access to the private
manicinc/synthstack-prorepository - Lifetime updates via
git pull origin master
Purchase → Stripe Checkout → Webhook → License Record Created
↓
Welcome Email with Access Link
↓
Customer Submits GitHub Username → Validates via GitHub API
↓
GitHub Org Invitation Sent → Invitation Email
↓
Customer Accepts Invite → Access Granted Email
↓
Customer Clones Repo & Starts Building 🚀
-- Created via migration: services/directus/migrations/122_lifetime_license_github_access.sql
CREATE TABLE lifetime_licenses (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Purchase info
stripe_session_id VARCHAR(255) UNIQUE NOT NULL,
stripe_customer_id VARCHAR(255),
email VARCHAR(255) NOT NULL,
amount_paid_cents INT NOT NULL,
purchased_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
-- GitHub access
github_username VARCHAR(100),
github_username_submitted_at TIMESTAMPTZ,
github_invitation_sent_at TIMESTAMPTZ,
github_invitation_accepted_at TIMESTAMPTZ,
github_access_status VARCHAR(50) DEFAULT 'pending'
CHECK (github_access_status IN ('pending', 'username_submitted', 'invited', 'active', 'revoked')),
-- Onboarding
welcome_email_sent_at TIMESTAMPTZ,
access_email_sent_at TIMESTAMPTZ,
onboarding_completed_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);// POST /api/v1/billing/lifetime-checkout
async function createLifetimeLicenseCheckout(promoCode?: string) {
let priceId = process.env.STRIPE_PRICE_LIFETIME!;
// Apply promo code for early bird pricing
const promotionCode = promoCode === 'EARLYSYNTH'
? await getStripePromotionCode('EARLYSYNTH')
: undefined;
const session = await stripe.checkout.sessions.create({
mode: 'payment', // One-time payment, not subscription
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${FRONTEND_URL}/?license=success&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${FRONTEND_URL}/?license=cancelled`,
metadata: {
type: 'lifetime_license', // Critical for webhook routing
},
...(promotionCode && { discounts: [{ promotion_code: promotionCode.id }] }),
});
return session.url;
}When checkout.session.completed fires with metadata.type === 'lifetime_license':
// In stripe-webhooks.ts
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
if (session.metadata?.type === 'lifetime_license') {
const { customer_email, id: sessionId, amount_total } = session;
// 1. Create license record
await db.query(`
INSERT INTO lifetime_licenses (
stripe_session_id,
stripe_customer_id,
email,
amount_paid_cents,
github_access_status
) VALUES ($1, $2, $3, $4, 'pending')
ON CONFLICT (stripe_session_id) DO NOTHING
`, [sessionId, session.customer, customer_email, amount_total]);
// 2. Send welcome email with license access link
const emailService = getEmailService();
await emailService.sendLifetimeWelcomeEmail({
to: customer_email!,
sessionId,
licenseAccessUrl: `${FRONTEND_URL}/license-access?session=${sessionId}`,
});
// 3. Update email sent timestamp
await db.query(`
UPDATE lifetime_licenses
SET welcome_email_sent_at = NOW()
WHERE stripe_session_id = $1
`, [sessionId]);
fastify.log.info({ email: customer_email, sessionId }, 'Lifetime license purchased');
}
}API Route: /api/v1/license-access/submit-username
// POST /api/v1/license-access/submit-username
async function submitGithubUsername(sessionId: string, githubUsername: string) {
// 1. Validate license exists
const license = await db.query(
'SELECT id, email FROM lifetime_licenses WHERE stripe_session_id = $1',
[sessionId]
);
if (!license.rows.length) {
throw new NotFoundError('License not found');
}
// 2. Validate GitHub username exists
const githubService = new GitHubOrgService(fastify);
const validation = await githubService.validateUsername(githubUsername);
if (!validation.valid) {
throw new BadRequestError(validation.error || 'Invalid GitHub username');
}
// 3. Update license with username
await db.query(`
UPDATE lifetime_licenses
SET github_username = $1,
github_username_submitted_at = NOW(),
github_access_status = 'username_submitted'
WHERE stripe_session_id = $2
`, [githubUsername, sessionId]);
// 4. Send GitHub invitation
const { email } = license.rows[0];
const invitation = await githubService.inviteToOrganization(githubUsername, email);
if (!invitation.success) {
throw new InternalServerError('Failed to send GitHub invitation');
}
// 5. Update status and send confirmation email
await db.query(`
UPDATE lifetime_licenses
SET github_access_status = 'invited',
github_invitation_sent_at = NOW()
WHERE stripe_session_id = $1
`, [sessionId]);
await emailService.sendLifetimeInvitationSentEmail({
to: email,
githubUsername,
});
return { success: true, message: 'GitHub invitation sent!' };
}Service: packages/api-gateway/src/services/github-org.ts
import { Octokit } from '@octokit/rest';
export class GitHubOrgService {
private octokit: Octokit;
private orgName = process.env.GITHUB_ORG_NAME || 'manicinc';
private teamSlug = process.env.GITHUB_TEAM_SLUG || 'synthstack-pro';
constructor(fastify: FastifyInstance) {
this.octokit = new Octokit({
auth: process.env.GH_PAT
});
}
async validateUsername(username: string) {
try {
await this.octokit.users.getByUsername({ username });
return { valid: true };
} catch (error: any) {
if (error.status === 404) {
return { valid: false, error: 'GitHub username not found' };
}
return { valid: false, error: 'Failed to validate username' };
}
}
async inviteToOrganization(username: string, email: string) {
try {
const userId = await this.getUserId(username);
const teamId = await this.getTeamId();
await this.octokit.orgs.createInvitation({
org: this.orgName,
invitee_id: userId,
role: 'member', // Read-only access
team_ids: [teamId],
});
return { success: true };
} catch (error: any) {
return { success: false, error: error.message };
}
}
async checkMembershipStatus(username: string) {
try {
await this.octokit.orgs.checkMembershipForUser({
org: this.orgName,
username,
});
return 'active';
} catch (error: any) {
if (error.status === 404) {
const invitations = await this.octokit.orgs.listPendingInvitations({
org: this.orgName,
});
const hasPending = invitations.data.some(
inv => inv.login?.toLowerCase() === username.toLowerCase()
);
return hasPending ? 'pending' : 'none';
}
throw error;
}
}
private async getUserId(username: string): Promise<number> {
const { data } = await this.octokit.users.getByUsername({ username });
return data.id;
}
private async getTeamId(): Promise<number> {
const { data } = await this.octokit.teams.getByName({
org: this.orgName,
team_slug: this.teamSlug,
});
return data.id;
}
}Three email templates are sent during the access flow:
- Welcome Email (
lifetime-welcome.ts) - Sent immediately after purchase - Invitation Sent Email (
lifetime-invitation-sent.ts) - After GitHub invitation sent - Access Granted Email (
lifetime-access-granted.ts) - After invitation accepted
All templates are located in: packages/api-gateway/src/services/email/templates/
Page: apps/web/src/pages/LicenseAccess.vue
The license access portal provides a step-by-step UI for:
- Entering GitHub username
- Viewing invitation status
- Confirming invitation acceptance
- Displaying repository clone instructions
Access via: https://synthstack.app/license-access?session={CHECKOUT_SESSION_ID}
Before deploying this feature, complete these manual steps:
-
Create GitHub Team
- Go to https://github.com/orgs/manicinc/teams
- Create team: "synthstack-pro"
- Visibility: Secret
- Grant Read access to
synthstack-prorepository
-
Generate GitHub PAT
- Go to https://github.com/settings/tokens/new
- Scopes:
admin:org,repo - Set as
GH_PATenvironment variable
-
Configure Stripe Product
- Create "SynthStack Lifetime License" product
- Create one-time payment price
- Set as
STRIPE_PRICE_LIFETIMEenvironment variable
-
Test End-to-End
- Complete test purchase with Stripe test card
- Verify welcome email received
- Submit test GitHub username
- Accept invitation
- Clone repository
Key Metrics:
- Total lifetime licenses sold
- Conversion rate (invited → active)
- Average time to accept invitation
- Stuck licenses (pending > 24h, invited > 7 days)
Admin Queries:
-- View all licenses
SELECT email, github_username, github_access_status, purchased_at
FROM lifetime_licenses
ORDER BY purchased_at DESC;
-- Find stuck licenses
SELECT * FROM lifetime_licenses
WHERE github_access_status = 'invited'
AND github_invitation_sent_at < NOW() - INTERVAL '7 days';Customer Support:
For common issues (invitation not received, changed username, etc.), see the internal operations guide:
docs/internal/LIFETIME_LICENSE_OPERATIONS.md
- GitHub PAT Security: Store in environment variables, rotate every 6-12 months
- Webhook Validation: Always verify Stripe signature
- Rate Limiting: Limit username submission attempts
- Idempotency: Handle duplicate webhook events gracefully
- Customer Guide:
docs/guides/LIFETIME_LICENSE_GETTING_STARTED.md - Internal Ops Guide:
docs/internal/LIFETIME_LICENSE_OPERATIONS.md - Pricing & Features:
docs/PRICING_AND_FEATURES.md
- API docs: Swagger UI at
/docs, OpenAPI at/openapi.jsonand/openapi.yaml - Admin CMS:
docs/ADMIN_CMS.md - Pricing/plan docs: see
README.mdand this file for env mapping