Subscription Flow
Overview
Sync features in zudo-text require a Pro subscription. This document covers the authentication, payment, subscription lifecycle, and feature gating architecture.
Authentication
Provider
Better Auth mounted in sync-server for email/password identity and sessions
RS256 service JWTs for API authentication
The opaque seven-day sliding session is persisted by the client; the 15-minute service JWT is cached separately. There is no refresh-token grant. See Better Auth.
Auth Flow
User clicks "Sign In"
→ Client opens the sync-server Better Auth handoff
→ User authenticates with email and password
→ Handoff returns a single-use OTT plus the client's CSRF state
→ Client verifies the OTT and receives the sliding session token
→ Client calls GET /api/auth/token for a 15-minute service JWT
→ Frontend receives AuthState { isAuthenticated: true, user: { ... } } Auth State
The BackendAPI.auth domain manages authentication state:
interface AuthUser {
id: string;
email: string;
name: string;
picture?: string;
}
interface AuthState {
isAuthenticated: boolean;
user: AuthUser | null;
}Components subscribe to auth changes via auth.onStateChanged(). The SyncContext gates all sync operations behind isAuthenticated.
Payment
Provider
Stripe for subscription billing
Stripe Customer Portal for self-service subscription management (update card, cancel, view invoices)
Plans
| Plan | Price | Sync Access | Notes |
|---|---|---|---|
| Free | $0 | No | Default for all new accounts |
| Pro | Paid | Full | File Sync + Cloud Sync + real-time WebSocket |
Trial
30-day free trial with full Pro access
No credit card required to start
Trial starts when the user explicitly clicks "Start Trial"
Countdown visible in settings when 7 days or fewer remaining
Subscription States
type SubscriptionStatus =
| "free"
| "trial"
| "active"
| "past_due"
| "cancelled"
| "expired";
type SubscriptionPlan = "free" | "pro";| Status | Description | Sync Access |
|---|---|---|
free | Default state, no subscription | No |
trial | 30-day free trial in progress | Yes |
active | Paid subscription, billing current | Yes |
past_due | Payment failed, retry in progress | Yes (temporary) |
cancelled | Will expire at end of current billing period | No |
expired | Trial or subscription ended | No |
Subscription Info
interface SubscriptionInfo {
plan: SubscriptionPlan;
status: SubscriptionStatus;
trialStartDate: string | null;
trialEndDate: string | null;
currentPeriodEnd: string | null;
cancelAtPeriodEnd: boolean;
}State Transitions
┌─────────────────────┐
│ free │ ← default for new accounts
└──────┬──────┬───────┘
│ │
Start Trial │ │ Subscribe directly
▼ ▼
┌──────────┐ ┌──────────┐
│ trial │ │ active │◀─── payment succeeds
└────┬─────┘ └────┬──────┘
│ │
┌──────────────┤ ├──────────────┐
│ │ │ │
Trial expires Subscribe Payment fails Cancels
│ │ │ │
▼ ▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────┐
│ expired │ │ active │ │ past_due │ │ cancelled │
└──────────┘ └──────────┘ └─────┬────┘ └─────┬─────┘
│ │
Retry succeeds Period ends
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ active │ │ expired │
└──────────┘ └──────────┘Transition Details
| From | To | Trigger |
|---|---|---|
free | trial | User clicks "Start Trial" |
free | active | User subscribes directly (Stripe Checkout) |
trial | active | User subscribes during trial |
trial | expired | 30 days elapsed without subscribing |
active | past_due | Stripe payment fails (auto-retry begins) |
active | cancelled | User cancels (remains active until period end) |
past_due | active | Retry payment succeeds |
past_due | expired | All retry attempts exhausted |
cancelled | expired | Current billing period ends |
expired | active | User resubscribes |
Feature Gating
Sync Access
Sync features are available only when the subscription status grants access:
function canAccessSync(info: SubscriptionInfo): boolean {
return info.status === "trial" || info.status === "active" || info.status === "past_due";
}UI Behavior by Status
| Status | Sync Settings | Sync Operations | UI Indicators |
|---|---|---|---|
free | Hidden (shows benefits dialog) | Blocked | "Upgrade to Pro" prompt |
trial | Visible | Enabled | Trial countdown (days remaining) |
active | Visible | Enabled | "Pro" badge |
past_due | Visible | Enabled | "Payment issue" warning |
cancelled | Visible (read-only) | Disabled | "Expires on (date)" notice |
expired | Visible (read-only) | Blocked | "Resubscribe" prompt |
Benefits Dialog
When a free user attempts to access sync settings, a benefits dialog is shown instead of the sync configuration panel. The dialog highlights:
Cross-device sync with E2E encryption
Real-time collaboration via WebSocket
Offline support with automatic queue
30-day free trial, no credit card required
Trial Warning
When the trial has 7 or fewer days remaining, a warning banner appears in the sync settings section showing the remaining days and a link to subscribe.
Graceful Degradation
When a subscription expires:
All local files are preserved — nothing is deleted
Sync is disabled but previously synced data remains intact
The user can still read, edit, and manage all their local content
Re-subscribing immediately restores sync with the existing workspace
Server-Side Integration
Better Auth identity mapping
Better Auth mints sub: better_auth|<user.id>. Protected application routes resolve that exact subject against the existing users table, which remains authoritative for subscriptions and workspace ownership. A missing mapping fails closed rather than silently provisioning access. After the handoff establishes the Better Auth session, GET / returns the short-lived service JWT used by the subscription routes.
Stripe Webhook
The server listens for Stripe webhook events to update subscription status:
| Stripe Event | Action |
|---|---|
customer.subscription.created | Set status to active |
customer.subscription.updated | Update status, period dates |
customer.subscription.deleted | Set status to expired |
invoice.payment_failed | Set status to past_due |
customer.subscription.trial_will_end | Send trial ending notification |
Customer Portal
The GET /subscription/portal-url endpoint generates a Stripe Customer Portal session URL where users can:
Update payment method
View billing history
Cancel or resubscribe
Download invoices