Sable Documentation

Welcome to the documentation for Sable, a self-hosted VPN platform made of three projects: a Flutter VPN app for Android and iOS, a NestJS backend API with an agent for your VPN servers, and a Next.js admin panel. Follow this guide to set everything up, brand it and publish it.

What is in this package: Setting up for the first time? Start with the backend (section 13), then the admin panel (section 14), then add your first VPN server (section 15), then come back to section 1 for the app. The app and the admin panel both need a running backend, and the app needs at least one active VPN server to connect to.
Contents
  1. Feature Walkthrough
  2. Third-Party Services and Costs
  3. How the Pieces Connect (Architecture)
  4. 1. Prerequisites
  5. 2. Environment Setup
  6. 3. Change Package Name and Bundle ID
  7. 4. Firebase Setup
  8. 5. Social Sign-In and Push Notifications
  9. 6. Change App Name
  10. 7. App Icon, Splash and Colours
  11. 8. Connect the App to Your Backend
  12. 9. Languages
  13. 10. AdMob and Rewarded Ads
  14. 11. Premium Subscriptions (RevenueCat)
  15. 12. Build and Release
  16. 13. Backend Setup (NestJS)
  17. 14. Admin Panel Setup (Next.js)
  18. 15. VPN Servers and the Node Agent
  19. 16. API Reference
  20. 17. Database Schema
  21. 18. FAQ and Common Build Errors
  22. 19. Support
  23. 20. Changelog
  24. 21. Upgrading to a New Version
  25. 22. Open-Source Licences
  26. 23. AI Disclosure

Feature Walkthrough

A plain-language tour of what the app does, so you can find your way around before touching any code. Nothing in this section needs programming knowledge — the technical setup starts at section 1.

First launch and sign-in

Connecting

Free time, rewarded ads and Premium

Tools

Account and settings

Current plan, change password, signed-in devices (sign any of them out), theme, language (English, Bangla, Arabic, Russian and Ukrainian), support messages, debug logs, legal pages, sign out and delete account.

What you control from the admin panel

Almost everything users see is managed in the browser, without rebuilding the app: VPN servers and regions, plans and store products, promo codes, rewarded-ad rules, announcements, push notifications, minimum app version and maintenance mode, the privacy policy, terms and about pages, app users, support messages and integration keys. The full list is in section 14.

Recommended first hour. Set up the backend (section 13), start the admin panel (section 14) and sign in. Add a region and one VPN server with the install command (section 15) and activate it. Then point the app at your backend (section 8) and run it on a real phone — create an account, tap Connect and check that your IP address changes. Once that round trip works, move on to branding, Firebase and store setup.

Third-Party Services and Costs

Sable is self-hosted: you receive the full source code, with no licence keys and nothing payable to us. Some features connect to outside services that are not included in the purchase.

Buyers are responsible for all fees charged by third-party services. This includes hosting, VPN servers and their bandwidth, developer accounts, app store commissions, email sending and any usage beyond a provider's free tier. The item price covers the source code and documentation only. Prices change; check each provider's current rates.
Accounts you create yourself. Sable ships no credentials of ours. Depending on the features you keep, you need:
ServiceRequired?Used forWho bills you
VPS or cloud serverRequiredRuns the backend (Node.js 24, PostgreSQL 14+) and the admin panelYour hosting provider
VPN servers (Ubuntu or Debian)RequiredCarry your users' VPN traffic. One or more per location; each runs WireGuard, Xray (V2Ray) and the node agent. Watch the bandwidth allowance of each serverYour hosting provider
SMTP emailRequired for email sign-upOne-time codes for sign-up and password resetYour email provider, beyond its free tier
Google Play Developer accountRequired for AndroidPublishing the app; USD 25 one-timeGoogle
Apple Developer ProgramRequired for iOSPublishing the app, and the Network Extension capability for the VPN tunnel; USD 99 per yearApple
Firebase (your own project)OptionalGoogle sign-in, push notifications, Analytics, Crashlytics. Without it, email sign-in still works and these features are offGoogle, beyond the free tier
RevenueCatOptionalPremium subscriptions: manages Google Play and App Store purchases and calls your backend's webhookRevenueCat: free tier, then a share of tracked revenue
Google Play BillingOptional (through RevenueCat)Premium on Android. Google takes a commission on each sale, typically 15–30%Google (deducted from sales)
Apple In-App PurchaseOptional (through RevenueCat)Premium on iOS. Apple takes a commission on each sale, typically 15–30%Apple (deducted from sales)
bKash and NagadNot integratedThe database contains switched-off placeholder rows for these two gateways, but no checkout is included. They stay off; enabling them needs your own development work—
Stripe, PayPal and other gatewaysNot integratedNo checkout integration ships for these—
Google AdMob (your own account)OptionalRewarded and interstitial ads. You must follow AdMob's policies (section 10)Free to join; you are responsible for policy compliance and any costs related to your account or traffic
Cloudflare R2OptionalImages uploaded from the admin panelCloudflare, beyond the free tier
Microsoft ClarityOptionalSession analytics in the app; off until you set a project IDFree
AxiomOptionalRemote app logs; off until you set a tokenAxiom, beyond the free tier
Cloudflare speed test endpointsUsed by defaultThe in-app speed test downloads from and uploads to speed.cloudflare.com. You can point it to your own server (section 8)Free public service
VPN services are regulated in some countries. You are responsible for following the laws of the countries where you run servers and offer the app, the terms of your server providers, and the Google Play and App Store policies for VPN apps. Both stores have extra rules and declarations for VPN apps; read the current Google Play VpnService policy and Apple's App Review Guidelines before you submit.

How the Pieces Connect (Architecture)

Sable is three projects plus one small agent on every VPN server. Only the backend talks to the database and holds secrets. The app never sees another user's data, and the admin panel never talks to PostgreSQL directly.

Flutter App Android and iOS sable-vpn/ Admin Panel Next.js server + browser sable-admin/ (port 5101) Backend API NestJS, https://api.your-domain.com/api/v1 sable-backend/ (port 3001, PM2) accounts, plans, servers, peers, payments PostgreSQL users, plans, servers, sessions, audit log VPN servers Ubuntu / Debian WireGuard + Xray (V2Ray) node agent (systemd) SMTP and R2 OTP emails, image uploads Firebase Google sign-in Cloud Messaging (push) Analytics, Crashlytics RevenueCat / AdMob store subscriptions, ads REST + Sable JWT REST + admin JWT encrypted tunnel (WireGuard or V2Ray) agent peers, health sign in verify, push TypeORM send, upload buy, ads webhooks, ad reward callbacks
ConnectionWhat happens
App → BackendEvery request goes to https://api.your-domain.com/api/v1/... with the user's Sable access token in Authorization: Bearer and device headers (X-Device-Id, X-Platform, X-App-Version). Access tokens last 15 minutes and are renewed with a refresh token.
App → Firebase → BackendFor Google sign-in, Firebase returns an ID token. The app sends it to POST /auth/social; the backend checks it with the Firebase Admin SDK and returns Sable tokens. Email sign-in does not use Firebase.
App → Backend → VPN serverWhen the user taps Connect, the backend picks a server, creates a WireGuard peer or a V2Ray user for that session and returns the tunnel settings. The app then connects straight to the VPN server. VPN traffic never passes through the backend.
VPN server (node agent) → BackendThe agent on each server sends health and load every 30 seconds and applies new or removed peers every 5 seconds. It signs in with the agent token from the install command.
Admin panel → BackendThe admin signs in with the email and password set in the backend .env. The panel keeps the admin token in an httpOnly cookie and forwards each browser request to /api/v1/admin/... from its own server, so the browser never talks to the API directly and no CORS setup is needed.
Backend → PostgreSQLAll data lives here. Only the backend connects.
Backend → SMTP, R2One-time codes for sign-up and password reset are sent by email through your SMTP account. Images uploaded in the admin panel (region flags, push images) go to Cloudflare R2.
Backend → Firebase Cloud MessagingPush notifications written in the admin panel are sent by the backend.
App ↔ RevenueCat → BackendThe app buys a subscription through Google Play or the App Store with the RevenueCat SDK. RevenueCat then calls the backend's webhook, and the backend grants or removes Premium.
App ↔ AdMob → BackendA free user watches a rewarded ad. AdMob calls the backend's server-side verification URL, and only then does the backend add VPN time.

1. Prerequisites

Use these versions for the smoothest build. The project was built and tested with the versions marked "tested".

Flutter SDKTested: 3.47.6
(pinned in .fvmrc)
Java (JDK)21 to run Gradle
(the app targets Java 17)
Android SDKCompile/target 36
Min SDK 24
Xcode26.2 or newer
iOS 15.6 or newer
CocoaPodsLatest (1.16 or newer)
Node.js24 LTS (backend)
20.9+ (admin panel)
PostgreSQL14 or newer
(tested on 17)
Real devicesAn Android phone (ARM) and/or an iPhone

Server size

You need two kinds of server: one for the backend and admin panel, and one or more for VPN traffic.

ServerCPURAMDiskNotes
Backend + admin panel, minimum1 vCPU2 GB (plus 2 GB swap)20 GB SSDTesting and a small user base
Backend + admin panel, recommended2 vCPU4 GB40 GB SSDLaunch and thousands of users. Consider a managed PostgreSQL as you grow
Each VPN server1 vCPU or more1 GB or more10 GB+Ubuntu 22.04 / 24.04 or Debian 12, a public IPv4 and generous bandwidth. Capacity depends mostly on network speed and bandwidth allowance, not CPU

Building the admin panel (npm run build) needs about 1.5 GB of free RAM; on a 2 GB server add swap first. Shared hosting (cPanel without SSH and Node.js) is not enough; you need VPS or cloud servers with root SSH access.

Test on real devices. The VPN tunnel cannot run in the iOS Simulator (Network Extensions are not supported there), and the Android VPN core ships only ARM libraries (arm64-v8a, armeabi-v7a), so it does not run on x86 Android emulators. The rest of the app opens in emulators, but Connect needs a phone.

2. Environment Setup

Select your operating system to see the setup steps.

Android Studio

  • Download Android Studio from the official page, run the installer and let it download the SDK components.
  • Optional: create an emulator in the Device Manager.

