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.isDeveloperAccount === true ||
info.status === "trial" || info.status === "active" || info.status === "past_due";
}Publish Access
Publishing supports both Free and Pro accounts. The server resolves a capability snapshot with resolveCapabilities in workers/; client capability checks only guide the UI.
Active Pro subscriptions and Pro trials with a valid end date within the three-day grace window receive Pro capabilities. Recognized expired, cancelled, past-due, and Free subscription states receive Free capabilities. Missing or malformed subscription records return 403; database failures return 503. Free publishing includes four reading themes, three picture patterns, two palettes, and required attribution. Pro unlocks additional design choices and branding controls.
An account whose users.sub matches publish-server's DEV_ACCOUNT_SUBS receives Pro capabilities. This secret is separate from sync-server's, so keep the two lists in sync. The server checks each requested design change against its capabilities, while allowing an unchanged, previously saved Pro design after a downgrade.
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