All Guides
Guide

How to Add Subscriptions to a Desktop Application

Desktop apps have unique subscription challenges: offline access, license validation, and cross-platform auth. Learn how to handle them with StackBE.

Published January 16, 2026Updated January 25, 2026

Desktop Subscriptions Are Different

Desktop applications present unique challenges for subscription billing that web apps don't face:

  • **Offline access**: Apps must work without internet
  • **License validation**: Verifying subscription without constant API calls
  • **Cross-platform**: Windows, macOS, Linux (sometimes)
  • **Update distribution**: Pushing updates outside app stores
  • **Security**: Client-side code can be decompiled
  • Whether you're building with Electron, Tauri, or native frameworks, the billing patterns are similar.

    Architecture

    Desktop App (Electron/Tauri/Native)

    ↕ API calls (when online)

    StackBE (auth, billing, entitlements)

    ↕ Payment processing

    Stripe (payment gateway)

    Local cache for offline access

    Key Design Decisions

    1. How often to validate: On launch? Daily? Every time?

    2. Offline behavior: Full access? Limited? None?

    3. License storage: Where to cache subscription data locally?

    Authentication Flow

    OAuth-Style Browser Auth

    Desktop apps typically authenticate via the system browser:

    1. App opens system browser to login URL

    2. User authenticates (magic link)

    3. Browser redirects to custom protocol handler

    4. App captures the auth token

    // Electron example
    const { shell } = require('electron');
    function startLogin() {
    // Register protocol handler
    app.setAsDefaultProtocolClient('myapp');
    // Open browser to StackBE login

    shell.openExternal(

    'https://yourapp.com/auth/desktop?redirect=myapp://auth/callback'

    );

    }
    // Handle the callback
    app.on('open-url', (event, url) => {
    const token = new URL(url).searchParams.get('token');
    if (token) {

    storeSession(token);

    checkSubscription(token);

    }
    });

    Custom Protocol Registration

    Register your app's protocol in the OS:

    Electron:

    app.setAsDefaultProtocolClient('myapp');

    macOS Info.plist:

    <key>CFBundleURLTypes</key>

    <array>

    <dict>

    <key>CFBundleURLSchemes</key>

    <array>

    <string>myapp</string>

    </array>

    </dict>

    </array>

    Subscription Validation

    Online Validation

    When the app has internet, verify subscription with StackBE:

    async function validateSubscription(token) {

    try {

    const response = await fetch('https://api.stackbe.io/v1/subscriptions/current', {

    headers: {

    'Authorization': `Bearer ${token}`,

    'X-Api-Key': API_KEY,

    'X-App-Id': APP_ID

    }
    });
    if (response.ok) {
    const subscription = await response.json();
    // Cache locally for offline use

    cacheSubscription({

    plan: subscription.plan.slug,

    status: subscription.status,

    expiresAt: subscription.currentPeriodEnd,

    validatedAt: new Date().toISOString()

    });
    return subscription;
    }
    } catch (error) {
    // Network error - use cached data
    return getCachedSubscription();
    }
    }

    Offline Validation

    When offline, use cached subscription data:

    function getCachedSubscription() {
    const cached = store.get('subscription');
    if (!cached) {
    return { plan: 'free', status: 'no_cache' };
    }
    // Check if cache is still valid
    const validatedAt = new Date(cached.validatedAt);
    const now = new Date();
    const hoursSinceValidation = (now - validatedAt) / (1000 * 60 * 60);
    // Allow offline access for up to 7 days
    if (hoursSinceValidation > 168) {
    return { plan: 'free', status: 'cache_expired' };
    }
    // Check if subscription period hasn't ended
    if (new Date(cached.expiresAt) < now) {
    return { plan: 'free', status: 'subscription_expired' };
    }
    return cached;
    }

    Validation Strategy

    async function getSubscriptionStatus(token) {
    // Try online validation first
    if (navigator.onLine) {
    const online = await validateSubscription(token);
    if (online) return online;
    }
    // Fall back to cached data
    return getCachedSubscription();
    }

    Feature Gating

    Based on Subscription

    function initializeFeatures(subscription) {
    const isPro = subscription.plan === 'pro' &&

    ['active', 'trialing'].includes(subscription.status);

    // Enable/disable menu items

    setMenuEnabled('Export PDF', isPro);

    setMenuEnabled('Batch Processing', isPro);

    setMenuEnabled('Cloud Sync', isPro);

    // Show upgrade prompt for locked features
    if (!isPro) {

    showUpgradeBar('Upgrade to Pro for unlimited access');

    }
    }

    Entitlement Checks (Online)

    async function canUseFeature(feature) {
    // Check cache first
    const cached = getCachedEntitlements();
    if (cached && cached[feature] !== undefined) {
    return cached[feature];
    }
    // Check online if available
    if (navigator.onLine) {
    const { hasAccess } = await stackbe.entitlements.check(

    customerId,

    feature

    );

    // Cache the result

    cacheEntitlement(feature, hasAccess);

    return hasAccess;
    }
    // Default to cached or free tier
    return false;
    }

    Upgrade Flow

    In-App Upgrade

    Open Stripe checkout in the system browser:

    async function handleUpgrade(planId) {
    const { url } = await stackbe.checkout.createSession({

    customer: customerId,

    planId,

    successUrl: 'https://yourapp.com/upgrade-success',

    cancelUrl: 'https://yourapp.com/pricing',

    });
    // Open in system browser

    shell.openExternal(url);

    // Poll for subscription change

    startPollingForUpgrade();

    }
    function startPollingForUpgrade() {
    const interval = setInterval(async () => {
    const sub = await validateSubscription(sessionToken);
    if (sub.plan !== 'free') {

    clearInterval(interval);

    initializeFeatures(sub);

    showNotification('Upgrade successful! Pro features enabled.');

    }
    }, 5000);
    // Stop polling after 10 minutes

    setTimeout(() => clearInterval(interval), 600000);

    }

    Local Storage Security

    Secure Token Storage

    Don't store tokens in plain text files:

    Electron (keychain):

    const keytar = require('keytar');
    // Store
    await keytar.setPassword('myapp', 'session', token);
    // Retrieve
    const token = await keytar.getPassword('myapp', 'session');
    // Delete on logout
    await keytar.deletePassword('myapp', 'session');

    Alternative (encrypted store):

    const Store = require('electron-store');
    const store = new Store({

    encryptionKey: getOrCreateMachineKey(),

    });

    store.set('subscription', subscriptionData);

    Platform-Specific Considerations

    Electron

  • Full Node.js access, easy API calls
  • `keytar` for OS keychain integration
  • Auto-updater for distributing updates
  • Large binary size (~100MB+)
  • Tauri

  • Smaller binary, Rust backend
  • System webview (no Chromium bundled)
  • HTTP client via Rust for secure API calls
  • Custom protocol handlers for auth
  • Native (Swift/Kotlin/C#)

  • Best performance
  • OS-native keychain access
  • Platform-specific HTTP clients
  • More development work per platform
  • Offline Grace Period

    Define a reasonable offline policy:

    Online validation: Check on every launch (if connected)

    Offline grace: 7 days of full access without validation

    Expired cache: Revert to free tier features

    Cache renewal: Any successful online validation resets the timer

    This balances:

  • **User experience**: Don't punish users for being offline
  • **Revenue protection**: Don't allow indefinite free access
  • **Practicality**: Most users are online at least weekly
  • Anti-Piracy Considerations

    Desktop apps face piracy challenges:

    Accept: Some piracy is inevitable. Don't punish paying customers with aggressive DRM.

    Mitigate:

  • Server-side validation when online
  • Periodic re-validation
  • Unique machine fingerprints (be transparent about this)
  • Cloud features that require active subscription
  • Don't:

  • Block functionality when the server is down
  • Require always-online for offline-capable features
  • Use invasive DRM that impacts performance
  • The best anti-piracy strategy is providing enough value that paying is the natural choice.

    Testing

    Test these scenarios:

  • [ ] Fresh install, no account
  • [ ] Login flow (browser → app handoff)
  • [ ] Active subscription loads features
  • [ ] Expired subscription reverts to free
  • [ ] Offline with valid cache
  • [ ] Offline with expired cache
  • [ ] Upgrade flow (browser checkout → app detection)
  • [ ] Cancellation (subscription ends at period end)
  • Next Steps

    1. Choose your framework: Electron, Tauri, or native

    2. Implement auth: Browser-based login with custom protocol

    3. Add subscription validation: Online check with offline cache

    4. Gate features: Enable/disable based on plan

    5. Build upgrade flow: In-app prompt → browser checkout

    6. Test offline scenarios: Ensure graceful degradation

    Ready to simplify your SaaS backend?

    StackBE combines auth, billing, and entitlements in one API. Get started in minutes, not weeks.

    Get Started Free

    Frequently Asked Questions