Install and publish Audisode

Everything you need to go from the zip file to a running backend, an admin panel with your first books, and an app on Google Play and the App Store. You can do all of it with this guide alone.

Welcome

Audisode is an audiobook and episodic-series platform. It has two parts: a Flutter app for Android and iOS that your listeners use, and a Laravel backend with an admin panel that you use in a web browser to run the business: books, prices, listeners, notifications and settings.

This guide uses the same words throughout. The backend is the server. The admin panel is the website you sign in to at /login. The app is the mobile app. A listener is a person using the app. Coins are the app's currency, Premium is the subscription that unlocks everything, and a coin pack is a bundle of coins sold as an in-app purchase.

The app's home screen with the daily rewards strip, continue listening and featured books
Home
A book page with its cover, rating, summary and the list of episodes with free and priced ones
A book and its episodes
The audio player with a waveform, speed, sleep timer and skip buttons
Player
The episode list opened from the player
Episode list
The library with the books a listener is part-way through
Library
The profile page with coins, activity, invite friends and settings
Profile
The sign-in page with email, Google and Apple
Sign in

What is included

Every feature below exists in the code you received.

Listening

  • Single audiobooks and series of episodes
  • Streaming and offline downloads
  • Sleep timer, speed control, bookmarks
  • Progress that follows the listener across devices
  • Reviews and ratings
  • Follow authors, recommendations
  • Activity page with listening time
  • Shareable book links that open the app

Monetisation

  • Coins: welcome gift, daily check-in streak, rewarded ads, invites, coin packs
  • Premium subscriptions (unlock everything, no ads)
  • In-app purchases through RevenueCat
  • AdMob banner, full-screen and rewarded ads with a consent form
  • Server-side wallet: the app cannot grant itself coins

Engagement

  • Push notifications: new episodes, streak reminders, announcements
  • Invite friends with rewards for both
  • Google, Apple and email sign-in
  • Four app languages, including right-to-left Arabic
  • Light and dark themes

Admin

  • Dashboard with analytics and a "needs attention" list
  • Books, episodes, authors, categories, languages
  • Bulk upload of whole seasons
  • Home-screen builder and collections
  • AI narration (optional)
  • Listeners, reviews, notifications
  • Branding, team (more than one admin)
  • Web installer, read-only demo admin
  • Audio on your server or S3-compatible storage

Quick start

The whole job in six steps. Each links to the section that explains it.

  1. Install the backend. Upload it, create a database, run the installer. Section 4.

  2. Add content. Languages, authors, books and episodes in the admin panel. Section 7.

  3. Set up Firebase. Google and Apple sign-in, and push notifications. Section 9.

  4. Make the app yours. Name, package ID, icons, colours, backend address. Section 15.

  5. Set up RevenueCat. Coin packs and Premium. Section 10.

  6. Build and publish. Android bundle, iOS archive, store listings. Section 16.

About this guide

This is Audisode version 1.0.0. The guide was built on 4 October 2026 from the code in this package. What changed in each release is in the changelog. Quote the version number when you ask for support: it is also shown in the admin panel's user menu and when you run php artisan app:install.

The two developer READMEs stay in the package: audisode_backend/README.md and audisode/README.md. This guide points to them where deeper detail lives, but you should not need them to install, configure and publish.

1How it fits together

Your listeners use the app. The app talks to your backend for everything that matters: the catalogue, coins, unlocks, and the links to the audio. You use the admin panel, which is part of the backend, to manage it all. Around them sit a few outside services, shown in the diagram.

How the app, the backend and the outside services connect The app talks to your backend, to Firebase for sign-in, to the RevenueCat SDK for purchases and to AdMob for ads. The backend talks to Firebase, to RevenueCat, receives a callback from AdMob, and can use S3 or R2 for audio storage and OpenAI or ElevenLabs for AI narration. The app Flutter, Android and iOS Firebase Sign-in, push notifications RevenueCat Purchases and Premium AdMob Ads (optional) S3 or R2 bucket Audio in the cloud (optional) OpenAI or ElevenLabs AI narration (optional) Your backend API /api Admin panel /login MySQL database Private audio folder Scheduler and queue 1 2 3 4 5 6 7 8 9

Dashed outlines and lines mark the optional parts.

  1. App and backend. Everything the app shows and does goes over HTTPS as JSON to the backend's /api.
  2. App and Firebase. Google and Apple sign-in happen in Firebase. The app then sends Firebase's proof of identity (an ID token) to your backend.
  3. App and RevenueCat. The RevenueCat SDK in the app shows the store prices and runs the purchase.
  4. App and AdMob. The AdMob SDK in the app loads banner, full-screen and rewarded ads.
  5. Backend and Firebase. The backend checks every sign-in token with Firebase, and sends push notifications through Firebase Cloud Messaging.
  6. Backend and RevenueCat. The backend asks RevenueCat's REST API what a listener owns. RevenueCat also calls the backend's webhook when something changes.
  7. AdMob and backend. When a listener finishes a rewarded ad, AdMob calls /api/admob/ssv with a signed message. Only then are coins granted.
  8. Backend and cloud storage (optional). Audio can sit in a private S3 or Cloudflare R2 bucket instead of your server.
  9. Backend and AI voices (optional). The AI narration page sends text to OpenAI or ElevenLabs and stores the audio they return.

2Requirements

Check these before you start. The accounts take the longest: some need approval from Apple or Google, which can take days.

2.1Server

ItemWhat you need
PHP8.2 or newer with the openssl, pdo_mysql, mbstring, gd, fileinfo, curl and zip extensions, plus the extensions every Laravel host already has. The installer checks all of this for you.
DatabaseMySQL 8 or MariaDB 10.6 or newer, with an empty database and a user that has all privileges on it.
HTTPSRequired. The app only talks to https:// addresses in release builds, and stores and sign-in providers expect it.
CronOne entry that runs every minute (shown in section 4). It sends notifications, makes AI narration and does daily clean-up.
Outgoing HTTPSThe server must be allowed to call out to the licence server (appentium.com), Firebase, RevenueCat and, if you use them, Google's ad verification keys, OpenAI or ElevenLabs, and your cloud storage.
Disk and bandwidthAudio is large. An hour at a typical 64 to 128 kbps MP3 is about 30 to 60 MB. Pick a plan with room for your library, or put audio in cloud storage.
Upload limitsPHP limits how much one upload may hold (upload_max_filesize, post_max_size, max_file_uploads). The Bulk upload page shows yours. Section 7.4 explains how to raise them.
SSH and ComposerHelpful, not required. With SSH you run two commands. Without it, use the web installer and a package that already contains vendor/. Composer 2 is needed only if the package has no vendor/ folder.

2.2Accounts

AccountUsed forCost noteStep
Licence from appentium.comThe licence key and username that activate your copy for one domainIncluded with your purchase4
Hosting and a domainBackend and admin panelPaid4
FirebaseGoogle and Apple sign-in, push notificationsFree plan available9, 12
RevenueCatCoin packs and Premium for both storesFree tier available; see RevenueCat's pricing page10
Google Play ConsolePublishing on Android; Android in-app productsOne-time registration fee10, 16
Apple Developer ProgramPublishing on iOS; iOS in-app products; Sign in with Apple and its key; pushYearly fee9, 10, 16
AdMob OptionalReal ads. Without it the app shows Google's test ads, which earn nothingFree account11
OpenAI or ElevenLabs OptionalAI narrationBilled by the provider per character7.5
S3 or Cloudflare R2 OptionalAudio in the cloudStorage and bandwidth billed by the provider14

2.3Your computer

ToolNotes
Flutter SDKStable channel. The app was built and tested with Flutter 3.44. Follow Flutter's install guide and finish with flutter doctor.
Android StudioFor the Android SDK, an emulator and Java 17 (the project builds with Java 17).
Xcode and CocoaPodsOn a Mac only, for iOS. The app supports iOS 15 and newer.
Firebase CLIThe FlutterFire tool in section 9 needs it installed and signed in (firebase login).
Composer 2Only if your package has no audisode_backend/vendor folder.

3What is in the package

Unzip the package. This is what you will find:

folders
audisode-v1.0.0/
  START_HERE.html          opens this guide
  CHANGELOG.md             what changed in each version
  THIRD_PARTY_NOTICES.md   licences of the open-source parts
  docs/                    this guide (index.html), its images, a PDF copy
  audisode_backend/        the server: Laravel 12 API and admin panel
  audisode/                the mobile app: Flutter, Android and iOS
FolderWhat it is
audisode_backend/The backend. It also holds README.md for developers (API, deeper server detail) and .env.example, the template for your settings file. If a vendor/ folder is inside, the PHP packages are already installed.
audisode/The app. Its README.md is the developer reference for app setup.
docs/This guide. Open index.html in any browser; it needs no internet.

3.1What is not included, and why

Nothing that belongs to a previous owner is in the package. You create your own:

  • Your .env file, with your database password and keys. You make it from .env.example.
  • Your Android keystore and android/key.properties. See section 16.1.
  • Firebase files: google-services.json, GoogleService-Info.plist and lib/firebase_options.dart. The app will not build until you create them (section 9).
  • Ad IDs: the app ships with Google's test IDs.
  • Apple team: you choose yours in Xcode.

4Install the backend~20 minServer

You need: hosting with PHP 8.2 or newer and a MySQL or MariaDB database, a domain with HTTPS, and the licence key and username from your purchase.

Pick the path that matches your hosting. All three end in the same place: an admin panel you can sign in to.

4.1A. Server with SSHRecommended

  1. Create a database and a user. In your hosting panel, or with these commands in the MySQL client (change the names and the password):

    sql
    CREATE DATABASE audisode CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
    CREATE USER 'audisode_user'@'localhost' IDENTIFIED BY 'choose-a-strong-password';
    GRANT ALL PRIVILEGES ON audisode.* TO 'audisode_user'@'localhost';
  2. Upload the audisode_backend folder to your server. If your host allows it, put it outside the public web folder.

  3. Point the domain's document root at the public/ folder inside it. This matters: everything else in the folder (your .env file, logs, source code) must not be reachable from the web.

  4. Install the PHP packages. Skip this step if the folder already contains vendor/.

    bash
    cd /path/to/audisode_backend
    composer install --no-dev --optimize-autoloader
  5. Create your settings file.

    bash
    cp .env.example .env

    Open .env and set at least these lines. Leave APP_ENV=production, APP_DEBUG=false and APP_KEY (the installer fills it in).

    dotenv
    APP_NAME="Your Brand"
    APP_URL=https://your-domain.com
    DB_DATABASE=audisode
    DB_USERNAME=audisode_user
    DB_PASSWORD=choose-a-strong-password
    MAIL_MAILER=smtp
    MAIL_HOST=mail.your-domain.com
    MAIL_PORT=587
    MAIL_USERNAME=hello@your-domain.com
    MAIL_PASSWORD=your-mail-password
    MAIL_FROM_ADDRESS="hello@your-domain.com"

    APP_NAME is your brand name. It appears in the admin panel, the sign-in pages, emails and the public website. Every key is explained in section 5.

  6. Run the installer.

    bash
    php artisan app:install --demo

    It prints Installing Audisode v1.0.0, asks for your licence key and username (the email you bought with) and activates the licence for your domain. Then it creates the tables and default settings, adds an English language and a few categories (because of --demo), and asks for the admin email and password you will sign in with (at least 8 characters).

    It ends with a configuration checklist. Lines marked TODO are normal at this point: they are the later sections of this guide. The command is safe to run again.

    These options skip the questions, which helps in scripts:

    OptionWhat it does
    --license-key= and --license-username=The licence from your purchase.
    --admin-email= and --admin-password=The admin account.
    --demoAdds an English language and a few categories, so you can add books straight away.
    --sample-booksAlso adds seven English sample audiobooks with authors and covers, so the app has something in it from the first minute. Delete them before you launch. Includes what --demo adds.
  7. Add the scheduler to cron. Run crontab -e and add this line, with your path. It runs push notifications, AI narration jobs and the daily clean-up:

    cron
    * * * * * cd /path/to/audisode_backend && php artisan schedule:run >> /dev/null 2>&1
  8. Check that your settings file is not public. This must print 404 or 403, never 200:

    bash
    curl -s -o /dev/null -w "%{http_code}\n" https://your-domain.com/.env
  9. Sign in at https://your-domain.com/login with the admin email and password you chose. Continue with the first sign-in below.

4.2B. cPanel shared hosting, no SSH