Flutter SDK

  • Download the stable Windows release from the Flutter download page and extract it, for example to C:\flutter.
  • Add C:\flutter\bin to the Path environment variable (This PC > Properties > Advanced system settings > Environment Variables).
  • Open a new Command Prompt and run flutter doctor. Fix anything it reports.

IDE

  • In Android Studio open File > Settings > Plugins > Marketplace, install the Flutter and Dart plugins and restart.
▶ Watch Windows Setup Video

Android Studio

  • Download Android Studio from the official page, drag it into Applications and complete the setup wizard.

Flutter SDK

  • Download the stable macOS release from the Flutter download page and extract it: tar xf flutter_macos_<version>.tar.xz
  • Add flutter/bin to your PATH in ~/.zshrc, then open a new terminal and run flutter doctor.

Xcode and CocoaPods (for iOS)

  • Install Xcode from the Mac App Store, open it once to finish installing components, then run sudo xcodebuild -runFirstLaunch.
  • Install CocoaPods: sudo gem install cocoapods (or brew install cocoapods).

IDE

  • In Android Studio open Settings > Plugins > Marketplace, install Flutter and Dart and restart.
▶ Watch macOS Setup Video

Android Studio

  • Download Android Studio from the official page, extract it (tar -xvzf android-studio-*-linux.tar.gz) and run bin/studio.sh.

Flutter SDK

  • Download the stable Linux release from the Flutter download page and extract it: tar xf flutter_linux_<version>.tar.xz
  • Add flutter/bin to your PATH, then run flutter doctor.

IDE

  • In Android Studio open File > Settings > Plugins > Marketplace, install Flutter and Dart and restart.
▶ Watch Linux Setup Video

Open and run the app

cd sable-vpn
flutter pub get
flutter run                 # on a connected real device

Before the app can sign in and connect, point it at your backend (section 8). There is no code generation step: translations and models are plain Dart files.

To check the project builds cleanly:

flutter analyze
flutter test

3. Change Package Name and Bundle ID

Every app needs a unique identifier, for example com.yourcompany.vpn. The defaults are com.sablevpn.app (Android and iOS), com.sablevpn.app.VpnTunnel (the iOS tunnel extension) and the App Group group.com.sablevpn.app. Change them before you set up Firebase, because Firebase apps are registered by this identifier.

Android

FileChange
android/app/build.gradlenamespace and applicationId (currently com.sablevpn.app)
android/app/src/main/kotlin/com/sablevpn/app/The package line at the top of every .kt file in this folder and its widget/ and vpn_controller/ subfolders. You may also move the files into folders that match the new package (optional)
android/app/src/main/AndroidManifest.xmlThe widget actions com.sablevpn.app.widget.ACTION_CONNECT and ACTION_DISCONNECT. Keep them identical to the constants in widget/WidgetActionBroadcastReceiver.kt

The change_app_package_name tool in dev_dependencies can do most of the Android part: dart run change_app_package_name:main com.yourcompany.vpn. Check the widget actions afterwards.

The VPN plugin in packages/flutter_vpncore has its own ID, com.sablevpn.core. It is internal and does not need to change.

iOS

Open ios/Runner.xcworkspace in Xcode and change the Bundle Identifier on Signing & Capabilities for both targets:

Then update the same values in code. The app and the tunnel extension share data through the App Group, and the app starts the tunnel by its bundle ID. If any of these values differ, the app still builds but Connect fails on the device:

ValueWhere to change it
App Group group.com.sablevpn.appios/Runner/Runner.entitlements
ios/VpnTunnel/VpnTunnel.entitlements
ios/VpnTunnel/VpnCoreShared.swift (appGroup)
ios/Runner/AppDelegate.swift (sharedAppGroup)
packages/flutter_vpncore/ios/Classes/FlutterVpnCorePlugin.swift (appGroup)
lib/app/bootstrap.dart
Tunnel bundle ID com.sablevpn.app.VpnTunnelios/VpnTunnel/VpnCoreShared.swift (tunnelBundleIdentifier)
packages/flutter_vpncore/ios/Classes/FlutterVpnCorePlugin.swift

Use group. followed by your Runner bundle ID as the new App Group, and register it in your Apple Developer account (section 12).

Tip: the Dart package name in pubspec.yaml (sable) is internal and never shown to users. Leave it as is; renaming it means updating every package:sable/ import.

4. Firebase Setup

Firebase provides Google sign-in, push notifications, Analytics and Crashlytics. It is optional: without it the app runs with email sign-in, and the Google button, push, Analytics and Crashlytics are switched off automatically.

Use your own Firebase project. The package ships placeholder files with values such as YOUR_FIREBASE_.... They let the project compile, but Firebase features stay off until you replace them: There is no iOS Firebase file in the package, so on iOS Firebase only works after step 2 below. No Firebase project of ours is included.

Step 1: Create a Firebase project

Step 2: Connect the app (FlutterFire CLI)

Change the package name and bundle ID first (section 3). The FlutterFire CLI then registers your Android and iOS apps and writes the config files:

npm install -g firebase-tools
firebase login
dart pub global activate flutterfire_cli
cd sable-vpn
flutterfire configure --project=<your-firebase-project-id>

Select android and ios and confirm your IDs. The command updates lib/firebase_options.dart, android/app/google-services.json, ios/Runner/GoogleService-Info.plist and firebase.json. The Android build turns on the Firebase and Crashlytics Gradle plugins by itself once google-services.json no longer contains placeholders.

Step 3: Enable sign-in methods

Step 4: Service account for the backend

The backend checks Google sign-ins and sends push notifications with a Firebase service account: Project settings > Service accounts > Generate new private key. Paste the JSON in the admin panel (Settings > Integrations) or put it in the backend .env (section 13, step 7).

▶ Firebase + Flutter video tutorials

5. Social Sign-In and Push Notifications

Google

Apple

Sign in with Apple is not included in this version. The backend accepts only Google tokens, so the Apple button is hidden in the app. The code is kept but commented out in lib/data/auth/firebase_social_identity.dart for a future update. Email and Google sign-in are not affected.

Apple's App Review Guideline 4.8 asks apps that offer a third-party sign-in such as Google to also offer an equivalent private sign-in option. Email and password sign-in is available in Sable; check the current guideline before you submit and be ready to explain your sign-in options to App Review.

Push notifications

6. Change App Name

WhereFileKey
Android launcher nameandroid/app/src/main/AndroidManifest.xmlandroid:label="Sable"
iOS home-screen nameios/Runner/Info.plistCFBundleDisplayName, CFBundleName
iOS build settings (Runner and VpnTunnel)Xcode > target > Build SettingsINFOPLIST_KEY_CFBundleDisplayName
Name in iOS Settings > VPNpackages/flutter_vpncore/ios/Classes/FlutterVpnCorePlugin.swiftlocalizedDescription = "Sable"
Android VPN notificationpackages/flutter_vpncore/android/src/main/kotlin/com/sablevpn/core/ (ServiceNotification.kt, VpnCoreService.kt)search for Sable
Android home-screen widgetandroid/app/src/main/kotlin/com/sablevpn/app/widget/search for Sable
App title and on-screen textlib/app/app.dart (title), lib/design/brand/sable_wordmark.dart, lib/core/i18n/app_strings.dartsearch for Sable / sable

Tip: search lib/, android/app/src/, ios/ and packages/flutter_vpncore/ for Sable to find every user-visible occurrence.

7. App Icon, Splash, Colours and Font

App icon and splash

All launcher icons, the notification icon and the splash image are generated from one picture, assets/brand/sable_silhouette.png:

Brand colours

All app colours are in lib/design/tokens/sable_colors.dart, with a light set (SableColors.light) and a dark set (SableColors.dark). Flutter writes colours as 0xAARRGGBB: FF (fully opaque) followed by the normal hex code.

// lib/design/tokens/sable_colors.dart
// SableColors.light
primary: Color(0xFF16408C),        // brand blue #16408C
connected: Color(0xFF1D50B5),      // the connect dial when connected

// SableColors.dark
primary: Color(0xFF5B8DEF),        // brand blue for dark mode #5B8DEF
connected: Color(0xFF6FA0FF),

Example: to use orange #FF6B00, change the light primary to Color(0xFFFF6B00) and pick a lighter orange for dark mode, then stop the app and run it again (hot reload does not update const colours). Screens read colours only from this file; a test (test/design/tokens_test.dart) fails if a screen uses a hard-coded colour.

Font

The app uses Inter Tight through the google_fonts package, which downloads the font the first time the app opens. On networks that block Google Fonts the app falls back to the system font. To ship the font inside the app instead, add the font files under flutter: fonts: in pubspec.yaml and change face in lib/design/tokens/sable_typography.dart to use fontFamily.

8. Connect the App to Your Backend

Set your backend address in lib/core/config/auth_api_config.dart:

// lib/core/config/auth_api_config.dart
static const String origin = String.fromEnvironment(
  'AUTH_API_BASE_URL',
  defaultValue: 'https://api.your-domain.com',   // ← your backend, origin only
);

Enter the origin only (no /api/v1); the app adds /api/v1/... itself. You can also leave the file alone and pass the address when you build: --dart-define=AUTH_API_BASE_URL=https://api.your-domain.com.