You do not need a terminal. The backend has a web installer that asks the same questions in your browser. It needs a package that already contains the PHP packages.

  1. Check for audisode_backend/vendor. If the folder exists, carry on. If it does not, run composer install --no-dev --optimize-autoloader inside audisode_backend on your own computer first, then upload the folder together with the new vendor/.

  2. Create a database and a user in cPanel under MySQL Databases. Add the user to the database with all privileges. cPanel puts your account name in front of both names (for example account_audisode): use the full names later.

  3. Choose PHP 8.2 or newer in MultiPHP Manager, and in Select PHP Version make sure the extensions pdo_mysql, openssl, mbstring, gd, fileinfo, curl and zip are ticked.

  4. Upload and unzip the audisode_backend folder with File Manager. Turn on Show Hidden Files and check that .env.example and .htaccess are there: hidden files are easy to lose.

  5. Point the domain at audisode_backend/public. In cPanel open Domains (or Subdomains) and set the document root of your domain or subdomain to that folder.

  6. Open your site in a browser. A copy that is not installed yet sends every page to /install. If a screen says it cannot write .env, make the project folder writable (permissions 775) for the installation and put it back afterwards.

  7. Click through the installer. It has five screens.

Installer step 1: a checklist of PHP version, PHP extensions, writable folders and the .env file, all ticked green, with a Continue button
Screen 1, Server. A look at your server. Every line must be ticked before you can continue; each red line says how to fix it.
Installer step 2: Activate your licence, with fields for licence key and username
Screen 2, Licence. Enter the key and username from your purchase. The licence covers one domain. Installing again on the same domain is free. The server must be able to reach appentium.com.
Installer step 3: Connect the database, with host, port, database name, username and password fields
Screen 3, Database. The details from step 2. On shared hosting the host is usually localhost. The installer tests the connection before going on and explains what went wrong if it cannot connect.
Installer step 4: Site, with the site name, site address and optional mail server fields
Screen 4, Site. Your brand name and your https address, and optionally a mail server so password-reset emails really send. You can set mail later in .env.
Installer step 5: Create the admin account, with email, password, repeat password and two checkboxes for starter and sample content
Screen 5, Admin. The email and password you will sign in with. The two boxes add a starter language and categories, and seven sample audiobooks. Click Install; it takes about a minute.
The finish page: Audisode is installed, the cron line to add, and a Still to do checklist
The finish page shows the cron line to add and the same checklist as app:install. Items still open are normal.
  1. Add the cron job. In cPanel open Cron Jobs, choose Once Per Minute and paste the command shown on the finish page. It looks like this:

    cron
    * * * * * cd /home/youraccount/audisode_backend && php artisan schedule:run >> /dev/null 2>&1

    If php on your host is an older version than 8.2, use the full path of PHP 8.2 instead (on cPanel it is often /opt/cpanel/ea-php82/root/usr/bin/php).

  2. Check that your settings file is not public. Open https://your-domain.com/.env in a browser. You must see an error page (404 or 403), never a list of settings.

When the installer has finished, every /install page is a 404. It only runs while there is no storage/app/installed file and no APP_KEY, so a site that is already set up is never offered the installer. To install from scratch again, delete .env and storage/app/installed.

4.3C. Your own computer

The quickest way to see the admin panel working. You need PHP 8.2 or newer and Composer 2 on your computer. The licence step still asks for your key, so you need internet. Local addresses (localhost, *.test and private IP addresses) do not use up an installation of your licence.

  1. Install the packages (skip it if vendor/ exists) and create the settings file:

    bash
    cd audisode_backend
    composer install
    cp .env.example .env
  2. Edit .env for development. A SQLite file is the easiest database: create an empty file and point to it with an absolute path.

    bash
    touch database/database.sqlite
    dotenv
    APP_ENV=local
    APP_DEBUG=true
    APP_URL=http://127.0.0.1:8000
    DB_CONNECTION=sqlite
    DB_DATABASE=/full/path/to/audisode_backend/database/database.sqlite
    SESSION_DRIVER=file
    CACHE_STORE=file
    MAIL_MAILER=log

    Or use a local MySQL database exactly as in path A. APP_DEBUG=true shows error details; never use it on a live server. MAIL_MAILER=log writes emails to storage/logs/laravel.log instead of sending them.

  3. Install and start:

    bash
    php artisan app:install --sample-books
    php artisan serve

    Open http://127.0.0.1:8000/login.

4.4Your first sign-in

Go to https://your-domain.com/login and sign in with the admin email and password you chose. After five wrong tries the sign-in pauses for a short while.

The admin sign-in page with the email and password fields
The admin sign-in page. "Forgot your password?" sends a reset link by email, so set up mail first.

The dashboard is your home page: listeners, active listeners, Premium subscribers, estimated revenue, a chart of new listeners or coin flow, your top books and the coin economy. Switch between 7, 30 and 90 days at the top; every number is compared with the period before.

The admin dashboard with listener and revenue cards, an activity chart, a Needs attention list, top books and the coin economy
The dashboard. The Needs attention list at the right tells you what to do next.

What "Needs attention" means

This list shows problems and unfinished setup. Each item links to the page that fixes it:

  • The licence needs attention, or a new version is available.
  • Narrations failed, or are in progress.
  • Single-audio books without audio, and series without episodes: listeners cannot play these yet.
  • Notifications failed this week: check the Firebase credentials.
  • Low ratings this week (one and two stars) worth a look.
  • In-app purchases are not set up, or the RevenueCat webhook is not secured: see section 10.
  • Push notifications are not set up: see section 12.
  • AI narration is not set up: optional, see section 7.5.

4.5Background jobs and the queue

Push notifications and AI narration run in the background. On hosting without a process manager you do nothing extra: the cron entry above runs the scheduler every minute, and the scheduler starts a short-lived queue worker each time. That is enough on shared hosting.

If you run the worker yourself with Supervisor or systemd, tell the scheduler not to start its own, and keep the retry time longer than your longest job:

dotenv
QUEUE_RUN_VIA_SCHEDULER=false
DB_QUEUE_RETRY_AFTER=960

DB_QUEUE_RETRY_AFTER must stay above 900 seconds, because a long AI narration can take up to 15 minutes. A sample Supervisor configuration:

ini
[program:audisode-worker]
command=php /path/to/audisode_backend/artisan queue:work --sleep=3 --tries=3 --max-time=3600
process_name=%(program_name)s_%(process_num)02d
numprocs=1
user=www-data
autostart=true
autorestart=true
stopwaitsecs=3600
redirect_stderr=true
stdout_logfile=/path/to/audisode_backend/storage/logs/worker.log

After you deploy an update, run php artisan queue:restart so the worker picks up the new code. The scheduler still needs its cron line: it sends the daily reminders and does the clean-up.

4.6File permissions

The web server user must be able to write to storage/ and bootstrap/cache/. If you see a blank page or a 500 error after uploading, this is the usual cause. On a server with SSH (replace www-data with your web server's user):

bash
cd /path/to/audisode_backend
chown -R www-data:www-data storage bootstrap/cache
find storage bootstrap/cache -type d -exec chmod 775 {} \;
find storage bootstrap/cache -type f -exec chmod 664 {} \;

On cPanel, select those two folders in File Manager and set the permissions to 775.

5The .env reference

The .env file in audisode_backend holds your settings and secrets. This section lists every line of .env.example, in the same order, so you can see what each one does.

Where a value contains spaces or a #, put it in double quotes. Anything marked Leave works as it is.

5.1App

The basics. Most of these you set once.

KeyRequired?ExampleWhat it does and where to get it
APP_NAMERequired"Your Brand"Your brand name. It shows in the admin panel, sign-in pages, emails and the public website. Settings > Branding can override it on web pages.
APP_ENVRequiredproductionproduction on a live server, local on your own computer.
APP_KEYAutomatic(empty)Encrypts sessions. The installer fills it in. Never share it. If it ever leaks, run php artisan key:generate --force (this signs admins out).
APP_DEBUGRequiredfalseMust be false on a live server: true shows error details that include secrets.
APP_URLRequiredhttps://your-domain.comYour address with https:// and no trailing slash. The licence is activated for this domain, and emails and shared links use it.
APP_LOCALELeaveenLanguage of messages the server writes. Leave as en.
APP_FALLBACK_LOCALELeaveenUsed when a translation is missing. Leave.
APP_FAKER_LOCALELeaveen_USOnly for test-data generators. Leave.
APP_MAINTENANCE_DRIVERLeavefileWhere php artisan down keeps maintenance mode. Leave.
APP_MAINTENANCE_STORELeavedatabaseCommented out in the template. Only matters if the driver is cache.
PHP_CLI_SERVER_WORKERSLeave4Workers for php artisan serve on your own computer. Not used on a live server.
BCRYPT_ROUNDSLeave12How hard passwords are to crack. Leave.
LOG_CHANNELLeavestackWhere logs go. With the defaults they are in storage/logs/laravel.log.
LOG_STACKLeavesingleOne log file. Leave.
LOG_DEPRECATIONS_CHANNELLeavenullLeave.
LOG_LEVELOptionaldebugHow much is logged. warning keeps the file smaller on a busy live server.

5.2Database

The empty database you created in the install step.

KeyRequired?ExampleWhat it does and where to get it
DB_CONNECTIONRequiredmysqlmysql for MySQL and MariaDB. sqlite is for your own computer.
DB_HOSTRequired127.0.0.1The database server. On shared hosting it is usually localhost.
DB_PORTRequired3306Leave unless your host says otherwise.
DB_DATABASERequiredaudisodeThe database name. On cPanel it starts with your account name. With SQLite, the full path of the file.
DB_USERNAMERequiredaudisode_userThe database user. Change it from the template's root.
DB_PASSWORDRequired(a strong password)The database user's password.

5.3Sessions, cache and queue

These work as they are. The backend runs on the database drivers, so you need no Redis or Memcached.

KeyRequired?ExampleWhat it does and where to get it
SESSION_DRIVERLeavedatabaseWhere admin sign-ins are kept.
SESSION_LIFETIMELeave120Minutes of inactivity before the admin panel signs you out.
SESSION_ENCRYPTLeavefalseLeave.
SESSION_PATHLeave/Leave.
SESSION_DOMAINLeavenullLeave empty. A wrong value here causes "419 Page Expired" (see troubleshooting).
BROADCAST_CONNECTIONLeavelogNot used by Audisode. Leave.
FILESYSTEM_DISKLeavelocalLeave. Audio has its own setting, MEDIA_DISK.
QUEUE_CONNECTIONLeavedatabaseBackground jobs wait in the database. Leave.
CACHE_STORELeavedatabaseLeave.
CACHE_PREFIXLeave(commented out)Only if several sites share one cache.
MEMCACHED_HOSTLeave127.0.0.1Only used if you switch the cache to Memcached.
REDIS_CLIENTLeavephpredisOnly used if you switch cache, sessions or queue to Redis.
REDIS_HOSTLeave127.0.0.1As above.
REDIS_PASSWORDLeavenullAs above.
REDIS_PORTLeave6379As above.

5.4Mail

Password-reset emails for admins and for app users, and admin invitations. Until you set this up, emails are only written to the log.

KeyRequired?ExampleWhat it does and where to get it
MAIL_MAILERRequiredsmtplog (the template's value) writes emails to storage/logs/laravel.log instead of sending them. Use smtp, or mailgun, ses, postmark.
MAIL_SCHEMEOptionalnullLeave empty for the usual. Set smtps if your provider needs SSL on port 465.
MAIL_HOSTRequiredmail.your-domain.comYour mail server. Your hosting panel or email provider shows it.
MAIL_PORTRequired587Usually 587 (TLS) or 465 (SSL).
MAIL_USERNAMERequiredhello@your-domain.comThe mail account.
MAIL_PASSWORDRequired(its password)The mail account's password.
MAIL_FROM_ADDRESSRequired"hello@your-domain.com"The sender address. Use one on your own domain, or mail may land in spam.
MAIL_FROM_NAMEOptional"${APP_NAME}"The sender name. By default it is your APP_NAME.
AWS_ACCESS_KEY_IDLeaveLaravel's own Amazon settings. Audisode uses them only if you send mail through Amazon SES. Audio in the cloud uses the MEDIA_S3_* keys below.
AWS_SECRET_ACCESS_KEYLeaveAs above.
AWS_DEFAULT_REGIONLeaveus-east-1As above.
AWS_BUCKETLeaveAs above.
AWS_USE_PATH_STYLE_ENDPOINTLeavefalseAs above.

5.5Admin and demo admin

Only used by php artisan db:seed, the alternative to app:install. The installer and app:install ask you for the admin email and password instead.

KeyRequired?ExampleWhat it does and where to get it
ADMIN_EMAILOptionaladmin@example.comEmail of the admin that db:seed creates.
ADMIN_PASSWORDOptional(empty)Leave empty and db:seed generates a random password and prints it once.
SEED_DEMO_ADMINOptionalfalsetrue also creates the read-only demo admin when seeding. See hosting a demo.
DEMO_ADMIN_EMAILOptionaldemo@example.comSign-in email of the demo admin. Use an address that is not a real mailbox.
DEMO_ADMIN_PASSWORDOptional(a password)Sign-in password of the demo admin. Empty means a random one is printed once.

5.6Firebase sign-in

So the backend can check who is signing in, and end a listener's Sign in with Apple when they delete their account. See section 9.

KeyRequired?ExampleWhat it does and where to get it
FIREBASE_PROJECT_IDRequiredyour-firebase-project-idFirebase console > Project settings > General > Project ID. Without it nobody can sign in with Google or Apple.

5.7RevenueCat

Coin packs and Premium. See section 10.

KeyRequired?ExampleWhat it does and where to get it
REVENUECAT_SECRET_KEYRequiredsk_…RevenueCat > Project settings > API keys, the secret key. It stays on the server.
REVENUECAT_IOS_API_KEYRequiredappl_…The public SDK key of your iOS app. The app reads it from the backend, so changing it needs no app rebuild.
REVENUECAT_ANDROID_API_KEYRequiredgoog_…The public SDK key of your Android app.
REVENUECAT_WEBHOOK_AUTHRequireda-long-random-secretAny long random string. You paste the same value into RevenueCat's webhook settings. Without it, renewals and refunds do not reach the backend.
REVENUECAT_ENTITLEMENTOptionalpremiumThe name of the entitlement your Premium plans unlock. Keep premium unless you named it differently.

5.8App identity

Fallbacks for shared book links. Settings > App links takes precedence.

KeyRequired?ExampleWhat it does and where to get it
GOOGLE_PLAY_PACKAGE_NAMEOptionalcom.yourcompany.yourappYour Android package name. Only used if Settings > App links has none.
APPLE_BUNDLE_IDOptionalcom.yourcompany.yourappYour iOS bundle ID. Only used if Settings > App links has none.

5.9Revenue currency

KeyRequired?ExampleWhat it does and where to get it
REVENUE_CURRENCYOptionalUSDThe currency of the dashboard's revenue estimates. Once you save Settings > Store products, its Price currency is used instead.

5.10Rewarded ads

See section 11.

KeyRequired?ExampleWhat it does and where to get it
ADMOB_SSV_ENABLEDRequiredtruetrue (the default): coins for a rewarded ad are granted only by AdMob's signed callback. Set false while testing with Google's test ads, which never call your server.

5.11Audio and cloud storage

See section 14. With the defaults, audio stays on your server.

KeyRequired?ExampleWhat it does and where to get it
MEDIA_URL_TTL_MINUTESOptional1440How long the signed audio links given to the app stay valid. With cloud storage the maximum is 10080 (7 days).
MEDIA_DISKOptionalmediamedia keeps audio on your server. media_cloud uses the bucket below.
MEDIA_S3_KEYCloud onlyAccess key of the bucket.
MEDIA_S3_SECRETCloud onlySecret key of the bucket.
MEDIA_S3_REGIONCloud onlyautoauto for Cloudflare R2, for example us-east-1 for AWS.
MEDIA_S3_BUCKETCloud onlyaudisode-audioName of the private bucket.
MEDIA_S3_ENDPOINTCloud onlyhttps://ACCOUNT.r2.cloudflarestorage.comThe provider's endpoint. Empty for AWS S3.
MEDIA_S3_PATH_STYLECloud onlyfalsetrue for providers that need path-style addresses (some self-hosted stores).

5.12Push notifications

See section 12.

KeyRequired?ExampleWhat it does and where to get it
FIREBASE_SERVICE_ACCOUNT_JSONPush/home/you/keys/firebase-service-account.jsonFull path of a service account key file with the Firebase Cloud Messaging API Admin role. Keep it outside the web root.
FCM_PROJECT_IDOptional(empty)Defaults to FIREBASE_PROJECT_ID. Only set it if push uses a different project.

5.13AI narration

Optional. See section 7.5. Providers bill per character.

KeyRequired?ExampleWhat it does and where to get it
TTS_PROVIDERAIopenaiopenai or elevenlabs.
OPENAI_API_KEYAIsk-…Your OpenAI API key, if you use OpenAI.
OPENAI_TTS_MODELAIgpt-4o-mini-ttsThe OpenAI speech model.
ELEVENLABS_API_KEYAI(your key)Your ElevenLabs API key, if you use ElevenLabs.
ELEVENLABS_MODELAIeleven_multilingual_v2The ElevenLabs model.
ELEVENLABS_VOICESAI"Rachel:21m00Tcm4TlvDq8ikWAM,Adam:pNInz6obpgDQGcFmaJgB"The voices offered on the AI Narration page, as Name:voice_id pairs separated by commas.
TTS_MAX_CHARACTERSAI50000The longest text one narration may have.

5.14Background jobs

See the queue.

KeyRequired?ExampleWhat it does and where to get it
QUEUE_RUN_VIA_SCHEDULEROptionaltruetrue: the cron entry also runs the queue. Set false only if Supervisor or systemd runs queue:work.
DB_QUEUE_RETRY_AFTEROptional960Seconds before a stuck job is retried. Keep it above 900.

5.15Keys that are not in the template

You can add these lines yourself.

KeyRequired?ExampleWhat it does and where to get it
SANCTUM_EXPIRATIONOptional525600How long a listener stays signed in on a phone, in minutes. The default is a year. Empty means never expires. After it passes, the app asks the listener to sign in again.
LICENSE_CHECKDo not useFor the automated tests. Leave it out of .env.

6The admin panelAdmin panel

The admin panel is where you run the business. This section walks through each page in the order of the left-hand menu. Sign in at https://your-domain.com/login.

  • Press Ctrl or ⌘ + K anywhere to search books, episodes, authors and listeners, or to jump to a page. Press / to focus the search box on the page you are on.
  • The Create button at the top has shortcuts for a new book, episode, bulk upload, AI narration and notification.
  • The half-moon button at the top switches between light, dark and system themes.
  • The menu at the top right holds your Account, the app settings, a link to your public website, and the product version.
  • A small orange dot next to AI Narration or Notifications means that feature is not set up yet.

6.1Dashboard

The home page, shown in section 4. Use it to see how the business is doing and what needs doing.

  • Switch between 7, 30 and 90 days. Every number is compared with the period before.
  • Cards for listeners, active listeners, Premium subscribers, estimated Premium income per month, estimated coin pack income and invited listeners.
  • An activity chart (new listeners, or coin flow), your top books by listeners, coins spent and views, and the coin economy: coins earned and spent, and where they came from.
  • The Needs attention list of problems and unfinished setup, each linking to the page that fixes it.

6.2Books

Your catalogue. A book is either a single audio (one file) or a series (episodes).

The Books page: tabs for All, Series, Single audio and Featured, filters, and a table of books with cover, format, price, rating and views
Books. Seven sample titles; the star marks a book that appears under Featured on the home screen.
  • Search, and filter by format, category, language, author or featured. Sort by newest, oldest, title, highest rated or most viewed. Switch between a table and a cover grid.
  • New book asks for the title, summary, author, category, language, a cover image and, for a single audio, the price in coins and the audio.
  • For a series the price is set on each episode instead, and you add the episodes on the Episodes page or with Bulk upload.
  • A price of 0 makes a book free. Premium subscribers always listen for free.
  • Star a book to feature it on the home screen.
  • The filter links from Needs attention show books without audio and series without episodes.
The New book form with title, summary, author, category, language, format, price and cover
The new book form.

6.3Episodes

Open a series to see its episodes in order.

The episodes of one series in order, with titles, numbers, prices and audio
The episodes of one series.
  • Add episodes one at a time: title, number, price, and the audio (upload or link).
  • Preview each episode's audio on the page.
  • Set a price per episode. Making the first few free is the usual way to hook listeners.
  • Episode numbers must be unique within a series.

6.4Bulk upload

Add a whole season in one go. Section 7.4 explains it step by step.

The Bulk upload page with a series picker, numbering and price, and a drop zone for audio files
Bulk upload, with the server's upload limits shown at the bottom.
  • Choose the series, the first episode number, the price per episode and how many are free.
  • Drop the audio files in. A preview table shows the order, titles and sizes before you send anything.
  • The page checks the files against your server's upload limits and warns you before uploading.

6.5Home screen

Decides what listeners see when they open the app.

The Home screen page with a list of sections, each with a drag handle, an optional heading field and an on or off switch
Home screen. Drag to reorder, or use the arrows.
  • Sections you can show, hide and reorder: Premium offer, Daily check-in, Continue listening, Recommended for you, Featured, Recently added, New episodes, Series, Browse by category, Trending this week, Listeners are saying, Free to listen, Your week, Top rated, Top 10, Author spotlight and Authors.
  • Sections tagged Personal are filled in by the app from each listener's own activity. You only decide whether they appear and where.
  • Most sections let you type a heading of your own.
  • Collections you create appear here too. Reset restores the original order.

6.6Collections

Hand-picked shelves, such as "Staff picks" or "Summer thrillers".

The Collections page with two collections and the books in each
Collections.
  • Create a collection with a heading and a short description, then add books and put them in order.
  • A new collection is placed under Featured on the home screen; move it on the Home screen page.

6.7Authors

  • Add authors with a name, a photo and a short bio.
  • Authors who have a bio can be featured in the home screen's Author spotlight, and the bio shows on their page in the app.
  • Listeners can follow an author and get an alert when a new episode arrives.
The Authors page with each author's photo, name, bio and book count
Authors.

6.8Categories

The topics listeners browse by, such as Fiction or Kids. Add, rename and delete them here.

The Categories page with a list of categories and their book counts
Categories.

6.9Languages

The languages of your audiobooks. Listeners pick one in the app, and the home screen shows books in that language. This is separate from the language of the app's own text; see section 15.9.

The Languages page with one language
Languages.

6.10AI NarrationOptional

Paste text and a voice reads it aloud. See section 7.5.

The AI Narration page with a form for book, voice, episode title, number, price and text, and a list of recent narrations
AI Narration. The job list refreshes itself while audio is made.
  • Choose a book and a voice, paste the text. For a series it becomes a new episode (title, number, price); for a single audio it replaces the audio.
  • Long text is split on sentence boundaries and joined into one MP3.
  • Failed jobs can be retried or deleted. It needs an API key in .env.

6.11Listeners

Everyone who uses the app.

One listener's page with coin balance, listening time, coin history and an Adjust coins form
A listener's page.
  • Search by name, email or invite code. Filter by Premium, suspended or active.
  • Each listener's page shows listening time, books started, unlocks, recently played, subscriptions, coin pack purchases and the full coin history.
  • Adjust coins: set a new balance and give a reason. The listener sees the reason in their coin history.
  • Suspend signs the listener out on every device and blocks the app. Store subscriptions keep billing; refund them in Google Play Console or App Store Connect if needed. You can restore the account later.

6.12Reviews

The Reviews page with a rating summary, filters and a list of reviews
Reviews.
  • A rating summary, search and filters, including low ratings.
  • Hide removes a review from the app and from the book's rating, and you can bring it back. Delete removes it for good.

6.13NotificationsOptional

Send an announcement to listeners' phones. It needs Firebase push set up first: section 12.

The Notifications page with a form for title, message, what happens when tapped and who to send to, and a lock-screen preview
Notifications, with a preview of how it looks on the lock screen.
  • Write a title and message, choose who gets it (everyone, Premium subscribers, or free listeners), and choose whether tapping opens the app or a particular book.
  • The page shows how many devices each audience reaches.
  • New-episode alerts and check-in reminders go out automatically; you only switch them on in Settings.

6.14Settings

How the app rewards, sells and talks to listeners. Changes reach the app the next time it loads its settings. Use the list at the left to jump between sections, then press Save settings at the bottom. There are nine sections.

Coins & rewards

Settings: Welcome coins and Rewarded ad fields, and the Daily check-in seven-day grid
Coins & rewards, and below it Daily check-in.

Welcome coins are given once when a listener creates an account. Rewarded ad is the number of coins for watching a rewarded video to the end.

Daily check-in

Coins for each day of a seven-day streak. Missing a day starts the streak over. The page adds up a full week for you.

Ads

Settings: Ads section with switches for ads, banner ads and full-screen ads, and frequency fields
Ads.

A master switch for every ad, then banner ads, full-screen ads, how often a full-screen ad may appear (at most one every N plays and never more often than the interval), and how many rewarded ads one listener may watch per day (0 means no limit). Premium subscribers never see ads.

Invite friends

Every listener has an invite code. A new listener can enter a friend's code in their first 7 days and both get coins. Set the rewards, and the monthly limit of rewarded invites per inviter (0 means no limit; a cap limits abuse with fake accounts).

Store products

Settings: Store products with coin packs and Premium plans, each with a product ID, coins or months, and a price
Store products. The IDs must match your store products and RevenueCat. Section 10 explains it.

The coin packs and Premium plans the app sells. Remove every Premium plan to hide Premium in the app.

Notifications

Switch on new-episode alerts (to followers of the author and listeners who played or saved the book) and check-in streak reminders, and choose the reminder hour in your server's time zone. Listeners can still turn each kind off in the app.

Branding

Settings: Branding with a name field, accent colour picker, logo and favicon uploads
Branding.

Your name, accent colour, logo and favicon for the admin panel, sign-in pages, public website and shared book pages. The name replaces APP_NAME on web pages and in emails. Choose an accent colour dark enough for white button text (the form tells you if it is not). Logos are PNG, JPG or WEBP up to 1 MB; favicons PNG or ICO up to 256 KB; SVG is refused on purpose. The mobile app has its own name, icon and colours: section 15.

App links

Where shared book links lead. Section 13 explains it.

Legal pages

Settings: Legal pages with rich-text editors for the privacy policy, contact us and about us pages
Legal pages.

Your privacy policy, contact us and about us text, written in a simple editor. They show in the app (Profile menu) and on the public website at /page/privacy_policy, /page/contact_us and /page/about_us. Both stores require a privacy policy, so fill it in before you submit.

6.15Team

Everyone who can sign in to the panel. Under System in the menu.

The Team page listing the admins and a form to add an admin
Team.
  • Add and invite creates the account and emails the new admin a link to choose their own password. If the server has no real mail set up yet, the page shows the link for you to send by hand.
  • The link works for 60 minutes. Invite again sends a new one.
  • You cannot remove yourself, and the last admin can never be removed.
  • Admins can change everything in the panel. Only add people you trust.

6.16License

The licence for this copy. Under System in the menu.

The License page showing status, licence key, domain, version and support end date
License, with sample values.
  • Shows the status, a masked licence key, the domain, installations used, the date support ends and whether a newer version exists.
  • Check now asks the licence server straight away. The scheduler also checks once a day.
  • Move this licence gives the domain back so you can use the licence on another site. A subdomain counts as a different domain.
  • If the licence server cannot be reached, nothing changes: the product keeps running on the last answer. Only a refusal from the server shows a notice, with its reason.

6.17Account

Your own sign-in details: top-right menu, Account.

The Account page with profile fields and a change-password form
Account.

Change your name, email or password. Changing the email or the password asks for your current password and signs out every other browser.

6.18The public website

Your domain's home page (/) is a small public website with your name, store badges and links to the legal pages. The store badges appear only for the stores whose links you filled in under Settings > App links. The admin sign-in is a small link in the footer.

The public website home page with the app name, a short pitch, a phone mock-up and feature cards
The public home page. To show a screenshot of your app in the phone, put a PNG, JPG or WEBP named app-screenshot in audisode_backend/public/1/images/.

A shared book link (https://your-domain.com/book/12) opens a page with the book's cover and summary and sends phones on to the app or the store. See section 13.

The shared book page showing a book cover, title, author and store buttons
The page behind a shared book link.

7Adding contentAdmin panel

Work through the left-hand menu from top to bottom. This is the order that saves you backtracking: a book needs an author, a category and a language to exist first.

7.1Languages, categories and authors

  1. Languages. The installer added English if you used --demo or --sample-books. Add the languages your audiobooks are in. Listeners pick one in the app and see books in that language.

  2. Categories. Add the topics listeners browse by (Fiction, Kids, Mystery & Thriller…).

  3. Authors. Add each author with a name and, if you like, a photo and a short bio. A bio makes the author eligible for the home screen's Author spotlight.

7.2A single-audio book

One file, one price. Good for a complete audiobook.

  1. Open Books and click New book.

  2. Choose the format Single audio. Enter the title and summary, and pick the author, category and language.

  3. Upload a cover image.

  4. Set the price in coins. 0 makes it free. Premium subscribers always listen for free.

  5. Upload the audio file, or paste a link to it. See uploaded and linked audio below.

  6. Save. Check the dashboard: a single audio without audio shows up under Needs attention.

7.3A series with episodes

Many short episodes under one book. Each episode has its own price, so you can make the first few free.

  1. Click New book and choose the format Series. A series has no price of its own.

  2. Add episodes one at a time on the Episodes page: open the series, then New episode. Give each a title, a number, a price and its audio.

  3. Or add them all in one go with bulk upload.

7.4Bulk upload

The quickest way to add a whole season. In the menu, open Bulk upload (or the Bulk upload button on the Books page).

  1. Choose the series the episodes belong to, and the number the first file should get.

  2. Choose how titles are made. From file names uses the name of each file; the other choice names them "Episode 1", "Episode 2" and so on.

  3. Set the price per episode and how many of the first episodes are free. A free sample hooks listeners.

  4. Drop the audio files in. Name them with the number first: 01 Intro.mp3, 02 The Journey.mp3. Files are put in order by the number their name starts with, so Episode 3 - Title.mp3, 03_Title.mp3 and 3 Title.mp3 all sort as 3. With From file names, the number and separators are removed from the title: 03 - The Long Night.mp3 becomes "The Long Night".

  5. Check the preview table and click Upload episodes. Followers of the author and listeners who played or saved the book get one notification for the whole batch, not one per episode.

When uploads are rejected for size

PHP limits how much one upload may hold. The Bulk upload page shows your server's limits and warns you before you send. If they are too small, raise them: in cPanel open MultiPHP INI Editor, or edit php.ini (or .user.ini) on your own server.

ini
upload_max_filesize = 256M
post_max_size = 300M
max_file_uploads = 50
max_execution_time = 300

post_max_size must be larger than upload_max_filesize. If you use nginx, also raise client_max_body_size. When even that is not enough, upload big seasons in parts, a few files at a time.

7.5AI narrationOptional

You need: an API key from OpenAI or ElevenLabs. They bill per character, so check their pricing first. About 10 minutes.
  1. Put the key in .env and run php artisan config:cache.

    dotenv
    TTS_PROVIDER=openai
    OPENAI_API_KEY=sk-...
    # or
    TTS_PROVIDER=elevenlabs
    ELEVENLABS_API_KEY=...
    ELEVENLABS_VOICES="Rachel:21m00Tcm4TlvDq8ikWAM,Adam:pNInz6obpgDQGcFmaJgB"
  2. Open AI Narration. The orange dot beside it in the menu disappears once a key is set.

  3. Choose a book and a voice. For a series, add the episode title, number and price. Paste the text (up to TTS_MAX_CHARACTERS, 50,000 by default) and send it.

  4. Wait. The job list refreshes itself. When it finishes, a series gets a new episode (and listeners are notified) and a single-audio book gets its audio replaced.

This needs the scheduler (cron) or a queue worker running: see the queue. A failed job shows on the dashboard; retry it from the AI Narration page.

7.6Arrange the home screen and collections

  1. Star the books you want under Featured on the Books page.

  2. Make collections ("Staff picks") on the Collections page: a heading, a description, and the books in order.

  3. On Home screen, drag the sections into the order you want, switch off the ones you do not need, and give most of them your own heading. Collections show up in the list; move them where they belong.

Sections such as Trending this week stay empty until listeners play books. Changes reach the app the next time it opens.

7.7Audio formats, uploaded and linked audio

Audio can be uploaded as MP3, WAV, M4A, M4B or AAC. (M4B is the usual audiobook format.) Or you can paste a link to audio hosted somewhere else.

Uploaded audioLinked audio
Where it livesIn storage/app/private/media, outside the web root (or in your S3 or R2 bucket)Wherever the link points
How the app plays itThe app asks the backend, which checks the listener may play the title and hands back a signed link that expires after MEDIA_URL_TTL_MINUTESThe link is handed to the app as it is
Protected?Yes: a paid title is never given to a listener who has not unlocked itNo: anyone who sees the link can use it
Use it forPaid contentFree content, or audio you host and protect yourself

8Coins, Premium and the economyAdmin panel

Coins are the app's currency. Listeners earn them or buy them, and spend them to unlock books and episodes. Everything is decided on the server and every price is read from the database, so the app cannot cheat.

The app's Coins page with the balance, a Premium banner, the daily check-in with a seven-day grid and an Earn Free Coins link
The app's Coins page. The line "Coin packs are not available right now" appears until RevenueCat is set up (section 10).

8.1How listeners earn coins

Way to earnHow it worksWhere you set it
Welcome giftGiven once when a listener creates an account.Settings > Coins & rewards
Daily check-inA seven-day streak with a different amount each day. Missing a day starts it over. A reminder is sent before the streak resets.Settings > Daily check-in, and Settings > Notifications for the reminder
Rewarded adCoins for watching a rewarded video to the end, up to a daily limit per listener. Coins are granted only when AdMob confirms it.Settings > Coins & rewards, and Settings > Ads
Invite a friendA new listener enters a friend's code in their first 7 days. Both get coins, up to a monthly limit per inviter.Settings > Invite friends
Buying a coin packAn in-app purchase through RevenueCat. Coins are credited once the store confirms the payment.Settings > Store products
Your own adjustmentYou can add or remove coins on a listener's page, with a reason the listener can see.Listeners

8.2How listeners spend coins

  • A single-audio book is unlocked at the price set on the book.
  • An episode of a series is unlocked at the price set on that episode. There is no "buy the whole series" price: each episode is unlocked on its own.
  • Free titles (price 0) and the free first episodes need no coins.
  • An unlock is final: coins are deducted and the title is the listener's to keep.
  • Listeners with Premium are never charged: everything is open to them.

8.3Premium

  • Premium unlocks every book and episode, and removes all ads, while it is active.
  • Plans are auto-renewing subscriptions that you set up under Settings > Store products and in the stores. Remove every plan and Premium disappears from the app.
  • Cancelling keeps Premium until the period ends. A billing problem keeps it open for the store's grace period.
  • Promotional entitlements you grant in the RevenueCat dashboard (a free month for a reviewer, say) work too.

8.4A worked example

You upload a 10-episode series and set 20 coins per episode with the first 2 free.

  • A new listener hears episodes 1 and 2 at once, for nothing.
  • Episodes 3 to 10 are 8 episodes at 20 coins: 160 coins to hear them all.
  • With the default settings, a new listener starts with 50 welcome coins and a full week of check-ins pays another 145 (5, 10, 15, 20, 25, 30 and 40). That is 195 coins, enough to finish the series without paying.
  • Or they buy the coins_100 pack twice (200 coins), or subscribe to Premium and listen to everything.

So tune the three numbers against each other: how generous the free coins are, how much each episode costs, and how big the packs are. The dashboard's coin economy chart shows what listeners earn and spend.

9Firebase: sign-in and push~25 min

You need: a Google account, the Flutter SDK and the Firebase CLI on your computer, and an Apple Developer membership for Sign in with Apple. Do the package name step in section 15 first: Firebase must know your final package name and bundle ID.

Firebase checks who is signing in (Google and Apple) and delivers push notifications. Email and password sign-in is handled by your own backend, so you do not need to turn on Firebase's Email/Password provider.

  1. Create a project. Open the Firebase console, click Add project and follow the prompts. Google Analytics is optional.

  2. Turn on the sign-in providers. In your project open Authentication, then Sign-in method, and enable Google (choose a support email) and Apple. Switch Apple on and save; the key that Apple requires for account deletion is added in Sign in with Apple and the App Store, below. The Apple button shows in the iOS app only.

  3. Connect the app to the project. Install and sign in to the Firebase CLI, then run the FlutterFire tool inside the audisode folder. Pick your project and tick Android and iOS. It reads the package name and bundle ID from the project, so check that they are the ones you chose in section 15.

    bash
    npm install -g firebase-tools
    firebase login
    dart pub global activate flutterfire_cli
    cd audisode
    flutterfire configure

    It writes three files:

    • android/app/google-services.json
    • ios/Runner/GoogleService-Info.plist
    • lib/firebase_options.dart

    Google sign-in takes its client IDs from these files, so nothing goes into the Dart code. On Android, google-services.json must contain a web client ("client_type": 3). Firebase adds it once the Google provider is on. If the app says serverClientId must be provided, run flutterfire configure again to refresh the file.

  4. Add your Android fingerprints. Google sign-in fails on Android without them. You need up to three SHA-1 fingerprints. Add each in Project settings > Your apps > Android app > Add fingerprint:

    • Debug, for testing. Run ./gradlew signingReport in the android folder.
    • Release, from your upload keystore (section 16.1): keytool -list -v -keystore ~/upload.jks -alias upload.
    • Play App Signing, after your first upload to Google Play. Google re-signs your app with its own key, and releases installed from the store use that key. Copy it from Play Console > Test and release > App integrity > App signing. This is the one people forget: Google sign-in works in testing and fails for real users.

    After adding fingerprints, download google-services.json again (or rerun flutterfire configure) so it includes them.

  5. iOS: one value in Info.plist. Open ios/Runner/GoogleService-Info.plist, copy the value of REVERSED_CLIENT_ID, and paste it into ios/Runner/Info.plist in place of REPLACE_WITH_REVERSED_CLIENT_ID (under CFBundleURLSchemes). Without it Google sign-in fails on iOS.

  6. Sign in with Apple, on Apple's side. In Apple Developer open Certificates, Identifiers & Profiles > Identifiers, open your app's ID, and tick Sign In with Apple. In Xcode (section 16.2) check that Sign in with Apple is listed under Signing & Capabilities. The project's Runner.entitlements already asks for it. Then add the key for account deletion: see Sign in with Apple and the App Store, below. Apple requires it.

  7. Tell the backend. Set your Firebase project ID in the server's .env and refresh the cache. The backend uses it to check every sign-in, so nobody can sign in as someone else just by sending an email address.

    dotenv
    FIREBASE_PROJECT_ID=your-firebase-project-id
    bash
    php artisan config:cache

    The project ID is in Project settings > General.

9.1Sign in with Apple and the App Store

Apple rejects an iOS app that offers Google sign-in without Sign in with Apple next to it (App Review Guideline 4.8). It also requires that people can delete their account inside the app, and that an app with Sign in with Apple ends that link when they do (Guideline 5.1.1(v) and Apple's account-deletion rules). The app already follows all three. The last one needs a key from you, and the key goes into Firebase, not into your backend.

What Apple asksWhat the app does
Offer Sign in with Apple as well as GoogleThe sign-in page shows Apple's own Continue with Apple button, above Google's and the same size. It appears on iOS only, because Apple's sign-in sheet exists only there. People can hide their email: the app and the backend work with the private address Apple creates.
Let people delete their account in the appProfile, the pencil icon, Delete account.
End the Sign in with Apple link when the account is deletedAn Apple account first asks the person to confirm with Apple. The backend deletes the account, then Firebase revokes the link, so the app disappears from the person's Sign in with Apple list. This needs the key below.
  1. Create a key. In Apple Developer open Certificates, Identifiers & Profiles > Keys and click +. Name it, tick Sign in with Apple and click Configure. Choose your app's ID as the Primary App ID, save, and register the key. Download the .p8 file now: Apple lets you download it only once. Write down the Key ID (10 characters).

  2. Create a Services ID. Under Identifiers click +, choose Services IDs and give it an identifier such as com.yourcompany.yourapp.signin. Open it, tick Sign in with Apple and click Configure. Choose your app's ID as the primary App ID, add the domain your-project-id.firebaseapp.com, and the return URL https://your-project-id.firebaseapp.com/__/auth/handler. Firebase shows both values under the Apple provider (next step).

  3. Give them to Firebase. In the Firebase console open Authentication > Sign-in method > Apple. Fill in Services ID, and under OAuth code flow configuration your Apple Team ID (Apple Developer > Membership details), the Key ID, and the contents of the .p8 file as the Private key. Save. Despite what the form implies, these fields are not optional for deleting accounts: without them Firebase cannot revoke the link.

Test it before you submit

  1. On a real iPhone, or a simulator that is signed in to an Apple Account in Settings, tap Continue with Apple and choose Hide My Email. The new listener appears under Listeners with the sign-up type Apple.
  2. Delete that account: Profile, the pencil icon, Delete account, then confirm with Apple.
  3. On the iPhone open Settings > your name > Sign-In & Security > Sign in with Apple. Your app must no longer be listed.

10In-app purchases with RevenueCat~45 min

You need: a free RevenueCat account, your apps created in Google Play Console and App Store Connect, and the paid-apps agreements and banking signed in both stores. Allow time: the stores can take a day or more to approve things.

Coin packs and Premium are sold through RevenueCat, one service for Google Play and the App Store. The backend never trusts what the app says it bought. It asks RevenueCat what the listener owns, and credits coins or turns Premium on from that answer. Each store transaction is credited once.

10.1How a purchase works

How a purchase reaches your backend The app buys through the RevenueCat SDK and the store. The app then calls the backend's purchases sync endpoint. The backend asks RevenueCat's REST API what the listener owns, credits coins or turns Premium on, and returns the new balance. Later, RevenueCat sends a webhook for renewals and refunds, which runs the same sync. The app Store and RevenueCat Your backend Buy a coin pack or subscription Purchase confirmed POST /api/purchases/sync What does user_42 own? (REST, secret key) Purchases and entitlements Credits the coins once, or turns Premium on New coin balance and Premium status Later: webhook (renewal, refund) runs 4 to 6 again 1 2 3 4 5 6 7 8

The app calls the sync right after a purchase and when it starts. The webhook covers everything that happens while the app is closed: renewals, expirations, refunds. All of it can repeat safely.

10.2Set it up

  1. Create the products in the stores.

    • Coin packs are consumable in-app products. In Google Play Console: Monetize > Products > In-app products. In App Store Connect: your app > In-App Purchases.
    • Premium plans are auto-renewing subscriptions. In App Store Connect create one subscription group and a subscription in it for each plan. In Google Play Console create one subscription for each plan, with one base plan each.
    • Use the same IDs on both stores, made of lowercase letters, digits and underscores. The starter IDs are coins_100, coins_500, coins_1000, premium_monthly and premium_yearly. Pick your own if you like.
    • On Google Play enter the subscription ID (RevenueCat's subscription:base-plan form is understood too).
  2. Create a RevenueCat project and add two apps: an iOS app with your bundle ID and an Android app with your package name. RevenueCat asks for an App Store Connect API key (the In-App Purchase key) and a Google Play service account with access to your app. Follow its prompts: they change from time to time.

  3. Add the products to RevenueCat. Under Product catalog > Products add the same IDs.

  4. Create the entitlement premium and attach the subscriptions to it. Do not attach coin packs. (If you name it something else, set REVENUECAT_ENTITLEMENT.) You do not need offerings: the app fetches the products by ID.

  5. Tell the admin panel. Open Settings > Store products and enter the same product IDs, the number of coins each pack gives, each plan's billing period in months, a list price and the currency. Save. The app reads this list from your server, so you can change coin amounts later without a new app release. Until you save it, the starter list in config/monetization.php applies.

    Settings: Store products with three coin packs and two Premium plans
    Settings > Store products.
  6. Add the keys to .env. In RevenueCat open Project settings > API keys. There are two kinds of key; do not mix them up:

    dotenv
    REVENUECAT_SECRET_KEY=sk_...            # secret API key. Server only, never in the app
    REVENUECAT_IOS_API_KEY=appl_...         # public SDK key of the iOS app
    REVENUECAT_ANDROID_API_KEY=goog_...     # public SDK key of the Android app
    REVENUECAT_WEBHOOK_AUTH=a-long-random-secret

    Make up REVENUECAT_WEBHOOK_AUTH yourself: any long random string. Then php artisan config:cache. The two public keys reach the app through your server, so changing them needs no app rebuild.

  7. Add the webhook. In RevenueCat open Project settings > Integrations > Webhooks and add one:

    • URL: https://your-domain.com/api/webhooks/revenuecat
    • Authorization header value: the same string as REVENUECAT_WEBHOOK_AUTH
    • Environment: both production and sandbox
  8. Send the test event from RevenueCat. Your server answers 200 only when the secret matches. A 403 means the value differs. A 503 means REVENUECAT_WEBHOOK_AUTH is empty on the server (or the config cache is stale). You can also check by hand:

    bash
    curl -i -X POST https://your-domain.com/api/webhooks/revenuecat \
      -H "Authorization: wrong-value" -H "Content-Type: application/json" -d '{}'

    That should answer 403 (or 503 if the secret is not set). It must never answer 404: a 404 means this backend version is not installed there.

Without the webhook, purchases still apply when the app syncs, but renewals, expirations and refunds only show up the next time that listener opens the app. The dashboard warns you if the secret is missing.

10.3Testing

  • Google Play: add your Google account as a licence tester (Play Console > Settings > License testing) and install the app from an internal testing track.
  • App Store: create a Sandbox tester in App Store Connect (Users and Access > Sandbox) and sign in with it on the device when asked to buy.
  • Buy one coin pack and one subscription on both platforms. Coins should appear within seconds. Sandbox purchases credit coins and Premium too, but are left out of the dashboard's revenue.
  • Test Restore purchases after reinstalling the app.
  • App Review buys in the sandbox, which RevenueCat handles for you: no server switch is needed.

10.4Refunds

When RevenueCat reports a coin pack as refunded, its coins are taken back from the listener's balance. The balance never goes below zero, and coins already spent cannot be recovered. The purchase on the listener's page shows how many coins were taken back, and refunded packs are left out of the revenue estimate. Google Play refunds count once they go through RevenueCat (refund or revoke in Play Console or in RevenueCat); a Play refund made some other way may not be reported. A refunded or expired subscription ends Premium through the same webhook.

10.5What "not available" means

Until the purchase setup is complete, the Coins and Premium pages say that packs and Premium are not available. This is what a listener sees:

The Premium page showing its benefits and the message that Premium isn't available right now
The Premium page with no RevenueCat keys yet.

It appears when the public key for that platform is empty, when the products are missing or not ready in the store or RevenueCat, or when the product IDs in the admin panel do not match. In the same way, the sync request answers 503 while the secret key is missing or RevenueCat cannot be reached, and the app tries again later. See troubleshooting for a checklist.

11Ads with AdMobOptional~15 min

You need: an AdMob account and your app added in it. Skip this section if you do not want ads.

You can run the app with no ads at all: switch Show ads off in Settings > Ads. Premium subscribers never see ads.

  1. Create the apps in AdMob. Add an Android app and an iOS app. Each gets an app ID that looks like ca-app-pub-0000000000000000~0000000000 (with a tilde).

  2. Put the app IDs in the app.

    • Android: audisode/android/app/src/main/AndroidManifest.xml, the com.google.android.gms.ads.APPLICATION_ID value.
    • iOS: audisode/ios/Runner/Info.plist, the GADApplicationIdentifier value.
  3. Create the ad units in AdMob: a banner, a full-screen (interstitial) and a rewarded ad unit for each platform. Their IDs have a slash, ca-app-pub-0000000000000000/0000000000. Put them in audisode/lib/utils/app_config.dart, or pass them when you build:

    Setting in app_config.dartBuild option
    androidBannerAdID--dart-define=ANDROID_BANNER_AD_ID=…
    androidInterstitialAdID--dart-define=ANDROID_INTERSTITIAL_AD_ID=…
    androidRewardedAdID--dart-define=ANDROID_REWARDED_AD_ID=…
    iosBannerAdID--dart-define=IOS_BANNER_AD_ID=…
    iosInterstitialAdID--dart-define=IOS_INTERSTITIAL_AD_ID=…
    iosRewardedAdID--dart-define=IOS_REWARDED_AD_ID=…
  4. Create the consent messages. In AdMob open Privacy & messaging and create a GDPR message. For iOS also create an IDFA explainer message if you want personalised ads: it explains the request and then shows Apple's tracking prompt. The app shows the messages automatically where the law requires consent, and offers Ad privacy in the Profile menu there.

  5. Turn on server-side verification for rewarded ads. In AdMob open the rewarded ad unit, enable server-side verification, and set the callback URL to:

    text
    https://your-domain.com/api/admob/ssv

    Coins are then credited only when AdMob's signed callback reaches your server. This is the default (ADMOB_SSV_ENABLED=true).

  6. Testing with Google's test ads. Test ad units never call your server, so no rewards would be credited. While you test, set ADMOB_SSV_ENABLED=false in .env: the app then claims rewards itself, one per 30 seconds, within the daily limit. Set it back to true before you release.

  7. Tune the frequency in Settings > Ads: the master switch, banner and full-screen ads, how often full-screen ads appear, and how many rewarded ads one listener can watch per day.

12Push notificationsOptional~15 min

You need: your Firebase project from section 9, and for iOS an Apple Developer membership. Skip this section if you do not want notifications.

The backend sends three kinds of notification through Firebase Cloud Messaging:

  • New episode. To listeners who follow the author, have listened to the book or saved it. One alert per bulk upload.
  • Daily check-in reminder. To listeners whose streak is about to reset, at the hour you choose.
  • Announcements you write on the Notifications page.
  1. Create a service account key. In Google Cloud, choose the project that is your Firebase project. Open IAM & Admin > Service accounts, create a service account with the role Firebase Cloud Messaging API Admin, and create a JSON key for it. Download the file.

  2. Store the key outside the web root, for example in /home/you/keys/, and tell the backend where it is:

    dotenv
    FIREBASE_SERVICE_ACCOUNT_JSON=/home/you/keys/firebase-service-account.json

    FCM_PROJECT_ID defaults to FIREBASE_PROJECT_ID. Run php artisan config:cache.

  3. iOS only: upload an APNs key to Firebase. In Apple Developer open Keys, create a key with Apple Push Notifications service (APNs) turned on, and download the .p8 file (you can download it only once). In the Firebase console open Project settings > Cloud Messaging, find your iOS app, and upload the key with your Key ID and Team ID. In Xcode, check that Push Notifications is listed under Signing & Capabilities.

  4. Switch on the automatic notifications in Settings > Notifications (new-episode alerts and check-in reminders) and choose the reminder hour.

  5. Send a test. Open Notifications in the admin panel, write a title and message, send it to everyone, and watch your phone. The orange dot beside Notifications in the menu and the matching item on the dashboard go away once the key is set.

Listeners choose which kinds they want in the app, and the app asks for permission the first time. The scheduler must be running (cron), because the reminder is sent by it.

You need: your backend on its final domain, the app's final package name and bundle ID, and (for the fingerprints and Team ID) access to Play Console and Apple Developer.

The share button on a book shares https://your-domain.com/book/12. What happens next depends on the device:

Where the link is openedWhat happens
A phone with your app installedThe book opens in the app.
A phone without the appA page with the book's cover and summary opens, with a button to Google Play or the App Store.
A chat app previewing the linkIt reads the same page for its preview card.
A computerThe page shows both store buttons.
  1. Put your domain in the app, in two files. Replace your-domain.com with the domain of your backend (the one in BASE_URL, without https://):

    • audisode/android/app/src/main/AndroidManifest.xml: the android:host in the intent filter with pathPrefix="/book/".
    • audisode/ios/Runner/Runner.entitlements: the applinks: entry under com.apple.developer.associated-domains.

    The domain is written into the app, so a backend that moves to a new domain needs a new app release for links to open the app.

  2. Fill in Settings > App links in the admin panel:

    • Store pages: the Google Play and App Store links. Leave a store empty until its listing is public; its button is hidden and phones are not sent there.
    • Android package name: your application ID.
    • Android signing fingerprints: SHA-256 fingerprints, one per line. Add the app signing key from Play Console (Test and release > App integrity > App signing) and, for copies installed outside Google Play, the key they were signed with (keytool -list -v -keystore your.jks). These are SHA-256, not the SHA-1 used for Firebase.
    • Apple Team ID: 10 characters, from developer.apple.com > Account > Membership details.
    • iOS bundle ID: your bundle ID.
    Settings: App links with store links, Android package name, fingerprints, Apple Team ID and iOS bundle ID filled in
    Settings > App links (sample values). Lower on the page, the Verification files box shows whether the Android and iOS files are Ready.
  3. Check both verification files. From these settings the backend serves the two files phones read. Each must answer 200 with JSON over HTTPS, at the root of the domain in APP_URL, with no redirect:

    bash
    curl -i https://your-domain.com/.well-known/assetlinks.json
    curl -i https://your-domain.com/.well-known/apple-app-site-association

    A 404 means a setting is missing. An HTML page from your host instead of JSON means the web server answered before Laravel: let it pass /.well-known/ through to public/index.php.

  4. Test on a device. Without sharing anything:

    bash
    adb shell am start -a android.intent.action.VIEW -d "https://your-domain.com/book/12" com.yourcompany.yourapp
    xcrun simctl openurl booted "https://your-domain.com/book/12"

14Audio storage in the cloudOptional

You need: an Amazon S3 account or any S3-compatible storage: Cloudflare R2, DigitalOcean Spaces, Backblaze B2 or Wasabi.

By default your audio stays on your server. If your hosting has little disk space or bandwidth, keep it in a bucket instead. Listeners still get the same protected, expiring links.

  1. Create a private bucket and an access key with read and write access to it. The bucket must not be public.

  2. Set the keys in .env. For Cloudflare R2:

    dotenv
    MEDIA_DISK=media_cloud
    MEDIA_S3_KEY=your-access-key
    MEDIA_S3_SECRET=your-secret-key
    MEDIA_S3_REGION=auto
    MEDIA_S3_BUCKET=audisode-audio
    MEDIA_S3_ENDPOINT=https://ACCOUNT_ID.r2.cloudflarestorage.com
    MEDIA_S3_PATH_STYLE=false

    For Amazon S3, use your region (for example us-east-1) and leave MEDIA_S3_ENDPOINT empty. Keep MEDIA_URL_TTL_MINUTES at 10080 or less: cloud links cannot last longer than 7 days. Run php artisan config:cache.

  3. Move the audio you already have. New uploads go straight to the bucket. Copy the existing files across, first as a rehearsal and then for real:

    bash
    php artisan media:push-to-cloud --dry-run
    php artisan media:push-to-cloud --delete-local

    --delete-local removes each local file once it is safely in the bucket. Files not yet copied keep streaming from your server, so the switch needs no downtime.

15Make the app yoursApp

You need: the Flutter SDK, an editor, and your backend's address. Do the steps in this order: the package name must be settled before Firebase (section 9).

Everything that identifies the app is in a few files. Open the audisode folder in your editor. All paths below are inside it.

15.11. The backend address

In lib/utils/app_config.dart, set baseUrl to your backend, with https:// and a trailing slash:

dart
defaultValue: 'https://your-domain.com/',

You can also pass it when you run or build, without editing the file: --dart-define=BASE_URL=https://your-domain.com/. Release builds only allow https; plain http works in debug builds only.

15.22. The package name and bundle ID

Choose a unique ID in reverse-domain style, such as com.yourcompany.yourapp. You can never change it after you publish. Install the packages, then run:

bash
cd audisode
flutter pub get
dart run change_app_package_name:main com.yourcompany.yourapp

It changes the Android applicationId and namespace in android/app/build.gradle, the Android manifests, and the iOS bundle identifier. Open build.gradle and check that both show your ID.

15.33. The app name

The name appears in more than one place. Change them all:

FileWhat to change
android/app/src/main/AndroidManifest.xmlandroid:label on the <application> tag
ios/Runner/Info.plistCFBundleDisplayName and CFBundleName. Keep each value on one line with no spaces or line breaks around the name.
lib/utils/app_config.dartappName
lib/l10n/app_en.arb, app_bn.arb, app_ar.arb, app_es.arbappTitle in all four files

After editing the .arb files, regenerate the text:

bash
flutter gen-l10n

15.44. Icons and launch screen

One image makes both. Replace assets/icons/logo.png with your logo and run two commands.

bash
dart run flutter_launcher_icons
dart run flutter_native_splash:create

The first writes the Android and iOS icons (the iOS one without transparency, which the App Store refuses). The second writes the launch screens, light and dark. They read the settings at the end of pubspec.yaml. The colours there (#FDFCF9 for light and #1A1512 for dark) are the app's paper colours: change them if you change the palette. These commands rewrite files under android/ and ios/, so run them before you commit. The other pictures in assets/icons (Google, Apple, email, app bar) are shown inside the app.

15.55. Colours

All colours are in lib/utils/app_colors.dart, as two palettes: AppPalette.light and AppPalette.dark. Each colour is named for the job it does, not for how it looks:

RoleUsed for
paperThe page background
surface, surfaceSunkRaised sheets and dialogs; sunken fields and chips
rule, ruleStrongHairlines; the heavy rule under a row of covers
ink, inkMuted, inkFaintBody text; secondary text; disabled and tertiary text
oxblood, oxbloodInk, onAccentThe main accent: as a fill (buttons, progress, play button), as text on the page, and the text drawn on top of it
ochre, ochreInkThe second accent: ratings, coins, locked items
success, dangerFinished and downloaded; errors and destructive actions
spine, spineLightThe shading down a book cover's binding edge

Change the colour values (Color(0xFF8C2F24) and so on) and keep the names. Check that text stays readable on its background in both palettes.

15.66. Onboarding images

The three welcome screens use assets/onboard/intro1.jpeg, intro2.jpeg and intro3.jpeg, each 1024 × 1024 pixels. Replace them with your own pictures of the same size and keep the file names. Their titles and texts are in the .arb files (onboardingTitle1, onboardingBody1 and so on).

15.77. The notification channel ID

In lib/utils/app_config.dart, change audioNotificationChannelId so it starts with your new package name, for example com.yourcompany.yourapp.channel.audio. It names the Android notification used for the lock-screen player controls.

15.88. Terms and privacy links

The Premium page links to your terms and your privacy policy, which both stores require for subscriptions.

  • Privacy policy: comes from your backend, https://your-domain.com/page/privacy_policy. Write it in Settings > Legal pages.
  • Terms of use: by default Apple's standard licence agreement. To use your own, pass --dart-define=TERMS_URL=https://your-domain.com/terms when you build, or change termsUrl in app_config.dart.

15.99. Languages

The app's own text follows the phone's language. It ships in English, Bengali, Arabic (right to left) and Spanish. Have a native speaker review the translations in lib/l10n before you release: they were not written by professional translators.

  • Add a language: copy lib/l10n/app_en.arb to app_<code>.arb (for example app_fr.arb), translate the values, run flutter gen-l10n, and add the code to CFBundleLocalizations in ios/Runner/Info.plist.
  • Remove a language: delete its .arb file, run flutter gen-l10n, and remove its code from CFBundleLocalizations.

The language of the audiobooks is separate: it is chosen in the app under Audiobook language and managed under Languages in the admin panel.

15.1010. Run it

bash
flutter pub get
flutter run --dart-define=BASE_URL=https://your-domain.com/

Start the app on a phone or emulator that can reach your backend. If your backend runs on your own computer, use http://10.0.2.2:8000/ from the Android emulator or http://127.0.0.1:8000/ from the iOS simulator (plain http works in debug builds only).

16Build and publishApp

You need: a Google Play Console account for Android, and a Mac with Xcode and an Apple Developer membership for iOS. Allow a few hours for the first build and several days for store review.

16.1Android

  1. Create your own upload keystore. Keep it somewhere safe and back it up: if you lose it you cannot update your app the normal way. Never share it or put it in a repository.

    bash
    keytool -genkey -v -keystore ~/upload.jks -keyalg RSA -keysize 2048 -validity 10000 -alias upload
  2. Create android/key.properties with these four lines (your own passwords and the full path to the keystore):

    properties
    storePassword=your-store-password
    keyPassword=your-key-password
    keyAlias=upload
    storeFile=/full/path/to/upload.jks
  3. Set the version in pubspec.yaml. version: 1.0.1+4 means version name 1.0.1 and build number 4, on both platforms. The number after the plus sign must go up with every upload to a store.

  4. Build the bundle (it ends up in build/app/outputs/bundle/release/) and upload it in Play Console:

    bash
    flutter build appbundle --dart-define=BASE_URL=https://your-domain.com/

    Add any ad-unit IDs or TERMS_URL as further --dart-define options, or set them in app_config.dart.

  5. After the first upload, go back to two places. Google signs your app with its own key. Copy it from Play Console > Test and release > App integrity > App signing, and add:

    • its SHA-1 to the Android app in Firebase (section 9), or Google sign-in fails for store installs;
    • its SHA-256 to Settings > App links in the admin panel (section 13), or shared links open the browser.

16.2iOS

  1. Open the workspace in Xcode: ios/Runner.xcworkspace (not the .xcodeproj). If you have changed packages, run cd ios && pod install first.

  2. Choose your team. Under Runner > Signing & Capabilities pick your Team and leave Automatically manage signing on.

  3. Check the capabilities. The list must show Push Notifications, Sign in with Apple and Associated Domains. Automatic signing adds them from Runner.entitlements. The Associated Domains entry must show applinks:your-domain.com (section 13).

  4. Check the minimum version. The app supports iOS 15 and newer.

  5. Build the archive (the result is in build/ios/ipa/):

    bash
    flutter build ipa --dart-define=BASE_URL=https://your-domain.com/
  6. Upload it with Xcode's Organizer (Window > Organizer > Distribute App) or the Transporter app. Then finish the listing in App Store Connect.

Troubleshooting: iOS simulator build under Xcode 27

With Xcode 27, flutter build ios --simulator can stop with a "does not contain architectures" error in debug_unpack_ios (a failing lipo -verify_arch check). We have seen it only with simulator builds. Work around it by letting Flutter write the settings and building with xcodebuild for one architecture:

bash
flutter build ios --simulator --debug --config-only --dart-define=BASE_URL=http://127.0.0.1:8000/
xcodebuild -workspace ios/Runner.xcworkspace -scheme Runner -configuration Debug \
  -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' \
  ONLY_ACTIVE_ARCH=YES ARCHS=arm64 build

16.3Before you submit to the stores

Tick these off as you go. Your ticks are kept in this browser only.

17Running the serviceServer

17.1Before you go live

17.2Backups

Back up these regularly, and keep copies somewhere other than the server:

  • The database. For example, daily from cron:
    bash
    mysqldump -u audisode_user -p audisode | gzip > audisode-$(date +%F).sql.gz
  • Uploaded audio: the folder audisode_backend/storage/app/private/media (not needed if your audio is in a bucket, but keep that bucket safe).
  • .env and your keys: the Firebase service account file, your Android keystore and key.properties, and the Apple key files. Losing the keystore means you cannot update your Android app the normal way.
  • Uploaded branding and covers: audisode_backend/public/uploads.

17.3Updating the backend

When you receive a new version:

  1. Back up the database and the audio folder.

  2. Upload the new files over the old ones. Keep your .env and the storage/ folder (and public/uploads).

  3. Install the packages. With a package that includes vendor/, upload it too. With SSH:

    bash
    composer install --no-dev --optimize-autoloader
  4. Update the database.

    bash
    php artisan migrate --force
  5. Refresh the caches. If something looks stale, run php artisan optimize:clear first.

    bash
    php artisan config:cache
    php artisan queue:restart

    (queue:restart matters only if you run the worker under Supervisor.)

  6. Check the cron entry still exists. Running php artisan app:install again is safe, skips the admin account when one exists, and ends with the configuration checklist, so any new TODO line shows what the release needs.

  7. Check the deployment. POST /api/webhooks/revenuecat without the secret must answer 503 or 403, never 404. A 404 means the old code is still live.

17.4Logs and the scheduler

  • Errors are written to storage/logs/laravel.log. Keep APP_DEBUG=false on a live server.
  • To see that the scheduler knows its jobs, run php artisan schedule:list. To run it once by hand, run php artisan schedule:run.
  • If AI narrations or announcements stay waiting for more than a few minutes, the cron entry is not running: see troubleshooting.
  • The licence is checked once a day by the scheduler. A server that cannot reach the licence server keeps running.

17.5Hosting a public demo

To show the admin panel to buyers, host it with a read-only demo admin: someone who signs in with it can browse everything, but every change is refused and a "Demo mode" alert says so. It is the same code as the real panel, and the demo login exists only where you create it.

  1. Deploy the code to its own server with its own database, and run php artisan app:install --sample-books there. The demo server needs its own licence for its own domain.

  2. Set DEMO_ADMIN_EMAIL in .env. Use an address that is not a real mailbox, so the public "forgot password" page cannot reach anyone. Set DEMO_ADMIN_PASSWORD too.

  3. Create the demo admin:

    bash
    php artisan db:seed --class=DemoAdminSeeder
  4. Publish that login and password on your listing. Keep your own owner account off the demo server. Listener emails are masked in the demo.

  5. If people can use the mobile app against the demo server, reset its data now and then, for example nightly: only the admin panel is read-only, so listeners can create accounts, spend coins and write reviews. A reset is php artisan migrate:fresh --force followed by the install and seeder steps above. Run it from cron at night.

17.6Security checklist

  • APP_DEBUG=false and APP_ENV=production.
  • HTTPS everywhere, and the document root set to public/.
  • https://your-domain.com/.env answers 404 or 403.
  • Strong, unique admin passwords. Add people on the Team page rather than sharing one login. Remove people who leave.
  • Keep the Firebase service account key, .env and your keystore out of any public folder, repository and email.
  • Hide your PHP version: set expose_php = Off in php.ini (in cPanel, MultiPHP INI Editor). PHP otherwise sends an X-Powered-By: PHP/x.y header. The panel itself already sends X-Frame-Options, X-Content-Type-Options, Referrer-Policy and Permissions-Policy headers.
  • If .env was ever exposed or committed, change every key in it, rotate the app key with php artisan key:generate --force (this signs admins out), and sign every listener out by emptying the token table:
    bash
    php artisan tinker --execute="DB::table('personal_access_tokens')->delete();"
  • Keep the backend updated, and keep your server's PHP and operating system updated.

18API referenceFor developers

You do not need this to run the product. It is a compact map of the backend's API for developers. The README in audisode_backend/README.md has more detail.

  • All endpoints live under /api and return JSON.
  • Signed-in endpoints expect the header Authorization: Bearer <token>. The token comes from /login, /register or /social-login and lasts a year by default.
  • Errors always have one shape: {"status": false, "code": "...", "message": "...", "errors": {...}}. errors appears for validation failures.
  • Sign-in endpoints are rate limited. Purchases and unlocks answer 503 with not_configured while a needed service is not set up.
EndpointPurpose
GET /settingsApp settings, daily rewards, coin packs, Premium products, ad frequency, invite rewards
GET /home?language_id=Home screen: layout (sections to show, in order) and the data for each
GET /booksPaginated catalogue. Filters: language_id, category_id, author_id, featured, free, series, q, ids. sort: latest, top_rated, most_played, title
GET /books/{id}One book with its episodes
GET /books/{id}/reviewsPaginated reviews
GET /books/{id}/episodes, GET /episodes/{id}Episodes
GET /books/{id}/audio, GET /episodes/{id}/audioA playable, expiring URL, after checking access
POST /books/{id}/viewsCount a play (once per listener every 6 hours), for "Trending this week"
GET /authors, GET /authors/{id}, GET /categories, GET /languagesBrowsing
POST /register, /login, /social-login, /forgot-passwordAccounts (rate limited)
GET /me, PATCH /me/update, DELETE /me/delete, POST /logoutProfile, including Premium status and invite code
POST /unlock-book, /unlock-episodeSpend coins. The price comes from the server. Free with Premium
POST /purchases/sync, POST /rewards/ad-view, GET and POST /daily-check-inEarn coins. purchases/sync applies RevenueCat purchases
GET /referrals, POST /referrals/redeemInvite code, stats, and entering a friend's code
PUT /progress, GET and DELETE /progress/{book}Listening position, synced across devices
GET /library?shelf=listening|finished|unlockedMy Library shelves
POST and DELETE /authors/{id}/follow, GET /me/followingFollowing authors
PUT /me/listening, GET /me/statsTime listened per day; the activity page
GET /recommendations"Because you listened to…" and new books from followed authors
POST and DELETE /devices, PUT /me/notificationsPush tokens and notification choices
GET, POST, DELETE /bookmarks, GET /transactionsSaved places; coin history
POST /webhooks/revenuecatRevenueCat notifications (renewals, expirations, refunds)
GET /admob/ssvAdMob's signed rewarded-ad callback

18.1The home screen layout

GET /api/home returns a layout: the sections the app should draw, top to bottom, exactly as set on the admin panel's Home screen page. Each entry has a type and an optional title (your own heading). A collection entry also carries its description and books. The other keys in the response (featured, recent, top_rated, most_played, free, series, trending, new_episodes, reviews, spotlight, authors) hold the data for those sections. A section you hid is left out of layout and its data comes back empty.

Some sections are personal: the app fills them in from the listener's own activity (the premium and check-in strips, continue listening, recommendations, "your week"). The server only decides whether they appear and where. The app ignores a section type it does not know, so a newer backend can add sections without breaking older app releases. To add a built-in section, see "Home screen" in the backend README.

19Troubleshooting

Find the symptom, open it, and work down the list. Most problems are one wrong value in one place. Always check storage/logs/laravel.log on the server first: it usually names the problem.

19.1Server and admin panel

The admin panel shows a blank page or a 500 error
  1. Read the last lines of storage/logs/laravel.log.
  2. Check that storage/ and bootstrap/cache/ are writable by the web server user (file permissions).
  3. Run php artisan optimize:clear, then php artisan config:cache.
  4. Check that PHP is 8.2 or newer and that the extensions in section 2 are on.
  5. Check that .env exists and the database details are right.

To see the error on screen while you debug, set APP_DEBUG=true, reload, and set it back to false straight after.

500 error right after I uploaded the files

Almost always permissions or a missing vendor/ folder. Make storage/ and bootstrap/cache/ writable (775), check that vendor/ exists (or run composer install --no-dev --optimize-autoloader), and run php artisan optimize:clear. Also check the hidden files .htaccess and .env.example were uploaded.

https://your-domain.com/.env loads in the browser

The document root is not public/. Fix this before anything else: anyone can read your passwords. Set the document root to audisode_backend/public (section 4). If your host will not let you, make sure the .htaccess in the project folder was uploaded, then check again.

"419 Page Expired" when I sign in or save

The browser's session does not match the site's address.

  • Use the exact address in APP_URL: https versus http, and www versus no www, are different sites.
  • Leave SESSION_DOMAIN as null.
  • After you change APP_URL or the domain, run php artisan config:cache, clear the browser's cookies for the site, and try again.
Password-reset and invitation emails do not arrive
  • MAIL_MAILER=log writes emails to storage/logs/laravel.log instead of sending them. Set real MAIL_* values (section 5) and run php artisan config:cache.
  • Check the port and scheme with your mail provider: usually 587, or 465 with MAIL_SCHEME=smtps.
  • Use a MAIL_FROM_ADDRESS on your own domain, and check the spam folder.
  • Send a test from the server. It prints nothing if it worked, and an error if it did not:
    bash
    php artisan tinker --execute='Mail::raw("Test", fn($m) => $m->to("you@example.com")->subject("Test"));'
  • The Team page shows the invite link on screen when mail is not set up, so you can send it by hand.
Audio uploads are rejected (format or size)
  • Format: only MP3, WAV, M4A, M4B and AAC files are accepted.
  • Size: PHP's limits (upload_max_filesize, post_max_size, max_file_uploads) decide how much one upload may hold. The Bulk upload page shows them. Raise them as in section 7.4, or upload in parts. With nginx also raise client_max_body_size.
  • If a long upload times out, raise max_execution_time too.
Notifications, narrations or other background jobs never run

The cron entry is missing or wrong.

  • Check it exists: crontab -l, or Cron Jobs in cPanel. It must run every minute.
  • Check the path and the PHP version in the entry. On shared hosting the plain php may be an old version: use the full path of PHP 8.2.
  • Run php artisan schedule:list and php artisan schedule:run by hand. Errors show on screen.
  • If you use Supervisor, check QUEUE_RUN_VIA_SCHEDULER=false, that the worker is running, and that you ran php artisan queue:restart after the last update.
The installer says it cannot write the .env file

Make the project folder (and .env, if it exists) writable, permissions 775, while you install. Put them back afterwards.

The panel says the licence needs attention
  • Could not be reached: the server must be allowed to make outgoing HTTPS requests to appentium.com. Ask your host.
  • Domain changed: the copy was activated for another domain. Release the old domain first (License > Move this licence on the old site), then activate on the new one. A subdomain counts as a different domain.
  • Refused: the notice shows the reason the licence server gave. Check the key and username, and the number of installations your licence allows.

19.2App, sign-in and Firebase

The app stays on a loading screen or shows a retry screen

The app cannot reach the backend, or Firebase is not configured.

  • Check BASE_URL: https, your real domain, and a trailing slash. Open https://your-domain.com/api/settings in a browser: it must show JSON.
  • On an emulator, localhost is the emulator itself. Use http://10.0.2.2:8000/ (Android) or http://127.0.0.1:8000/ (iOS simulator), in a debug build.
  • Check that google-services.json and GoogleService-Info.plist are in place (section 9).
The Android build fails with "missing google-services.json"

Run flutterfire configure in the audisode folder (section 9). The same applies to a missing lib/firebase_options.dart or GoogleService-Info.plist.

Google sign-in fails on Android

Add your debug and release SHA-1 fingerprints to the Android app in Firebase (section 9), then download google-services.json again. If it says serverClientId must be provided, the file has no web client: run flutterfire configure again.

Google sign-in works in testing but fails in the version from Google Play

Releases from the store are signed with Google's Play App Signing key, not your upload key. Copy that key's SHA-1 from Play Console (Test and release > App integrity > App signing) into Firebase, and download google-services.json again. Rebuild only if the file changed.

Google sign-in fails on iOS

REPLACE_WITH_REVERSED_CLIENT_ID in ios/Runner/Info.plist was not replaced with the REVERSED_CLIENT_ID from GoogleService-Info.plist.

Sign in with Apple fails

Go through these in order:

  • The Apple provider is switched on in Firebase (Authentication > Sign-in method).
  • Your app's ID has Sign In with Apple ticked in Apple Developer, and Xcode lists the capability under Signing & Capabilities (section 16.2). A missing capability, or an iPhone or simulator that is not signed in to an Apple Account in Settings, usually shows as AuthorizationError error 1000.
  • The bundle ID in Xcode is the one registered for the iOS app in your Firebase project. Apple puts the bundle ID in the token, and Firebase checks it against the iOS apps in the project. After you change the bundle ID, run flutterfire configure again.
  • FIREBASE_PROJECT_ID in the server's .env is the project the app uses. If it is not, the backend answers that the sign-in token is invalid.

Closing Apple's sheet is not an error: the app simply stays on the sign-in page.

There is no "Continue with Apple" button

That is on purpose on Android. Apple's sign-in sheet exists only on iOS, and Google Play does not ask for it. On an iPhone the button is always on the sign-in page, above Google's.

A deleted account is still listed under Sign in with Apple on the iPhone

Firebase could not revoke the link, and the account was deleted anyway. The usual cause is the Apple provider in Firebase missing its Services ID or OAuth code flow fields (Team ID, Key ID, private key): the error in the app's log is then Code flow is not enabled for Apple. Fill them in (section 9.1). Check also that the Key ID belongs to the .p8 you pasted, and that the key has Sign in with Apple ticked. The person can remove the app by hand under Settings > your name > Sign-In & Security > Sign in with Apple.

Everyone is signed out, or the app says the session expired

Sign-in tokens last a year (SANCTUM_EXPIRATION), and rotating APP_KEY or emptying the token table signs listeners out on purpose. The app asks them to sign in again.

19.3Purchases and Premium

Coin packs or Premium say "not available"

Work down this list:

  1. The public key for that platform (REVENUECAT_IOS_API_KEY or REVENUECAT_ANDROID_API_KEY) is set in .env, and you ran php artisan config:cache. Open https://your-domain.com/api/settings: the keys appear in it.
  2. The product IDs in Settings > Store products match the store and RevenueCat exactly, letter for letter.
  3. The agreements, tax and banking are signed in both stores (Paid Apps Agreement in App Store Connect, merchant profile in Play Console).
  4. iOS products are Ready to Submit with a price and a localisation. Android products are Active, and the app has been uploaded to a testing track.
  5. The products exist in RevenueCat under Product catalog > Products, in the right app.
  6. You test with a licence tester (Google) or a Sandbox tester (Apple), on a real device.
The listener paid, but no coins arrived
  • Check REVENUECAT_SECRET_KEY is the secret key (sk_…), not a public one.
  • Check the product ID is listed under Settings > Store products as a coin pack with its coins.
  • The sync request answers 503 while the secret key is missing or RevenueCat cannot be reached, and the app tries again later. Open the app again after fixing it.
  • Look at the log for errors from RevenueCat.
  • Check the listener's page in the admin panel: purchases and coin history show there.
Premium does not turn on after a subscription

The subscription products must be attached to the entitlement named premium in RevenueCat (and REVENUECAT_ENTITLEMENT must match if you named it differently). Coin packs must not be attached to it.

The RevenueCat webhook test fails
  • 503: REVENUECAT_WEBHOOK_AUTH is empty on the server, or the cached configuration is stale. Set it and run php artisan config:cache.
  • 403: the Authorization header in RevenueCat differs from REVENUECAT_WEBHOOK_AUTH.
  • 404: the backend there is an old version without the webhook, or the URL is wrong.
Rewarded ads give no coins

With ADMOB_SSV_ENABLED=true, coins come only from AdMob's signed callback to /api/admob/ssv. Check that server-side verification is on for the rewarded ad unit with that exact URL. Google's test ad units never call your server: set ADMOB_SSV_ENABLED=false while testing with them, and back to true before release.

Shared links open the browser instead of the app
  1. Both /.well-known/ files must answer 200 with JSON (section 13). The Verification files box on Settings > App links must say Ready for both.
  2. Android: the SHA-256 fingerprints must include the Play App Signing key for store installs. The package name must match the app's.
  3. iOS: the Team ID and bundle ID must match, and the app must have the Associated Domains capability with applinks:your-domain.com.
  4. The domain in AndroidManifest.xml and Runner.entitlements must be your backend's domain.
  5. Android checks the file when the app is installed or updated, and Apple caches it for up to a day: uninstall and reinstall the app after any change.
Notifications never arrive
  • Set FIREBASE_SERVICE_ACCOUNT_JSON to the full path of a key with the Firebase Cloud Messaging API Admin role (section 12), then php artisan config:cache.
  • Check the cron job runs (above).
  • Check the listener has registered a device: their page in the admin panel shows Push devices. They must have allowed notifications.
  • Failed announcements show on the dashboard. Check the Firebase credentials.
Push notifications do not arrive on iPhones
  • Upload an APNs authentication key to Firebase (Project settings > Cloud Messaging) with the right Key ID and Team ID.
  • Check that Push Notifications is listed under Signing & Capabilities in Xcode.
  • Test on a real device with a build signed for it; some simulators cannot receive push.
  • On the device, check that notifications are allowed for the app in iOS Settings.

19.5Building

flutter build ios --simulator fails with "does not contain architectures"

This is a known problem with Xcode 27. Use the xcodebuild workaround in section 16.2.

Google Play rejects the upload because it is signed with a debug key

android/key.properties is missing or wrong, so the build fell back to the debug key. Create it (section 16.1) and build again.

Google Play says the version code has already been used

Raise the number after the plus sign in version: in pubspec.yaml and build again. It must go up with every upload.

The app name looks wrong or blank on an iPhone

Check that CFBundleDisplayName and CFBundleName in Info.plist are on one line with no spaces or line breaks around the name.

20FAQ

Can I run the app without ads?

Yes. Switch Show ads off in Settings > Ads. Rewarded ads then no longer exist, so listeners earn coins through the welcome gift, the daily check-in and invites. Premium subscribers never see ads either way.

Can I run it without Premium?

Yes. Remove every Premium plan under Settings > Store products and save. Premium disappears from the app.

Can I run it without anyone buying coins?

Yes. Remove the coin packs under Settings > Store products, or never set up RevenueCat, and listeners can still earn coins free. You can also make titles free by setting their price to 0. The app then has no purchases at all.

Can I change the currency?

The stores charge in each listener's own currency, so you do not set it. The currency in Settings > Store products only labels the dashboard's revenue estimates.

Can I host the audio somewhere else?

Yes. Use an S3-compatible bucket (section 14) and keep the protected links. You can also paste a link to audio hosted elsewhere, but a pasted link is handed out as it is, so use it only for free content.

Can I add a language?

Two kinds. For the app's text, copy an .arb file, translate it and regenerate (section 15.9). For audiobooks in a new language, add it under Languages in the admin panel.

Can several people use the admin panel?

Yes. Add them on the Team page; each gets an email to choose their own password. All admins have the same rights.

Does it work on shared hosting?

Yes, if the host has PHP 8.2 or newer, MySQL or MariaDB, cron and HTTPS. Use a package that includes vendor/ and the web installer (section 4, path B).

Can I rename everything?

Yes. The name, logo and accent colour of the admin panel and website are under Settings > Branding. The app's name, package ID, icons and colours are set in its code (section 15). The licence and the terms of your purchase still apply.

What happens to coins when a purchase is refunded?

The coins of a refunded coin pack are taken back from the listener's balance, but never below zero: coins already spent cannot be recovered. A refunded subscription ends Premium. See refunds.

Can a listener use their coins on a new phone?

Yes. Coins, unlocks, listening progress and bookmarks live on your server and follow the listener's account to any device. Downloads for offline listening stay on the device, and signing out removes them.

Can I move the product to another domain?

Yes. Use License > Move this licence on the old site to hand the domain back, then install and activate on the new one. Remember that the domain is written into the app for shared links, so the app needs a new release.

21Changelog, licence and support

21.1Changelog

Versions follow MAJOR.MINOR.PATCH. The version of the whole product, backend and app together, is in audisode_backend/config/app.php and shows in the admin panel's user menu and in php artisan app:install. Quote it when you ask for support. The app also carries its own number in audisode/pubspec.yaml (version: 1.0.1+4 is version name 1.0.1, build number 4). The stores need that number to go up with every upload, so it does not match the product version. The full text is in CHANGELOG.md.

1.0.0: first marketplace release

Mobile app (Android and iOS)

  • Audiobooks as single files or series of episodes, with covers, authors, categories, languages, collections and a home screen laid out from the admin panel
  • Playback with background audio, lock-screen controls, speed control, sleep timer, bookmarks and "continue listening" that follows the listener across devices
  • Offline downloads that pause, resume and survive a closed app
  • Coins: a welcome gift, a 7-day check-in streak with reminders, rewarded ads and invite-a-friend rewards; paid titles unlock with coins
  • Premium subscription that unlocks everything and removes ads
  • In-app purchases through RevenueCat (coin packs and subscriptions), with restore
  • Sign in with Google and Sign in with Apple through Firebase; guests can browse and play free titles
  • Reviews and ratings, push notifications, shared book links that open the app
  • AdMob ads with Google's consent form and an "Ad privacy" menu entry
  • Account deletion inside the app
  • Four languages: English, Bengali, Arabic (right to left) and Spanish
  • Light and dark themes
  • App icon and launch screen from one image
  • iOS privacy manifest and Google's SKAdNetwork list
  • Up-to-date dependencies, including google_sign_in 7 (see the app README for the two packages held back)

Backend and admin panel (Laravel)

  • REST API with token sign-in, rate limits and one error format
  • Server-side wallet and unlocks: the app cannot grant itself coins or titles
  • Audio kept outside the web root and handed out as short-lived signed links, or from S3-compatible cloud storage
  • Purchases verified against RevenueCat, with webhooks for renewals, expirations and refunds
  • Admin panel: dashboard with analytics, books, episodes, bulk upload, authors, categories, languages, home screen layout, collections, listeners, reviews, push notifications, AI narration (OpenAI or ElevenLabs), settings and legal pages
  • Public website with store badges and legal pages
  • Read-only demo admin for public demos
  • Web installer at /install for hosting without SSH
  • Licence activation for one domain, a daily check that never takes the site down when the licence server is unreachable, and the License page
  • Settings > Branding: name, accent colour, logo and favicon
  • Team: add admins by email invite, remove them; the last admin stays
  • php artisan app:install --sample-books: seven public-domain English audiobooks
  • Panel libraries and fonts served from public/vendor/: no CDN, no Google Fonts
  • Sign-in tokens expire after a year; expired ones are pruned daily
  • php artisan app:install with a configuration checklist
  • Light and dark admin themes, a command palette (Ctrl or ⌘ + K) and security headers

21.2Licence and support

Each copy of the product is licensed for one domain, activated with the key and username from your purchase (License page). The licence terms and the support terms are set by the seller of the product and are filled in below.

Licence terms{{LICENCE_SUMMARY}}
Support channel{{SUPPORT_CHANNEL}}
Support hours{{SUPPORT_HOURS}}

21.3Credits and third-party notices

Audisode uses open-source software and fonts, each under its own licence. The full list is in THIRD_PARTY_NOTICES.md at the top of the package. In short:

  • Admin panel and website: Bootstrap (MIT), Bootstrap Icons (MIT), Chart.js (MIT), Tom Select (Apache-2.0) and Quill (BSD-3-Clause), all served from audisode_backend/public/vendor/.
  • Fonts: Fraunces and Literata in the app, Geist, Geist Mono and Instrument Serif in the admin panel, all under the SIL Open Font License 1.1.
  • Sample content: the seven public-domain books and their LibriVox recordings, streamed from archive.org. Delete them before you launch.
  • Packages: the PHP packages are listed in audisode_backend/composer.lock and the Dart and Flutter packages in audisode/pubspec.lock.
  • Artwork: replace the placeholder images, icons and onboarding pictures with your own before you publish, and make sure you have the right to use every image, sound and font you ship.