HTTPS in release builds. Release builds only allow HTTPS. Plain HTTP is allowed only in Android debug builds (for testing against http://<your-computer-ip>:3001 on the same Wi-Fi). Put the backend behind HTTPS before you test a release build or an iPhone build.

Other build-time settings

All of these are optional. Pass them with --dart-define=NAME=value, or put them in a secrets.json file (ignored by Git) and build with --dart-define-from-file=secrets.json. A missing value turns that feature off.

NameDefaultWhat it does
AUTH_API_BASE_URLhttps://your-domain.comBackend origin (see above)
REVENUECAT_ANDROID_KEY, REVENUECAT_IOS_KEYemptyFallback RevenueCat SDK keys. Normally you set them in the admin panel instead (section 11)
CLARITY_PROJECT_IDemptyMicrosoft Clarity session analytics
AXIOM_TOKENemptySends app logs to your Axiom dataset vpn_log
SPEED_TEST_DOWNLOAD_URL, SPEED_TEST_UPLOAD_URL, SPEED_TEST_BYTES_PARAMhttps://speed.cloudflare.com/__down, .../__up, bytesSpeed test targets. Point them to your own server if you prefer

Everything else the app needs (plans, locations, ad units, RevenueCat keys, maintenance mode, minimum version, announcements, legal pages) comes from the backend at run time and is set in the admin panel.

9. Languages

The app ships with English, Bangla, Arabic, Russian and Ukrainian. Users pick a language on Account > Language; the app starts in English until they do.

What a language entry looks like

All texts live in one Dart file, lib/core/i18n/app_strings.dart. The keys are in lib/core/i18n/string_keys.dart. Each key has one line per language:

// lib/core/i18n/app_strings.dart
StringKeys.profile: {
  "en": "Profile",
  "bn": "প্রোফাইল",
  "ar": "ملف شخصي",
  "ru": "Профиль",
  "uk": "Профіль",
},

To change a text, edit its line. A missing translation falls back to English for that key only.

Add a language

Example: adding French (fr).

  1. In app_strings.dart, add 'fr' to AppStrings.supported, and add a case for 'fr' in _getLocal().
  2. Add "fr": "..." to every entry in localStrings in the same file:
    StringKeys.profile: {
      "en": "Profile",
      ...
      "uk": "Профіль",
      "fr": "Profil",
    },
  3. In lib/features/preferences/bloc/locale_cubit.dart, add an entry for French to languages (the code and the name shown in the picker, Français).
  4. In lib/app/app.dart, add Locale('fr') to supportedLocales.

Remove a language

Do the same steps in reverse: remove the code from AppStrings.supported, _getLocal(), LocaleCubit.languages and supportedLocales. The extra lines in localStrings can stay; they are simply not used.

Default language

English is the default in three places: languageCode = 'en' in app_strings.dart, and the starting value and fallback in locale_cubit.dart. Change all three to start in another language.

Right-to-left layout. Arabic texts are translated, but in this version the screen layout stays left-to-right. To mirror the layout for Arabic, add the flutter_localizations package and set localizationsDelegates (with GlobalWidgetsLocalizations.delegate) on the MaterialApp in lib/app/app.dart. A few labels, such as the bottom tab names in lib/app/router/shell/app_shell_page.dart, are written in English directly in the code.

10. AdMob and Rewarded Ads

Free users watch rewarded ads to add VPN time or unlock Ads locations; you can also show banner and interstitial ads. The package contains only Google's official test IDs (publisher ca-app-pub-3940256099942544), so ads show "Test Ad" until you add your own. Debug builds always use test ads.

Your AdMob account, your responsibility. AdMob is free to join. You must follow the AdMob and Google ad network policies (including asking for consent where required; the app uses Google's UMP consent form) and you are responsible for any costs related to your account or traffic.

Step 1: App IDs (built into the app)

Create your app in AdMob and replace Google's sample app IDs:

PlatformFileValue to replace
Androidandroid/app/build.gradle (admobAppId in defaultConfig and in the release block)ca-app-pub-3940256099942544~3347511713
Androidlib/core/config/admob_config.dart (liveAndroidAppId)the same value as in build.gradle
iOSios/Flutter/Release.xcconfig and ios/Flutter/Debug.xcconfig (GAD_APPLICATION_IDENTIFIER)ca-app-pub-3940256099942544~1458002511

Step 2: Ad unit IDs (in the admin panel)

Create a Rewarded ad unit (and optionally Banner and Interstitial units) for each platform. Then in the admin panel open Plans & billing > Rewarded ads and enter the unit IDs. Release builds use these values; no new app build is needed to change them. As a fallback you can also put your Android rewarded unit in liveAndroidRewardedUnitId in lib/core/config/admob_config.dart.

Step 3: Server-side verification (required for rewards)

The backend adds time only after Google confirms the ad was watched. In AdMob, open your Rewarded ad unit > Server-side verification and set the callback URL to:

https://api.your-domain.com/api/v1/webhooks/admob-ssv

The app passes the user's ID and a one-time code with each ad; the backend checks Google's signature and the code before adding time. Optionally restrict which ad units may grant rewards with ADMOB_REWARDED_AD_UNIT_IDS in the backend .env or the ad unit list on the Rewarded ads page.

Reward rules

On Plans & billing > Rewarded ads you set: rewards on or off, the reward ladder (minutes per ad, by default 30, 50, 60, 90 and 120), rewards per day per user and per device (5), the wait between ads, the most time a user can bank (4 hours), banner on or off, interstitial on or off and how often (every 3 connects). The same page shows a log of every reward and why any was refused.

11. Premium Subscriptions (RevenueCat)

Premium is sold as Google Play and App Store subscriptions through RevenueCat. The app buys through the RevenueCat SDK; RevenueCat tells your backend about every purchase, renewal, cancellation and refund through a webhook, and the backend grants or removes Premium. The app never grants Premium by itself.

Step 1: Create the products in the stores

Step 2: Set up RevenueCat

Step 3: Connect RevenueCat to Sable

Step 4: Match the products to your plans

In the admin panel open Plans & billing > Store products. For each store product, enter the exact product ID from the store and choose the plan it grants. The first start created example products (com.sablevpn.app.premium.monthly, com.sablevpn.app.premium.yearly, and sable_primium_subscription:monthly-premium-base-pack / ...:yearly-premium-base-pack for Google Play): change them to your own IDs. For Google Play use the subscriptionId:basePlanId form.

Then check the plans on Plans & billing > Plans: name, price and currency shown on the paywall, devices, limits and features. The example plans use BDT prices; change them to your own currency and prices.

How it works

Not included: Stripe, PayPal and other web checkouts. bKash and Nagad appear as switched-off rows in the database but have no checkout; leave them off.

12. Build and Release

Version number

Set version: in pubspec.yaml (for example 1.0.1+3: version name 1.0.1, build number 3). Android and iOS both read it. Raise the build number for every upload. In Xcode, give the VpnTunnel target the same version and build as the app, or App Store Connect warns that the extension version does not match.

Android: create an upload keystore

macOS / Linux:

keytool -genkey -v -keystore ~/upload-keystore.jks -keyalg RSA -keysize 2048 -validity 10000 -alias upload

Windows:

keytool -genkey -v -keystore %userprofile%\upload-keystore.jks -storetype JKS -keyalg RSA -keysize 2048 -validity 10000 -alias upload
Keep the keystore and passwords safe and private. If you lose them you cannot update your app on Google Play. Never commit them to a public repository.

Android: key.properties

Create android/key.properties:

storePassword=<password from previous step>
keyPassword=<password from previous step>
keyAlias=upload
storeFile=<full path to upload-keystore.jks>

android/app/build.gradle already reads this file. If it is missing, release builds are signed with the debug key so they still build — Google Play will not accept those.

Android: build

flutter build apk --release          # APK for direct install / testing
flutter build appbundle --release    # App Bundle for Google Play

The App Bundle is created at build/app/outputs/bundle/release/app-release.aab. Upload it in Play Console.

Google Play declarations for a VPN app. Play Console asks for extra information about apps that use the VPN service. The app also lists installed apps for split tunneling, which needs the QUERY_ALL_PACKAGES permission and its own declaration. Before you submit, review the permissions in android/app/src/main/AndroidManifest.xml and remove any your version does not need. The app does not ask for the camera, the microphone or storage.

iOS: Apple Developer setup

The VPN tunnel is an iOS app extension, so iOS needs a paid Apple Developer account and a little more setup than a normal app. In your Apple Developer account (Certificates, Identifiers & Profiles):

Then in Xcode (ios/Runner.xcworkspace), for both the Runner and VpnTunnel targets:

iOS: build

cd ios && pod install && cd ..
flutter build ipa --release

Test on a real device

13. Backend Setup (NestJS)

The backend lives in sable-backend/. It is a NestJS 11 + TypeORM API on PostgreSQL. The app, the admin panel and the agents on your VPN servers talk only to this API.

Step 1: Install

cd sable-backend
npm ci
cp .env.example .env

Step 2: Create the database

Use PostgreSQL 14 or newer (tested on 17). In psql as a superuser:

CREATE ROLE sable_user LOGIN PASSWORD 'choose-a-strong-password';
CREATE DATABASE sable OWNER sable_user;

The first start creates four extensions: citext, pg_trgm, pgcrypto and uuid-ossp. On PostgreSQL 13 and newer the database owner may create them. If your host refuses ("permission denied to create extension"), run these once as a superuser:

\c sable
CREATE EXTENSION IF NOT EXISTS citext;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

Step 3: Configure .env

Every variable is explained in .env.example. Only the REQUIRED block must be filled in; every optional feature stays off until you configure it. Generate each secret with openssl rand -hex 48.

VariableWhat to set
NODE_ENVdevelopment on your computer, production on the server
PORTAPI port, default 3001
API_PREFIXKeep api. Every route is served under /api/v1, and the admin panel expects it
PUBLIC_API_URLYour public API address including /api/v1, e.g. https://api.your-domain.com/api/v1. It is printed into the install command for new VPN servers, so it must be reachable from them
DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAMEYour PostgreSQL connection. Set DB_SSL=true for managed databases that require TLS
DB_RUN_MIGRATIONSKeep true: the bundled migrations create and update every table at start. Keep DB_SYNCHRONIZE=false
JWT_ACCESS_SECRETSigns app tokens. At least 32 characters
JWT_ACCESS_TTL_SEC, JWT_REFRESH_TTL_SECToken lifetimes: 900 s (15 minutes) and 2592000 s (30 days) by default
SMTP_HOST, SMTP_PORT, SMTP_SECURE, SMTP_USER, SMTP_PASSWORD, SMTP_FROM_NAME, SMTP_FROM_EMAILYour email account for sign-up and password-reset codes. Port 465 with SMTP_SECURE=true, or 587 with false (STARTTLS). SMTP_USER and SMTP_FROM_EMAIL must be email addresses
ADMIN_EMAILThe email you sign in to the admin panel with
ADMIN_PASSWORD_HASHThe hash of your admin password (step 4). Never the plain password
ADMIN_JWT_SECRETSigns admin tokens. At least 32 characters, and different from JWT_ACCESS_SECRET

Useful optional settings:

VariableWhat it does
TRUST_PROXYloopback when nginx runs on the same server, so rate limits, the login lockout and the audit log see real client IPs
SWAGGER_ENABLEDSwagger UI at /api/docs. Keep it false on a public production server (the example file sets false; set true while developing). If you remove the line, Swagger is on in development and off in production
CORS_ORIGINSBrowser origins allowed to call the API. The admin panel calls the API from its own server, so it does not need an entry here
ADMIN_IP_ALLOWLISTComma-separated IPs or CIDRs allowed to reach /api/v1/admin/*. Empty means no IP restriction
ADMIN_TOKEN_TTL_SEC, ADMIN_LOGIN_MAX_ATTEMPTS, ADMIN_LOGIN_WINDOW_SECAdmin session length (3600 s) and the login lockout (5 failed attempts per 15 minutes per IP)
OTP_DEV_CODEtrue returns the sign-up code in the API response, so you can test without SMTP. Ignored when NODE_ENV=production
OTP_TTL_EMAIL_SEC, OTP_RESEND_COOLDOWN_SEC, OTP_MAX_ATTEMPTS, OTP_MAX_RESENDSCode lifetime (600 s), resend wait (60 s), wrong tries (5) and resends (3). Keep OTP_LENGTH=6; the app and the API expect six digits
THROTTLE_TTL_MS, THROTTLE_LIMITGlobal rate limit: 100 requests per 60 seconds per IP by default
FIREBASE_SERVICE_ACCOUNT_B64Google sign-in and push notifications (step 7). Can also be set in the admin panel
CLOUDFLARE_R2_*Image uploads from the admin panel (step 6)
REVENUECAT_WEBHOOK_AUTH, REVENUECAT_ACCEPT_SANDBOXPremium subscriptions (section 11). Can also be set in the admin panel
ADS_ENABLED, AD_REWARD_*, ADMOB_REWARDED_AD_UNIT_IDSRewarded ads (section 10)
ENABLED_PROTOCOLS, PROTOCOL_PREFERENCEProtocols offered to the app (wireguard,v2ray) and the order Auto tries them in
NODE_HEARTBEAT_TIMEOUT_SEC, PEER_SYNC_INTERVAL_SEC, SESSION_IDLE_TIMEOUT_SECWhen a silent VPN server is marked unreachable (120 s), how often agents pick up new peers (5 s), and when an idle session is closed (1800 s)
Startup check. The server validates .env at start and lists every problem in one "Config validation error" message. Secrets shorter than 32 characters are rejected (the example placeholders are too short on purpose), and with NODE_ENV=production the admin email, an argon2 ADMIN_PASSWORD_HASH and ADMIN_JWT_SECRET are required. The check does not catch every placeholder: replace DB_PASSWORD and the SMTP_* values yourself.

Step 4: Your admin account

Sable has a single admin account, defined in .env (there is no admin table). Create the password hash and paste the printed line into .env:

npm run admin:hash -- "your-admin-password"

# prints:
ADMIN_PASSWORD_HASH='$argon2id$v=19$m=65536,p=4,t=3$...'

The password needs at least 10 characters. Keep the single quotes: the hash contains $ signs. To change the password later, run the command again, replace the line and restart the backend.

Step 5: First run

npm run start:dev        # development, with auto-reload; API at http://localhost:3001/api/v1

What a successful first run looks like

This is the real output of npm run start:dev on an empty PostgreSQL 17 database, with only the REQUIRED block of .env filled in (Firebase and R2 not configured yet). The run used PORT=3101 because port 3001 was busy on the test machine; yours will show your own port. About 230 lines that list modules and routes are shortened in the middle.

> sable-backend@1.0.0 start:dev > nest start --watch [4:18:08 PM] Starting compilation in watch mode... 1 [4:18:11 PM] Found 0 errors. Watching for file changes. [16:18:11.670] INFO (64421): Starting Nest application... {"context":"NestFactory"} [16:18:11.670] INFO (64421): TypeOrmModule dependencies initialized {"context":"InstanceLoader"} [16:18:11.670] INFO (64421): MailerModule dependencies initialized {"context":"InstanceLoader"} [16:18:11.670] INFO (64421): UsersModule dependencies initialized {"context":"InstanceLoader"} [... 51 more "InstanceLoader ... dependencies initialized" lines ...] 2 [16:18:11.671] WARN (64421): Unsupported route path: "/api/*". In previous versions, the symbols ?, *, and + were used to denote optional or repeating path parameters. ... Attempting to auto-convert to "/api/{*path}"... {"context":"LegacyRouteConverter"} [16:18:11.671] INFO (64421): HealthController {/api/health} (version: 1): {"context":"RoutesResolver"} [16:18:11.671] INFO (64421): Mapped {/api/health, GET} (version: 1) route {"context":"RouterExplorer"} [... 172 more "RoutesResolver" / "Mapped {...} route" lines ...] 3 [16:18:11.671] WARN (64421): CLOUDFLARE_R2_* not set — image uploads disabled {"context":"StorageService"} 4 [16:18:11.671] WARN (64421): No Firebase service account — push and social login are off {"context":"FirebaseService"} 5 [16:18:11.671] INFO (64421): Partitions ensured at boot: vpn_sessions_2026_10, vpn_sessions_2026_11, vpn_sessions_2026_12 {"context":"SessionsService"} 6 [16:18:11.671] INFO (64421): Nest application successfully started {"context":"NestApplication"}
  1. Found 0 errors: the TypeScript code compiled.
  2. WARN "Unsupported route path": comes from the NestJS router while it converts a catch-all route. It is harmless and can be ignored.
  3. WARN CLOUDFLARE_R2_*: normal until you set up R2 (step 6). Only image uploads in the admin panel are off.
  4. WARN No Firebase service account: normal until you add it (step 7). Email sign-in still works; Google sign-in and push notifications are off.
  5. Partitions ensured: the monthly tables for VPN session history were prepared.
  6. Nest application successfully started: success. The API is ready. The migrations already created every table and the default data before this line.

If the output stops with a red error before line 6, compare it with the troubleshooting list below. The most common causes are a wrong database password or port, a database that does not exist yet, and a port that is already in use (EADDRINUSE).

Check it in the browser or terminal

$ curl -s http://localhost:3001/api/v1/health
{"status":"ok","info":{"database":{"status":"up"}},"error":{},"details":{"database":{"status":"up"}}}

$ curl -s http://localhost:3001/api/v1/regions
{"success":true,"data":[{"code":"id-jkt-01","name":"Jakarta","country_code":"ID","city":"Jakarta","flag_url":"https://upload.wikimedia.org/...","latitude":-6.208763,"longitude":106.845599,"access_level":0}]}

A reply like this means the API, the database and the default data all work. Open http://localhost:3001/api/docs in your browser to see the interactive Swagger API docs:

Swagger UI at /api/docs showing Sable VPN API 1.0
Swagger at /api/docs after a successful first run.

The first start creates every table and this default data:

DataDefault
Plansfree (1 device, 60 minutes a day, ads), reward (earned with an ad), premium (for manual grants), premium_monthly and premium_yearly (5 devices, no limits, no ads). Edit prices, limits and texts in the admin panel
Store productsExample App Store and Google Play product IDs linked to the monthly and yearly plans. Replace them with your own (section 11)
Payment gatewaysGoogle Play and App Store (through RevenueCat), active. bKash and Nagad rows exist but are switched off and not integrated
Sample locationRegion Jakarta with server jkt01.your-domain.com (203.0.113.10, a documentation address). It is only an example: delete it, or edit it to match your real server (section 15)
Rewarded adsReward ladder 30, 50, 60, 90, 120 minutes, 5 rewards a day, and Google's test rewarded ad unit
App pagesEmpty privacy policy and terms, and a short About page

No app users are created. Sign in to the admin panel with ADMIN_EMAIL and your password once it is running (section 14).

Step 6: Image storage (Cloudflare R2, optional)

R2 is used for images you upload in the admin panel: push notification images, region flags and pictures in app pages. PNG, JPEG, WebP and GIF up to 2 MB. Without R2 the upload button reports that uploads are not configured; you can still paste https image links instead.

Step 7: Firebase service account (optional)

The backend uses a Firebase service account to verify Google sign-in and to send push notifications. In the Firebase console open Project settings > Service accounts > Generate new private key, then either:

The service account must belong to the same Firebase project as the app (section 4).

Step 8: Go live with PM2 and nginx

npm ci
npm run build
npm install -g pm2
pm2 start prod.config.js      # app name "sable-api", NODE_ENV=production
pm2 save && pm2 startup        # restart automatically after a reboot

Or run bash scripts/deploy-production.sh, which checks for .env, builds, and starts or reloads sable-api. Keep one PM2 instance: the scheduled jobs (free-time limits, idle sessions, server health, push sending, subscription expiry and clean-up) run inside the process.

Put nginx in front with HTTPS (for example with Let's Encrypt / certbot):

server {
    listen 80;
    server_name api.your-domain.com;
    client_max_body_size 10m;

    location / {
        proxy_pass http://127.0.0.1:3001;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Then run sudo certbot --nginx -d api.your-domain.com and set TRUST_PROXY=loopback in .env. Release builds of the app only talk to HTTPS addresses.

Troubleshooting

ProblemFix
"Config validation error" at startRead the list; it names every variable to fix (see the startup check above)
ECONNREFUSED or "password authentication failed"Check the DB_* values and that PostgreSQL is running and accepts connections
"database … does not exist"Create it first (step 2), and check DB_NAME
"permission denied to create extension"Run the CREATE EXTENSION lines from step 2 as a superuser
EADDRINUSE (port already in use)Another program uses the port. Change PORT in .env and in your nginx proxy_pass
Sign-up codes never arriveCheck the server log for "mail send failed". Fix the SMTP_* values; for testing set OTP_DEV_CODE=true in development
"No Firebase service account" warningNormal until step 7. Google sign-in and push are off until then
Google sign-in returns 400 "Social sign-in is not available"Firebase service account missing (step 7), or GOOGLE_SIGNIN_ENABLED=false
Image uploads failR2 is not configured or wrong (step 6). Images are limited to 2 MB
RevenueCat webhook gets 503 or 401503: the webhook secret is not set. 401: the Authorization value in RevenueCat does not match exactly (section 11)
Admin login answers 429Too many failed attempts from your IP. Wait until the lockout window ends (15 minutes by default; the message says how long)
Admin login answers 403 IP_NOT_ALLOWEDYour IP is not in ADMIN_IP_ALLOWLIST

14. Admin Panel Setup (Next.js)

The admin panel lives in sable-admin/. It is a Next.js 16 web app (React 19, Tailwind 4) that talks to the backend. It needs Node.js 20.9 or newer (tested on 24).

Step 1: Install and configure

cd sable-admin
npm ci
cp .env.example .env
VariableWhat to set
API_BASE_URLYour backend address without /api/v1, e.g. http://localhost:3001 locally or https://api.your-domain.com in production. The panel adds /api/v1/admin/ itself
NEXT_PUBLIC_APP_NAMEName in the sidebar, sign-in page and browser tab (default Sable Admin). Run npm run build again after changing it
SESSION_COOKIE_NAMEName of the sign-in cookie (default sable_admin). No need to change it
How the panel reaches the API. API_BASE_URL is read only by the panel's own server, never by the browser. The browser calls the panel, and the panel forwards each request to the backend with the admin token from an httpOnly cookie. So the backend does not need a CORS entry for the panel, and the token is never readable by page scripts.

Step 2: Run

npm run dev        # http://localhost:5101

Start the backend first. A successful start looks like this (real output):

> sable-admin@1.0.0 dev > next dev -p 5101 ▲ Next.js 16.3.8 (Turbopack) - Local: http://localhost:5101 - Network: http://<your-computer-ip>:5101 3 - Environments: .env 1 ✓ Ready in 212ms ✓ Running next.config.ts took 906ms 4 ✓ Generated AGENTS.md and CLAUDE.md for AI agents. Set `agentRules: false` in next.config to disable. 2 GET /login 200 in 2.3s (next.js: 2.1s, proxy.ts: 81ms, application-code: 106ms)
  1. Ready: the admin panel is running at http://localhost:5101.
  2. GET /login 200: your browser opened the sign-in page.
  3. Environments: .env: your settings file was found. If this line is missing, the panel uses the default backend address http://localhost:3001.
  4. Generated AGENTS.md and CLAUDE.md: Next.js 16 writes two helper files for AI coding tools into the project folder on next dev. They are not used by the panel; you can delete them or ignore them.

Open http://localhost:5101. You should see the sign-in page:

Sable admin sign-in page
The admin sign-in page. Sign in with the backend's ADMIN_EMAIL and the password you hashed with npm run admin:hash.

After signing in you land on the Dashboard. On a new installation most numbers are 0 until users connect. If sign-in fails with "Cannot reach the server", check that the backend is running and that API_BASE_URL is right.

Step 3: Go live

npm ci
npm run build
pm2 start prod.config.js     # app name "sable-admin", runs "next start" on port 5101
pm2 save

Put nginx in front with HTTPS. HTTPS is required in production: the sign-in cookie is marked Secure, so the browser will not send it over plain HTTP.

server {
    listen 80;
    server_name admin.your-domain.com;
    client_max_body_size 10m;

    location / {
        proxy_pass http://127.0.0.1:5101;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Then run sudo certbot --nginx -d admin.your-domain.com. Vercel also works: import the sable-admin folder, set API_BASE_URL in the project settings and deploy. npm run build downloads the Geist fonts from Google Fonts, so the build machine needs internet access.

Accounts and security

What you can manage

PageWhat it does
DashboardActive users (24 h), sign-ups today, paid subscribers, estimated revenue, ad rewards today, bandwidth today, servers and stale servers, a trend chart and a server table. Refreshes every 30 seconds
ServersEvery VPN server with region, protocols, status, health, load, peers, traffic and last heartbeat. Search, filter and sort; Add server opens the setup wizard (section 15)
Server detailTabs for Overview (CPU, memory, load, peers, bandwidth), Protocols, IP pools, Sessions and Settings. Actions: Activate, Maintenance, Drain, Disable, Re-enable, Rotate agent token, Disconnect all and Delete
RegionsLocations shown in the app: code, name, country, city, map position, flag, access level (Free, Ads or Premium), order and active switch
Plans & billing > PlansPrice, currency, billing period, trial days, access level, daily time, data allowance, speed limit, devices at once, ads, features, and whether the plan is sold on the paywall. Apply to current holders pushes a changed limit to existing users
Store productsGoogle Play and App Store product IDs and which plan each one grants
Subscriptions / Payment eventsEvery subscription with filters, and every RevenueCat event with its result. A failed event can be replayed
Promo codesCode, plan, duration, start and end dates, total and per-user limits, and redemptions
Rewarded adsThe ad reward log and the ad settings: rewards on or off, reward ladder, daily limits, cooldown, ad unit IDs, banner and interstitial switches
UsersSearch and filter users. User detail shows devices, sessions, subscription, payments and ad rewards. Actions: grant or revoke Premium, send a password reset code, sign out everywhere or one device, suspend, delete. Users who asked to delete their account show as Deletion requested with the date it will be deleted (see Account deletion)
SupportContact messages and problem reports sent from the app, with notes and resolved / open status
App configMaintenance mode and feature switches (ads, social sign-in, promo codes); Announcements (banners in the app); Push notifications to everyone, a plan, a platform or one user, now or scheduled; Versions (latest and minimum version per platform, force update); Pages (privacy policy, terms, about, with a rich-text editor)
Audit logWho changed what and when, with the values before and after
SettingsIntegration keys: Firebase service account, RevenueCat webhook secret, RevenueCat sandbox switch, RevenueCat Android and iOS SDK keys, how long logs are kept, and the account deletion grace period. Secrets are write-only. The AdMob app ID and the Firebase files are built into the app and cannot be set here

Admin panel tour

Screenshots from the demo, so you know your way around before you add your own servers. Server addresses are examples.

Dashboard: active users, sign-ups, revenue, ad rewards, bandwidth and server health
Dashboard: active users, sign-ups, revenue, ad rewards, bandwidth and server health
Servers: every VPN server with status, health, load and peers
Servers: every VPN server with its status, health, load and peers
Server detail: load, peers, CPU, memory, bandwidth and the server actions
Server detail: load, peers, CPU, memory, bandwidth and the server actions
Regions: the locations users see in the app
Regions: the locations users see in the app
Add a region: flag, city, map position and access level
Add a region: flag, city, map position and access level
Plans and billing: plans with prices, access and limits
Plans & billing: plans with their prices, access level and limits
Push notifications: audience, message, image, link and schedule
Push notifications: audience, message, image, link and schedule

Account deletion

Apple and Google require that users can delete their account from inside the app. Sable does this in two steps, the common pattern for apps with paid plans: the account is blocked at once, then deleted automatically after a grace period, so a request made by mistake can still be cancelled.

  1. The user asks. In the app: Account → Delete account. An email account confirms with its password; a Google account does not need one. The screen says when the account will be deleted and that an app store subscription is not cancelled with it.
  2. Blocked at once. Every device is signed out, VPN tunnels close and push notifications stop. Signing in again shows “This account is scheduled for deletion”. In the admin panel the user shows as Deletion requested, with the reason and the deletion date.
  3. Deleted automatically. Every hour the backend deletes the accounts whose grace period has passed. Set the period in Settings → Data retention → Account deletion grace period (days): 0 to 30, default 30. With 0 the account is deleted the moment the user asks.
  4. Admin options during the grace period. On the user's page: Cancel request (the account is active again, nothing was lost) or Delete permanently (deleted now).
DeletedKept, without personal details
Name, email, phone number and password; the user name becomes deleted-<id>@deleted.invalidSubscriptions and payment events (needed for accounting and refunds)
Sign-in tokens and verification codesRewarded-ad events and daily usage totals
Support messages, including those sent signed out from the account's emailAudit log entries: the email is replaced with “deleted user”, and the IP address and deletion reason of the user's own request are cleared
Device model, OS version, language and push token; open VPN sessions end
Store subscriptions are not cancelled. Deleting an account cannot stop Google Play or App Store billing; the user must cancel the subscription in the store. Mention this, and your grace period, in your privacy policy.

Branding the admin panel

WhatWhere
NameNEXT_PUBLIC_APP_NAME in .env; the page description is in src/app/layout.tsx
Colours (light and dark)src/app/globals.css: :root for light, .dark for dark. The primary colour is #16408c (light) and #5b8def (dark)
Logo iconThe shield icon in src/components/layout/sidebar.tsx and src/features/auth/login-form.tsx
Faviconsrc/app/favicon.ico

The panel is in English. Dates and numbers follow the browser's language.

Troubleshooting

ProblemFix
"Cannot reach the server" or 502 on every pageThe backend is not running, or API_BASE_URL is wrong. It must not end in /api/v1
"Invalid email or password"Use the backend's ADMIN_EMAIL and the password you hashed. If you forgot it, run npm run admin:hash again in sable-backend, replace the line in .env and restart the backend
Sign-in works locally but not on the server (you stay on the sign-in page)Use HTTPS. In production the cookie is only sent over HTTPS
"Your session expired"Normal after one hour. Sign in again, or raise ADMIN_TOKEN_TTL_SEC in the backend
"Images can be at most 2 MB" or uploads failUse a smaller image, and check R2 in the backend (section 13, step 6)
A changed NEXT_PUBLIC_APP_NAME has no effectRun npm run build again and restart
Port 5101 is in useChange -p 5101 in the dev and start scripts in package.json and in prod.config.js

15. VPN Servers and the Node Agent

Your users' traffic flows through VPN servers that you rent and control. Each one runs WireGuard, optionally Xray for V2Ray (VMess over TLS), and the small Sable node agent, which keeps the server in sync with the backend. You add servers from the admin panel; one copy-and-paste command sets each one up.

What a VPN server needs

Requirement
Operating systemUbuntu 22.04 or 24.04, or Debian 12, with systemd. The installer refuses other systems
CPU and memoryx86_64 or ARM64, at least 1 GB RAM
NetworkA public IPv4 address and outbound HTTPS to your API. Choose a plan with enough monthly bandwidth: every byte your users send and receive passes through this server
AccessRoot (or sudo) over SSH
FirewallOpen UDP 51820 for WireGuard. For V2Ray also open TCP 443 and TCP 80 (80 is used to get the TLS certificate). Open them in your provider's cloud firewall too; the installer opens ufw itself when ufw is active
Domain (V2Ray only)A hostname such as sg1.your-domain.com whose DNS A record points to the server's public IP, so a Let's Encrypt certificate can be issued
Use a separate, clean server for VPN traffic, not the one that runs the backend. Do not run nginx or another web server on it: the V2Ray setup needs ports 80 and 443 for itself.

Step 1: Add a region

In the admin panel open Regions and add the location: code (for example sg-sin-01), name, country code, city, map position, a flag image, the access level (Free, Ads or Premium) and the sort order. The first start of the backend created an example region, Jakarta, with a placeholder server; edit or delete them.

Step 2: Add the server (the wizard)

Open Servers > Add server. The wizard has five steps:

  1. Details — region, display name, hostname, public IPv4, provider, access level, maximum connections and bandwidth. Turn WireGuard on (UDP port 51820) and, if you want, V2Ray (VMess) with its TCP port and TLS.
  2. IP pool — the private addresses given to WireGuard clients. The default 10.8.0.0/24 (gateway 10.8.0.1, DNS 1.1.1.1, 1.0.0.1) serves about 250 users at once; use 10.8.0.0/22 for more. This step is skipped when WireGuard is off.
  3. Install — the panel creates the server in maintenance mode and shows a one-time install command. The agent token inside it is shown only once; copy the command before you continue.
  4. Verify — run the command on the server (step 3). The panel checks every 3 seconds until the agent has reached the API, registered its WireGuard key and sent its first heartbeat.
  5. Go live — the panel lists anything still missing. Click Activate server to make it available in the app.
Add server wizard: IP pool step
The IP pool step of the wizard
Add server wizard: the one-time install command
The install step: copy the one-time command (the token is example data)

Step 3: Run the install command on the server

SSH into the new server and paste the command from the panel. It looks like this:

curl -fsSL 'https://api.your-domain.com/api/v1/node-agent/install-agent.sh' \
  | sudo bash -s -- --api 'https://api.your-domain.com/api/v1' \
      --server-id '<server id>' --token '<one-time token>'

The script, in order:

It ends with "Agent is running". Back in the panel the Verify step turns green.

Options: --skip-wireguard (V2Ray only), --iface NAME (WireGuard interface name, default wg0), and --insecure-http (allows an http:// API address; only for testing on a private network).

What the agent does

Check a server

systemctl status vpn-node-agent                      # should be active (running)
journalctl -u vpn-node-agent -n 30 --no-pager        # no "401" or "failed" lines
wg show                                              # WireGuard interface and peers
systemctl status xray                                # if V2Ray is enabled
certbot certificates                                 # if V2Ray is enabled

Day-to-day operations

Action (server page)Effect
MaintenanceNo new connections; current users stay connected
DrainNo new connections, so current users leave over time (before you remove a server)
DisableDisconnects everyone and takes the server out of use
Re-enableMoves a disabled server back to maintenance; click Activate when it is ready
Disconnect allEnds every current session on the server
Rotate agent tokenIssues a new install command and makes the old token useless. Run the new command on the server
DeleteRemoves the server. Refused while users are connected; drain or disable it first

To update the agent on a server later, run the update command (no arguments; it reads the existing settings):

curl -fsSL 'https://api.your-domain.com/api/v1/node-agent/install-agent.sh' | sudo bash

Troubleshooting

ProblemFix
"Unsupported OS"Use Ubuntu 22.04 / 24.04 or Debian 12
Install stops with 401Wrong server ID or token, or the token was already rotated. Use Rotate agent token on the server page and run the new command
Install stops with 409The server is not ready in the panel yet; finish the wizard's Details step
Install refuses an http:// API addressPut HTTPS on the API (section 13), and check PUBLIC_API_URL in the backend .env
Certificate fails (V2Ray)The hostname's DNS must point to this server, and TCP port 80 must be open and free
Server shows "stale" or "never seen"journalctl -u vpn-node-agent -n 50. Check outbound HTTPS to the API and that wg show works
App connects but no traffic passesUDP 51820 (WireGuard) or TCP 443 (V2Ray) is blocked in the provider's firewall
App says the location is fullThe IP pool or max_connections is used up. Use a bigger pool (/22) or add another server to the region

16. API Reference

All endpoints live under https://api.your-domain.com/api/v1. The full, always-current reference with every request body is the Swagger UI at /api/docs (when SWAGGER_ENABLED is on). This section covers the conventions and the most important calls. The JSON below was captured from a real server or built from the request classes in the code.

Conventions

The response envelope

// success
{ "success": true, "data": { ... } }

// error (real response)
{ "success": false,
  "error": { "code": "UNAUTHORIZED", "message": "Invalid credentials" },
  "path": "/api/v1/auth/login", "timestamp": "2026-10-25T10:19:58.275Z" }

// validation error: message is a list (real response)
{ "success": false,
  "error": { "code": "BAD_REQUEST", "message": ["user_name must be an email", "password must be a string"] },
  "path": "/api/v1/auth/login", "timestamp": "2026-10-25T10:19:53.928Z" }

The health check (GET /health) and the agent files (GET /node-agent/:name) are not wrapped. Some errors carry extra fields; for example DEVICE_LIMIT_EXCEEDED includes limit, devices and a device_manage_token the app uses to sign another device out.

Pagination (admin lists): ?page=1&limit=20 (maximum 100). The response data is { "items": [...], "page": 1, "limit": 20, "total": 57 }.

CodeMeaning
200 / 201Success
400Invalid input (BAD_REQUEST, MISSING_DEVICE_HEADERS, UNSUPPORTED_PROVIDER)
401Missing, invalid or expired token, or wrong credentials
403Not allowed: REQUIRES_UPGRADE, REQUIRES_AD, ACCOUNT_DELETION_PENDING, IP_NOT_ALLOWED (admin)
404Not found
409Conflict: DEVICE_LIMIT_EXCEEDED, PASSWORD_NOT_SET, DUPLICATE
422Request understood but refused, e.g. WRONG_PASSWORD, PROMO_DISABLED, UPLOADS_NOT_CONFIGURED
429Too many requests
500INTERNAL_ERROR; details are only in the server log
503MAINTENANCE (maintenance mode is on), or a service that is not configured (for example the RevenueCat webhook)

Authentication

App users get a short-lived access token (15 minutes) and a refresh token (30 days). Send the access token on every user request:

Authorization: Bearer <access_token>

Sign-up with an email code:

POST /api/v1/otp/send
{ "destination": "user@example.com", "purpose": "register" }

// real response
{ "success": true, "data": { "sent": true, "resend_in": 60, "resend_available_at": "2026-10-25T10:21:03.488Z",
  "expires_in": 600, "expires_at": "2026-10-25T10:30:03.488Z", "attempts_left": 5 } }

POST /api/v1/otp/verify
{ "destination": "user@example.com", "purpose": "register", "code": "123456" }
// → data: { "otp_token": "...", "expires_in": 900, ... }

POST /api/v1/auth/signup          (device headers required)
{ "otp_token": "...", "password": "Str0ng!Passw0rd", "full_name": "Demo User" }
// → 201, same body as login below

Password reset uses the same two code calls with "purpose": "reset_password", then POST /auth/reset-password with { "otp_token", "new_password" }.

Login:

POST /api/v1/auth/login           (device headers required)
{ "user_name": "user@example.com", "password": "Str0ng!Passw0rd" }

{ "success": true, "data": {
  "access_token": "eyJ...", "refresh_token": "...", "expires_in": 900,
  "expires_at": "2026-10-25T10:15:00.000Z", "refresh_expires_at": "2026-11-24T10:00:00.000Z",
  "user": { "id": "uuid", "user_name": "user@example.com", "full_name": null,
            "register_provider": "email", "has_password": true },
  "device": { "device_id": "uuid", "login_provider": "email" },
  "entitlement": { "plan_code": "free", "plan_name": "Free", "access_level": 0, "max_devices": 1,
                   "data_limit_bytes": null, "data_limit_period": "month", "speed_limit_mbps": null,
                   "session_limit_seconds": 3600, "shows_ads": true, "source": "free", "expires_at": null } } }

Other auth calls: POST /auth/social with a Firebase ID token (Google), POST /auth/refresh with { "refresh_token" } (a reused refresh token signs that device out), POST /auth/logout, GET/PATCH /auth/me, POST /auth/change-password and POST /auth/delete-account.

Admins sign in separately; admin and user tokens are signed with different secrets and cannot replace each other:

POST /api/v1/admin/auth/login
{ "email": "admin@your-domain.com", "password": "your-admin-password" }

// real response
{ "success": true, "data": { "access_token": "eyJ...", "expires_at": "2026-10-25T11:18:39.285Z" } }

Example: connect

POST /api/v1/connect              (user token)
{ "region_code": "id-jkt-01", "protocol": "wireguard", "supported_protocols": ["wireguard", "v2ray"],
  "credentials": { "wireguard": { "public_key": "<the app's WireGuard public key>" } },
  "idempotency_key": "c0ffee-1" }

{ "success": true, "data": {
  "session_id": "uuid", "protocol": "wireguard", "peer_ready": false,
  "config": { "address": "10.8.0.2/32", "dns": ["1.1.1.1", "1.0.0.1"], "mtu": 1420,
              "peer": { "public_key": "<server key>", "endpoint": "jkt01.your-domain.com:51820",
                        "allowed_ips": ["0.0.0.0/0", "::/0"], "persistent_keepalive": 25 } },
  "session": { "started_at": "...", "expires_at": "...", "limit_seconds": 3600 },
  "server_time": "..." } }

Send "protocol": null to let the backend choose (Auto). For V2Ray, config is a VMess client configuration instead. peer_ready becomes true once the server's agent has applied the peer (within about 5 seconds). Errors include REQUIRES_UPGRADE and REQUIRES_AD (location needs Premium or an ad), DAILY_LIMIT_REACHED, DATA_LIMIT_REACHED, NO_SERVER_AVAILABLE, AT_CAPACITY and MAINTENANCE.

Example: plans (public)

GET /api/v1/plans

// real response, shortened
{ "success": true, "data": [
  { "code": "premium_monthly", "name": "Premium Monthly", "access_level": 10, "max_devices": 5,
    "shows_ads": false, "features": ["All server locations", "No ads", "5 devices", "Unlimited session time"],
    "price": { "amount_minor": 50000, "currency": "BDT", "display": "৳500.00" },
    "billing_period_days": 30, "is_recurring": true, "platforms": ["android", "ios"],
    "payment_methods": [
      { "gateway_code": "revenuecat_android", "name": "Google Play", "payment_type": "sdk",
        "product_id": "sable_primium_subscription:monthly-premium-base-pack",
        "charge": { "amount_minor": 200, "currency": "USD", "display": "$2.00" } },
      { "gateway_code": "revenuecat_ios", "name": "App Store", "payment_type": "sdk",
        "product_id": "com.sablevpn.app.premium.monthly",
        "charge": { "amount_minor": 200, "currency": "USD", "display": "$2.00" } } ] } ] }

The plan price and currency are what the paywall shows; the store charge comes from each store product. Change both in the admin panel to match your own store prices.

Endpoint groups

Every path below is relative to /api/v1. Access: public (no token), user (app token), admin (admin token, optional IP allowlist), node (a VPN server's agent token).

GroupMethod and pathAccess
HealthGET /healthpublic
Email codesPOST /otp/send
POST /otp/verify
public
AuthPOST /auth/signup
POST /auth/login
POST /auth/social
POST /auth/refresh
POST /auth/reset-password
public
AccountPOST /auth/logout
GET /auth/me
PATCH /auth/me
POST /auth/change-password
POST /auth/delete-account
user
DevicesGET /devices
POST /devices/push-token
DELETE /devices/:id
user
CatalogueGET /plans
GET /protocols
GET /regions
public
Servers and connectGET /servers
GET /servers/recommended
POST /connect
POST /disconnect
GET /sessions/current
user
Ads and promoGET /ads/config
POST /ads/rewards/nonce
POST /promo/redeem
user
App contentGET /app/config
GET /app/announcements
GET /app/version-check
GET /app/pages/:key
GET /app/pages/:key/view
public
SupportPOST /support/messagespublic (token optional)
WebhooksPOST /webhooks/revenuecat
GET /webhooks/admob-ssv
RevenueCat secret / Google signature
Node agentGET /internal/nodes/:serverId/setup
POST /internal/nodes/:serverId/register
POST /internal/nodes/:serverId/heartbeat
GET /internal/nodes/:serverId/peers
POST /internal/nodes/:serverId/peers/report
node
Agent filesGET /node-agent/:name (install script, agent, unit file)public
Admin: sign-inPOST /admin/auth/login
GET /admin/auth/me
public / admin
Admin: dashboardGET /admin/stats/overview
GET /admin/stats/timeseries
admin
Admin: serversGET, POST /admin/servers
GET, PATCH, DELETE /admin/servers/:id
POST /admin/servers/:id/activate, /maintenance, /drain, /disable, /enable, /rotate-token, /disconnect-all
GET /admin/servers/:id/status, /health, /sessions
POST /admin/servers/:id/sessions/:sessionId/disconnect
GET, POST /admin/servers/:id/pools, /protocols
PATCH, DELETE /admin/pools/:id, /admin/protocols/:id
admin
Admin: regionsGET, POST /admin/regions
GET, PATCH, DELETE /admin/regions/:id
admin
Admin: billingGET, POST /admin/plans; GET, PATCH, DELETE /admin/plans/:id
POST /admin/plans/:id/activate, /deactivate, /apply-to-subscribers
GET /admin/gateways
GET, POST /admin/products; GET, PATCH, DELETE /admin/products/:id
GET /admin/subscriptions, /admin/subscriptions/:id
GET /admin/payment-events, /admin/payment-events/:id; POST /admin/payment-events/:id/replay
GET, POST /admin/promo-codes; GET, PATCH, DELETE /admin/promo-codes/:id; GET /admin/promo-codes/:id/redemptions
GET /admin/ads/events
admin
Admin: usersGET /admin/users; GET, DELETE /admin/users/:id
GET /admin/users/:id/devices, /sessions, /subscriptions, /payment-events, /ad-events
POST /admin/users/:id/suspend, /unsuspend, /cancel-deletion, /logout, /reset-password, /grant, /revoke-grant
DELETE /admin/users/:id/devices/:deviceId
admin
Admin: app contentGET, POST /admin/announcements; GET, PATCH, DELETE /admin/announcements/:id
GET /admin/app/pages; GET, PUT /admin/app/pages/:key
GET /admin/app/versions; PUT /admin/app/versions/:platform
admin
Admin: pushGET /admin/notifications, /admin/notifications/audience, /admin/notifications/:id
POST /admin/notifications/send (10 per hour)
POST /admin/notifications/:id/cancel
admin
Admin: otherPOST /admin/uploads?purpose=notification|region|page
GET /admin/audit
GET, PUT /admin/settings; PUT, DELETE /admin/settings/:key
GET /admin/support/messages; GET, PATCH /admin/support/messages/:id
admin

17. Database Schema

PostgreSQL 14 or newer, managed with TypeORM. The tables are created by the migrations in sable-backend/src/database/migrations/, which run when the server starts (DB_RUN_MIGRATIONS=true). sable-backend/database/schema.sql holds the same schema and default data if you prefer to create the database by hand.

Conventions

Tables by area

AreaTables
Accountsusers, devices, auth_refresh_tokens, verification_codes
Plans and billingplans, user_entitlements, subscriptions, payment_gateways, gateway_products, plan_payment_methods, payment_events, promo_codes, promo_redemptions
Rewarded adsad_reward_nonces, ad_reward_events
Serversregions, servers, server_protocols, server_health, ip_pools
VPN sessionswireguard_peers (WireGuard peers and V2Ray users), vpn_sessions (and its monthly partitions), usage_daily
App contentapp_settings, app_pages, app_versions, announcements, notifications, support_messages
Systemaudit_logs, migrations

There is no admin table: the single admin account lives in the backend .env.

Key tables

TableImportant columns
usersuser_name (citext), register_provider, password_hash (argon2, never returned), email, full_name, status, country_code, last_seen_at, suspended_reason, deletion_requested_at, deleted_at
devicesuser_id, install_id, platform, model, os_version, app_version, locale, push_token, is_blocked, last_seen_at, revoked_at
planscode, name, access_level, max_devices, data_limit_bytes, data_limit_period, speed_limit_mbps, session_limit_seconds, shows_ads, features, price_minor, currency, discount_percent, billing_period_days, is_recurring, platforms, is_active, sort_order
user_entitlementsOne row per user with the limits in force now: plan_id, access_level, max_devices, limits, shows_ads, source, expires_at, subscription_id
subscriptionsuser_id, plan_id, gateway_id, store, environment, original_transaction_id, status, auto_renew, current_period_start, current_period_end, grace_period_end, cancelled_at, expired_at, refunded_at
regionscode, name, country_code, city, latitude, longitude, flag_url, access_level, is_active, sort_order
serversregion_id, hostname, ipv4, ipv6, provider, access_level, status, weight, max_connections, bandwidth_mbps, agent_token_hash, agent_version, label
server_protocolsserver_id, protocol, port, public_key, config, is_active
server_healthactive_peers, load_percent, CPU and memory, receive and send Mbps, is_reachable, last_heartbeat_at
ip_poolscidr, gateway_ip, dns_servers, first_ip, last_ip
wireguard_peersuser_id, device_id, server_id, protocol, public_key, allocated_ipv4, applied_at, revoked_at, removed_at, last_handshake_at, bytes received and sent, expires_at
vpn_sessionsuser_id, device_id, server_id, protocol, assigned_ip, bytes_up, bytes_down, started_at, ended_at, duration_seconds, disconnect_reason
ad_reward_eventstransaction_id, user_id, device_id, ad_unit, reward_seconds, status, granted_until
promo_codescode, plan_id, duration_seconds, max_redemptions, per_user_limit, starts_at, expires_at, is_active, note
app_settingskey, value (jsonb), is_public. Values set on the admin panel's Settings, Rewarded ads and App config pages
audit_logsactor, ip, action, target_type, target_id, summary, before, after

Enum values

EnumValues
Sign-in provideremail, phone, google, apple (email and google are used in this version)
Device platformandroid, ios, windows, macos, linux, tv
User statusactive, suspended, pending_deletion, deleted
Server statusactive, maintenance, draining, disabled
VPN protocolwireguard, v2ray (implemented); openvpn_udp, openvpn_tcp, ikev2, shadowsocks, trojan, hysteria (reserved)
Access level0 Free, 5 Ads, 10 Premium
Entitlement sourcefree, reward, subscription, promo, referral, manual
Subscription statusactive, billing_issue, paused, expired, refunded
Disconnect reasonuser_disconnect, idle_timeout, session_limit, quota_exceeded, entitlement_expired, device_revoked, server_shutdown, server_disabled, admin_disconnect, unknown
Code purposeregister, reset_password, change_contact (not used)
Notification statusscheduled, sending, sent, failed, cancelled
Announcement style / platforminfo, warning, promo / all, android, ios
Support messagekind: contact, report_issue; status: open, resolved
App pageprivacy_policy, terms_of_service, about_us

Migrations

npm run migration:show       # list applied and pending migrations
npm run migration:run        # apply pending migrations by hand
npm run migration:revert     # undo the last migration

Backups

pg_dump -Fc -d sable -f sable-$(date +%F).dump        # back up
pg_restore -d sable --clean sable-2026-01-15.dump      # restore

Schedule a daily backup (for example with cron) and keep copies off the server. The backend deletes old session and log data after the number of days set on the admin panel's Settings page (30 by default).

18. FAQ and Common Build Errors

Before anything else

Flutter app

Error or problemFix
"Unsupported class file major version" / Gradle fails to startRun Gradle with JDK 21: flutter config --jdk-dir "<Android Studio>/jbr" (Android Studio's bundled JDK)
"Your project's Gradle / Android Gradle Plugin / Kotlin version is lower than Flutter's minimum"Use Flutter 3.47.6 as tested. On a newer Flutter, raise the versions in android/settings.gradle and android/gradle/wrapper/gradle-wrapper.properties to the values in the message
NDK not foundInstall the NDK version named in the error from Android Studio's SDK Manager > SDK Tools > NDK (Side by side)
Connect does nothing on an Android emulatorExpected: the VPN core is built for ARM phones only. Use a real Android phone
Connect does nothing in the iOS SimulatorExpected: iOS VPN extensions do not run in the Simulator. Use a real iPhone
iOS: Connect fails right away, or "permission denied" for the VPN configurationThe App Group or tunnel bundle ID differs between the targets and the code (section 3), or Network Extensions / App Groups are missing from one target (section 12)
"No matching client found for package name"google-services.json does not match applicationId. Run flutterfire configure again for your package name
Google sign-in fails on Android (error 10 / DEVELOPER_ERROR)Add the SHA-1 and SHA-256 of the key you are running with (debug, upload or Play app signing) in Firebase
Google sign-in crashes on iOSReplace YOUR_IOS_CLIENT_ID in Info.plist (section 5)
No Google button at allFirebase is not configured yet (section 4); the app hides social sign-in without it
"Cannot reach the server" / every list is emptyCheck AUTH_API_BASE_URL (section 8). Release builds need HTTPS. On a phone, localhost is the phone itself: use your computer's network IP in a debug build
Sign-up code never arrivesThe backend cannot send email: check the SMTP_* values and the server log (section 13)
"Device limit reached" at sign-inThe plan allows only that many devices. Sign another device out from the dialog, or raise the plan's device limit in the admin panel
A location shows "Premium" or "Ad"The location's access level is Premium or Ads. Change it on the admin panel's Regions page, or buy Premium / watch an ad
Connected, but no websites loadThe VPN server's firewall blocks UDP 51820 (WireGuard) or TCP 443 (V2Ray), or the server is unhealthy. See section 15, Troubleshooting
CocoaPods errors (pod install fails)cd ios && pod repo update && pod install. If it still fails, delete ios/Pods and ios/Podfile.lock and run pod install again
iOS "No signing certificate" / "requires a development team"Select your Team for both the Runner and VpnTunnel targets
Premium plans do not appear, or buying failsRevenueCat keys missing (section 11), the store product IDs in the admin panel do not match the stores, the app is not installed from a Play testing track / TestFlight, or the user is not signed in
Purchased but still not PremiumThe webhook is not reaching the backend. Check the RevenueCat webhook URL and Authorization header, and the admin panel's Payment events page
Watched an ad but no time was addedServer-side verification is not set on the AdMob ad unit (section 10), or the daily limit was reached. See the log on the Rewarded ads page
Ads show "Test Ad"Expected in debug builds and with the default IDs. Set your own app IDs and ad units (section 10)
Push notifications do not arrive on iOSAdd the Push Notifications capability and upload an APNs key in Firebase (section 5). Test on a real device

Backend, admin panel and VPN servers

See the troubleshooting tables in section 13, section 14 and section 15.

General questions

QuestionAnswer
Are VPN servers included?No. You rent your own servers from any provider and add them in the admin panel; one command sets each one up (section 15).
How many users can one server handle?It depends mostly on its network speed and bandwidth allowance. By default each server accepts 250 connections and each IP pool gives out about 250 addresses; raise both for bigger servers.
Which protocols are supported?WireGuard and V2Ray (VMess over TLS), plus Auto. V2Ray is offered only on plans without a speed limit, because speed limits are applied to WireGuard.
Can I sell Premium on both Android and iOS?Yes, through Google Play and the App Store via RevenueCat (section 11).
Can I add another payment gateway?Yes, as custom development. Plans, gateways and products are separate tables, so a new gateway needs a checkout and a webhook that grants the plan.
Does the app work without Firebase?Yes. Email sign-in, connecting, ads and Premium work; Google sign-in, push, Analytics and Crashlytics are off.
Can I host the backend and admin panel on one server?Yes. Use two nginx server blocks (for example api. and admin. subdomains). Keep VPN servers separate.
What is lib/main.demo.dart?A developer preview entry point (flutter run -t lib/main.demo.dart) that skips Firebase and fakes the signed-in session. It still needs a running backend for servers and plans. Use the normal lib/main.dart for real builds.
Are any files minified or obfuscated?No. All three projects ship as readable source code. The only pre-built binaries are the VPN core libraries in packages/flutter_vpncore/android/libs/ and ios/Frameworks/.

19. Support

Questions before or after purchase? Please use the Support tab on the CodeCanyon item page. All support is handled through the official Envato item support system.

Support covers bugs and questions about the included features. Customisation, and installation on your own servers, are not part of item support.

Before you open a ticket

What to include

A complete first message lets us answer in one reply instead of several:

InformationHow to get it
Your purchase codeCodeCanyon > Downloads > Sable > License certificate and purchase code
Which partFlutter app, backend, admin panel or a VPN server, and the documentation section you were following
Platform and versionsAndroid or iOS and the device model; your computer's OS; for the app the output of flutter doctor -v; for the server the output of node -v and psql --version and the server's OS; for a VPN server the output of lsb_release -a
The exact error messageCopy and paste the text, not a summary. A screenshot of the full screen or terminal helps too
Log outputApp: the red error lines from flutter run, or the in-app debug logs. Backend: pm2 logs sable-api --lines 100. Admin panel: pm2 logs sable-admin --lines 100 and the browser console (F12 > Console). VPN server: journalctl -u vpn-node-agent -n 100
Steps to reproduceWhat you did, what you expected, and what happened instead
Never send secrets. Remove passwords, JWT_ACCESS_SECRET, ADMIN_JWT_SECRET, agent tokens, service-account JSON, API keys and keystore passwords from anything you paste. We will never ask for them.

More resources

20. Changelog

Version 1.0.0 (Initial Release)

21. Upgrading to a New Version

When a new version of Sable is released, download it from your CodeCanyon Downloads page and read its changelog first. Your users, servers, plans, subscriptions and settings live in your PostgreSQL database, not in the code, so updating the code does not delete them.

Before you start

Carrying your own code changes forward

If you edited source files (not only settings), do not copy the new version straight over them. The safest way is Git:

# one time, before your first customisation
cd sable-backend            # or sable-admin, or sable-vpn
git init && git add -A && git commit -m "Sable 1.0.0 original"
# ...make your changes and commit them as usual...

# when a new version arrives
git checkout -b upgrade
# copy the new package files over the folder (keep the files listed above)
git add -A && git commit -m "Sable 1.x.x original"
git checkout main && git merge upgrade      # Git combines both; fix any conflicts it lists

Without Git, compare your old folder and the new one with a diff tool (VS Code: right-click two files > Compare Selected) and re-apply your edits to the new files.

1. Backend

cd sable-backend
# copy the new files over your old folder, keeping your .env
npm ci
npm run build
pm2 reload sable-api

2. VPN servers

If the changelog mentions the node agent, run the update command on each VPN server (section 15). It keeps the server's settings and token.

3. Admin panel

cd sable-admin
# copy the new files over your old folder, keeping .env
npm ci
npm run build
pm2 restart sable-admin

The admin panel stores nothing itself; all data comes from the backend. Upgrade the backend first.

4. Flutter app

22. Open-Source Licences

The app's VPN core is GPL-3.0. Read this before you publish the app. The VPN tunnel on Android and iOS runs on libbox, the mobile library of sing-box (v1.13.16), licensed under the GNU General Public License v3.0 or later. Sable is not affiliated with or endorsed by sing-box or SagerNet.
ComponentWhereLicence
libbox (sing-box)packages/flutter_vpncore/android/libs/libbox.aar, ios/Frameworks/Libbox.xcframework. Rebuild with tool/build_libbox.sh; versions in packages/flutter_vpncore/CORE_VERSION.mdGPL-3.0-or-later, plus a term forbidding use of the sing-box name for a derived product
Code adapted from sing-box-for-androidAndroid VPN service in packages/flutter_vpncore/android/GPL-3.0-or-later
Code adapted from sing-box-for-appleiOS tunnel extension in ios/VpnTunnel/GPL-3.0-or-later
Code from shadowsocks-androidDefaultNetworkListener.ktGPL-3.0-or-later

Each adapted file carries a header naming its source. The full notices are in sable-vpn/NOTICE and the licence text in sable-vpn/LICENSE-GPL-3.0.

What this means for you

Because the app links this core, the Flutter app (sable-vpn/) is licensed under GPL-3.0-or-later (see sable-vpn/LICENSE.md). You may use, change and publish it, commercially too. When you distribute it (for example on Google Play or the App Store) you must:

  1. Make the complete corresponding source code of the app you distribute available to its users under GPL-3.0-or-later, as a public repository link or a written offer valid for at least three years.
  2. Include the GPL-3.0 licence text and the notices. The app's About us page is seeded with this notice and a source-code request address: in the admin panel open App config → Pages → About us and replace support@example.com with your own address. You may reword the page, but keep the notice and the way to get the source.
  3. Not add terms that restrict your users' GPL-3.0 rights.
  4. Not name your app after sing-box or suggest any association with it.

The backend and the admin panel do not include sing-box; they are licensed under the Envato Market licence you purchased, which adds no restriction to the app's GPL-3.0 rights. This section is a summary, not legal advice; if you are unsure, ask a lawyer before you publish.

Other libraries

The app, backend and admin panel install their other libraries from pubspec.yaml and package.json; each keeps its own licence (mostly MIT, BSD and Apache 2.0).

The VPN servers use WireGuard and Xray-core, which the install script downloads from their official sources onto your server; they are not part of the package.

The sable silhouette used in the logo is by Margot Michaud, published under CC0 (public domain) on PhyloPic.

23. AI Disclosure

AI tools were used to help create parts of this item:

All of it was reviewed, tested and finished by the author.

The item has no AI features: the app, the backend and the admin panel do not use or call any AI service or model.