Documentation

Introduction

CashLoop | Native Android (Kotlin/Compose) with Laravel Admin Panel

Thank you for purchasing CashLoop Android.


This documentation package covers the native Android app (cashloop-android, Kotlin + Jetpack Compose) and the Laravel admin panel and mobile API it runs against (cashloop-admin-panel). Offers, partners, wallets, cashouts, quests and home-screen layout are all owned by the panel. Jump to Native Android for configuration, reskin and Gradle setup, or to Run From Scratch for the full end-to-end install.

CashLoop is a ready-to-launch native GPT ("get-paid-to") rewards app for Google Play, powered by a complete Laravel 13 admin panel and a frozen mobile API. Users earn real money by installing and playing apps and games, completing surveys, finishing offerwall tasks and watching rewarded video — then withdraw it as gift cards, prepaid cards, e-wallet payouts or crypto. It is designed for publishers who want a real reward economy with an auditable money trail, not a coin counter in local storage.

Every balance change in CashLoop is a ledger row. Nothing writes a wallet balance directly; a conversion, a quest claim, a referral payout and a withdrawal all append to wallet_transactions, and an hourly wallet:verify job re-sums the ledger and exits non-zero if any balance has drifted. This is the difference between a demo and something you can put real payouts through.

Earning is attributed by click id. When a user opens an offer, the server mints a uuid, hands the app a tracked URL containing it, and the partner returns that same id on their server-to-server postback. That is how a conversion finds its user — a raw user id is never sent to a third party, and the SDKs never touch a wallet.

Key Value Propositions:

  • For Users: Five clean tabs — Apps, Games, Surveys, Cashout, Profile — with a live balance in the header, boosted payouts shown with the original price struck through, daily streaks, claimable quests, levels, promo codes, referral earnings, and withdrawals that show every unmet rule at once instead of one at a time.
  • For Publishers: Run the whole platform from one Laravel dashboard — partners and their credentials, offers and categories, boosts, quests, levels, streak rewards, promo codes, cashout catalogue and withdrawal queue, fraud rules, postback logs and an immutable audit trail. The home screen itself is admin-built: reorder or switch off a whole section without an app release.
  • For Buyers / Developers: Full Kotlin source (Compose, Hilt, Retrofit, kotlinx.serialization, DataStore Proto), full Laravel source, migrations and seeders — no SQL dump anywhere — a frozen and documented API v1 contract, six languages, nine colour presets, and eight offerwall partners already wired with a fail-closed postback layer.

Disclaimer: This app is built to work with the included Laravel admin panel (cashloop-admin-panel) and the following third-party services: Firebase (Auth, Cloud Messaging, Remote Config), offerwall and survey partners (BitLabs, ayeT-Studios, MyChips, Digital Turbine, CPAlead, Torox, Revenue Universe, MM Wall), OneSignal (push), and optional Google AdMob / Unity Ads. These backend and partner services are not included in the purchase — you will need to create your own accounts, sign publisher agreements, deploy Laravel on a PHP 8.3 / MySQL 8 host, configure credentials, and manage hosting costs yourself. Basic familiarity with Android Studio, Firebase, Laravel and Play Console is recommended before launching.

Payouts are real money. Read Partner Postbacks before you switch a partner to active.

What's Included:

  • Full native Android source code (Kotlin + Jetpack Compose, 200+ source files)
  • Laravel 13 admin panel with the frozen mobile API v1
  • 17 migrations and 22 seeders — the schema is the migrations, there is no SQL dump
  • 9 colour presets and an onboarding variant switch
  • 8 offerwall / survey partners: 4 native SDKs + 4 hosted walls, plus a generic adapter
  • Usage-verified offers (on-device play-time verification via UsageStatsManager)
  • Multi-language support (en, tr, de, es, it, ar)
  • Setup and deployment documentation (this guide)
  • Android release checklist

Key Features:

  • Real Money Wallet: decimal(14,6) balances handled with bcmath, never floats. Cash and coins, pending vs available, and a full transaction history the user can scroll.
  • Offers, Games and Surveys: One paginated feed implementation with three configurations, sort chips (payout, new, popular, quick, rating), category and provider filters, search, and admin-built sections above the list.
  • Reward Ladders: An offer can pay in steps ("reach level 5", "spend 7 days in app") with requirements and per-step payouts shown on the detail screen.
  • Boosted Payouts: When a boost is running the API sends original_payout and boost_percent, so the app renders the struck-through price without deciding anything itself.
  • Usage-Verified Offers: For offers you run yourself, the app measures real on-device play time and posts it; the server decides whether it qualifies.
  • Offerwall Partners: Native SDKs for BitLabs, ayeT-Studios, MyChips and Digital Turbine, hosted walls for CPAlead, Torox, Revenue Universe and MM Wall — with a registry that always falls back to the tracked URL rather than opening nothing.
  • Cashout Catalogue: Gift cards, prepaid cards, e-wallets and crypto grouped by brand, already filtered server-side to what this user can actually use.
  • Quests, Streaks and Levels: Claimable — never auto-credited — with a badge on the Profile tab the moment something becomes claimable.
  • Referrals: Signup bonus for both sides, a percentage of the friend's lifetime earnings, and an optional bonus on their first withdrawal.
  • Promo Codes: Redeemable in-app, rate-limited separately because codes are guessable by design.
  • Server-Side Kill Switches: A feature switched off in the panel has its tab removed, not greyed out.
  • Maintenance & Force Update Gates: Computed server-side from X-Platform and X-App-Version, and deliberately terminal — no back gesture, no "continue anyway".
  • Guest-to-Account Flow: Anonymous Firebase session on first launch; signing in with Google, Facebook or email upgrades it in place, so the balance and streak carry over with no merge step.
  • Fraud & Integrity Tooling: Configurable fraud rules, user flags, postback logs with redacted payloads, and immutable audit rows.
  • White-Label Ready: Colour presets, app icon, name, locales, ad units, push, legal pages and home-screen layout — all changeable without touching gameplay code.

App identifiers (current project):

  • Android applicationId / namespace: com.cashparty.rewards.app
  • versionCode 10 · versionName 2.0
  • Min SDK 26 · Target / Compile SDK 36 · JDK 17+ · AGP 9 (Kotlin built in)
  • Release API origin baked in as https://panel.cashloop.app/, overridable at runtime

Support: For questions or issues, contact us via the CodeCanyon messaging system.


📦 What's Included in the Package

After purchasing and downloading CashLoop Android, your package contains everything you need to build, rebrand and publish the native Android app to Google Play, plus the backend it runs against:

ItemWhat it is
Full native Android sourceKotlin + Jetpack Compose project (cashloop-android/app/, core/datastore-proto/, dummylib/, Gradle files). See Native Android.
Laravel admin panelFull PHP backend (cashloop-admin-panel/) — admin dashboard, public site, mobile REST API at /api/v1, partner postback endpoint, 17 migrations, 22 seeders, scheduled integrity commands.
Partner adaptersPostback adapters for BitLabs, CPAlead and Torox, plus a config-driven GenericOfferwallAdapter that covers any partner whose dialect you can describe in settings.
6 language translationsAndroid string resources under res/values/, values-en/, values-tr/, values-de/, values-es/, values-it/, values-ar/.
9 colour presetsArcade design-system palettes in Kotlin (ArcadePresets) — one constant switches the whole app, light and dark together. See Reskin.
Android unit tests7 JVM test classes covering the API envelope, paging, money serialization, cashout validation, home-section decoding and offerwall launcher resolution. ./gradlew :app:testDebugUnitTest
Backend test suite30 feature test files — 256 tests, all passing — over the wallet ledger, postback ingestion and signature verification, cashout eligibility and holds, the guest merge, referrals, quests and streaks, geo targeting, admin authorisation and the seeded install. php artisan test
Backend documentationREADME.md inside the admin panel — install, configuration, console commands and the seeded install. The frozen mobile API v1 contract is documented in this guide.
Licences & attributionlicenses/ in the Android project — the full SIL OFL 1.1 text for the bundled fonts, per-font attribution, and the third-party asset record.
This documentationThe HTML guide you are reading, covering configuration, reskin, admin panel, deployment, partner setup, the Play release path and Play policy.
In plain English: You get the native Android app, the Laravel backend it runs on, the partner plumbing that pays users, translations, and this guide — everything required to rebrand it and ship it under your own Play Console account.
No SQL dump is included, and none should ever be imported. The migrations are the schema. php artisan migrate on an empty database produces a complete, correct install — see Database Setup.

🤖 Native Android App Overview

CashLoop Android is a single-module Compose app plus one small library module. There is no Flutter, no WebView shell and no second UI framework — every screen you see is Compose.

LayerTechnology
UIJetpack Compose · Material 3 · Navigation Compose · custom "Arcade" design system
DIHilt (KSP)
NetworkingRetrofit 2.11 + OkHttp 4.12 + kotlinx.serialization
StorageDataStore (Preferences + Proto, module core:datastore-proto)
IdentityFirebase Auth (anonymous, Google, Facebook, email) → API JWT
ConfigFirebase Remote Config (one key) + GET /api/v1/config
PushFirebase Cloud Messaging + OneSignal
MediaCoil (incl. SVG) · Lottie · compose-shimmer
AdsGoogle Mobile Ads · Unity Ads
OfferwallsBitLabs · ayeT-Studios · MyChips · Digital Turbine (FairBid) · Torox AAR

Where each setting lives

Configuration is split deliberately, so public and secret values never share a home and so the things that must change without a release actually can:

LayerLocationHolds
Gradle build configapp/build.gradle.ktsDefault API origin per build type, applicationId, versions
Firebase Remote ConfigFirebase ConsoleOne key only: api_base_url
Mobile APIGET /api/v1/configEconomy, currency, feature flags, ad unit ids, OneSignal app id, legal links, maintenance and force-update gate
Mobile APIGET /api/v1/providers/{slug}/sdk-configPublisher-safe keys each offerwall SDK needs to initialise
Android resourcesres/values/strings.xmlApp name, Google OAuth client id, Facebook app id/token, AdMob application id
Firebase native fileapp/google-services.jsonPer-project Firebase identity
Laravel .envcashloop-admin-panel/.envDB credentials, APP_URL, JWT_SECRET, FIREBASE_CREDENTIALS, OneSignal server key, SMTP, storage
Admin panel databaseprovider_settingsPartner credentials and postback secrets — encrypted at rest
Signingdebug.keystore, your own release keystoreBuild signing
Security rule. The app only ever holds public keys: the AdMob application id, the Google OAuth web client id, the Facebook app id and client token. Partner secrets, postback signing secrets, the JWT signing key and the Firebase service account stay on the server. Offerwall SDK keys are fetched at runtime from the API rather than compiled in, so rotating a partner key is a panel edit, not a release.

⚡ Android Quick Start

Four things must be in place before the app can do anything useful:

#WhatWhere
1Laravel admin panel running with migrations appliedcashloop-admin-panel/ — see Step 4
2Firebase project + google-services.jsonapp/google-services.json
3Firebase service account JSON on the serverFIREBASE_CREDENTIALS in Laravel .env
4An API origin the device can actually reachGradle default, -Pcashloop.apiBaseUrl, or Remote Config
# Emulator, against a local panel on the host machine (default)
./gradlew :app:installDebug

# Physical device, against your LAN IP or a tunnel
./gradlew :app:installDebug -Pcashloop.apiBaseUrl=https://abc123.ngrok.io/

# Unit tests
./gradlew :app:testDebugUnitTest
The API origin must end in a slash. Retrofit resolves relative paths against it; https://panel.example.com without the trailing slash silently drops the last path segment.
In plain English: Start the backend first. The app is a client — with no reachable API it will sign in anonymously, fail every read, and show empty feeds.

⚙️ Android Configuration

1. API Base URL

The origin is resolved from three places, in this order — later wins:

OrderSourceWhen to use it
1BuildConfig.API_BASE_URL — baked in per build typeThe permanent default for each variant
2-Pcashloop.apiBaseUrl=… on the Gradle command linePointing one build at a tunnel or a LAN IP
3Firebase Remote Config key api_base_urlChanging the origin on already-installed apps, with no release
// app/build.gradle.kts
val debugApiBaseUrl: String =
    (findProperty("cashloop.apiBaseUrl") as String?) ?: "http://10.0.2.2:8000/"
val releaseApiBaseUrl: String =
    (findProperty("cashloop.apiBaseUrl") as String?) ?: "https://panel.cashloop.app/"

10.0.2.2 is the Android emulator's route to the host machine — localhost inside the emulator is the emulator. On a physical device you must use a LAN IP or a tunnel.

Change releaseApiBaseUrl before you build a release. Shipping with panel.cashloop.app points every installed copy of your app at someone else's server.
Never put cashloop.apiBaseUrl in the project's gradle.properties. That file is committed, and the property is read for both variants — so a value left there silently overrides the release URL too, and every APK you build afterwards points at your development machine. A tunnel hostname also publishes your IP address to anyone who inspects the APK.

Pass it per-invocation with -Pcashloop.apiBaseUrl=…, or put it in ~/.gradle/gradle.properties, which lives outside the repository. To check what a built APK actually points at:
unzip -p app-release.apk classes.dex | strings | grep -i "https://"
Cleartext HTTP is disabled (usesCleartextTraffic="false" in the manifest) — except for the emulator default, every origin must be HTTPS. If you need plain HTTP against a LAN box for a day, add a debug-only network security config rather than flipping this flag for release.

2. Package ID & version

android {
    namespace = "com.cashparty.rewards.app"
    compileSdk = 36

    defaultConfig {
        applicationId = "com.cashparty.rewards.app"
        minSdk = 26
        targetSdk = 36
        versionCode = 10
        versionName = "2.0"
    }
}

minSdk is 26 rather than 24 on purpose: the Arcade type scale drives the variable font weight axis through FontVariation, which the platform ignores below API 26 — every weight would silently render at the axis default.

Changing applicationId means recreating the Firebase Android app, replacing google-services.json, regenerating the Google OAuth client, updating the Facebook app's Android package, and using the new id in Play Console. All of them must match or sign-in breaks.

3. Firebase & google-services.json

  1. Firebase Console → Add app → Android.
  2. Package name: exactly your applicationId.
  3. Add your debug and release SHA-1 and SHA-256 fingerprints (required for Google Sign-In).
  4. Download google-services.json and place it at app/google-services.json, replacing the bundled one.
  5. Enable Authentication (Anonymous, Email/Password, Google, Facebook), Cloud Messaging and Remote Config.
Anonymous auth is not optional. The app signs in anonymously on first launch and trades that Firebase ID token for the API JWT pair. With Anonymous disabled, nothing past the splash screen works.

4. Strings & SDK keys

Everything compiled into the binary lives in app/src/main/res/values/strings.xml.

These ship as placeholders, not working credentials. No live key of ours is distributed with this package, so every row below is something you replace before you publish. The app still builds and runs untouched — that is what the placeholders are chosen for.
StringShips asWhat it is / where to get it
app_nameCashLoopLauncher label — your brand
facebook_app_id000000000000000Meta for Developers → your app → Settings → Basic
fb_login_protocol_schemefb000000000000000fb + the app id, used by the login redirect
facebook_client_tokenREPLACE_WITH_…Meta app → Settings → Advanced
admob_app_idGoogle's public test app id
ca-app-pub-3940256099942544~3347511713
AdMob console → your app. Serves test ads until replaced
Facebook login is skipped, not broken, while the placeholder is in place. FacebookSdk.fullyInitialize() throws on a malformed app id and would take the process down on first launch, so CashLoopApplication checks the id first and skips Facebook setup if it is still the placeholder. Every other sign-in method keeps working. Drop in a real id and it initialises normally — no code change.
The AdMob application id must be well-formed. The Google Mobile Ads SDK fails to initialise if the manifest APPLICATION_ID meta-data is missing or malformed, which is why the shipped default is Google's real public test id rather than an obvious dummy string. Ad unit ids are different — those come from the API and may be blank.
default_web_client_id is deliberately not in strings.xml. The google-services Gradle plugin generates it from the OAuth client inside your app/google-services.json, so Google Sign-In follows whichever Firebase project you drop in. Declaring it by hand as well pins it to a second project, and if the two disagree the ID token exchange fails in a way that is hard to trace. Replace google-services.json and this looks after itself.
Offerwall SDK keys are not here. BitLabs, ayeT-Studios, MyChips and Digital Turbine credentials are fetched at runtime from GET /providers/{slug}/sdk-config and read out of an open settings map, so adding or rotating a partner never needs an app release. See Offerwall Partners.

🎨 Reskin & Layout Variants

Two presentation axes are switched by a single Kotlin constant plus a rebuild. Everything else that changes the look of the app — which sections appear on a feed, in what order, with what titles — is admin-driven and needs no rebuild at all.

Colour presets (1–9)

File: app/src/main/java/com/cashparty/rewards/app/ui/theme/arcade/ArcadePresets.kt

object ArcadePresets {
    /**
     * CHANGE THIS (1..9) TO SWITCH COLOR PRESET.
     * 1 Ember Aurora  2 Emerald Pulse  3 Sunset Vinyl  4 Midnight Gold
     * 5 Neon Coral    6 Ink & Parchment  7 Bronze Luxury  8 Arcade
     * 9 CashLoop  <- active
     */
    const val SELECTED = 9
}

Each preset carries both the light and the dark role set, so switching one constant restyles the entire app in both modes — accents, canvas, surfaces, cards, dividers, text, and the CTA/social-proof gradients.

1 Ember Aurora
2 Emerald Pulse
3 Sunset Vinyl
4 Midnight Gold
5 Neon Coral
6 Ink & Parchment
7 Bronze Luxury
8 Arcade
9 CashLoop ★

To add a tenth preset: copy an existing ArcadePreset(...) block, give it a name, add a branch to the when in active, and set SELECTED. The data class lists every role the theme needs, so the compiler tells you if you missed one.

Onboarding variant

File: .../ui/onboarding/OnboardingRoute.ktOnboardingConfig.SELECTED

All variants share one completion pipeline — they differ in how they pitch the product, not in plumbing. Variant 1 (Deck) is the one implemented on Android; the remaining slots fall back to Deck until their scaffolds are ported, so changing the constant is safe but currently a no-op for 2–5.

In plain English: Change one number for the colour scheme. Everything else about how the app looks day to day — which shelves show on Apps, what the banner says, which partners are featured — you change in the admin panel while the app is live.

🛠 Admin Panel → App Config

Most of what used to be an app constant is served by GET /api/v1/config and applied on the next launch. The app fetches it before sign-in so a broken auth flow cannot hide a "we are down" message.

BlockKeysWhat it drives
economycoins_per_usd, min_cashout_cash, signup_bonus_cash, signup_bonus_coins, currency_codeEvery money format in the app. Never hardcode a $ — the currency symbol comes from here.
referralreferee_bonus_cash, referrer_bonus_cash, earnings_percent, cashout_bonus_cashThe copy and numbers on the Refer a Friend screen
featuresoffers, surveys, offerwalls, quests, cashout, referral, streak, promo_codesKill switches. A disabled feature has its tab removed, not greyed out.
adsadmob.{enabled, banner_id, interstitial_id, rewarded_id, native_id, interstitial_interval_minutes}, unity.{enabled, game_project_id, banner_id, interstitial_id, rewarded_id}, rewarded_ad_reward_coins, daily_rewarded_ad_limitWhich ad networks run, which unit ids, and what a rewarded view pays
releasemaintenance_mode, maintenance_message, force_update, min_supported_version, latest_versionThe two full-screen gates
linkshelp_url, faq_url, privacy_url, terms_url, support_emailSettings rows. A null link hides its row rather than opening a blank page.
pushonesignal_app_idOneSignal initialisation
updateminimum_version, latest_version, must_update, update_available, maintenance_mode, maintenance_messageComputed server-side from X-Platform and X-App-Version, so the decision cannot drift between client versions

Where to edit these in the panel:

Panel screenRoute
App settings (economy, features, ads, gates)/admin/settings/app
General settings/admin/settings/general
Contact / support/admin/settings/contact
Legal & help pages (served to GET /pages/{slug})/admin/content/pages
Home & feed sections, banners/admin/app-sections
Locales/admin/locales/all
Config is read at launch. A change in the panel reaches a running app on its next cold start, not instantly. Plan maintenance windows accordingly — the gate is checked on the same fetch.

🏷 Branding — Name, Icon, Theme

WhatWhere
App nameres/values/strings.xmlapp_name (and each values-xx/strings.xml if you want it localised)
Launcher iconres/mipmap-*/ic_launcher.png, ic_launcher_round.png, adaptive icon in mipmap-anydpi-v26/, background colour in res/values/ic_launcher_background.xml
Theme coloursArcadePresets.SELECTED — see Colour presets
Base Android themeres/values/themes.xmlTheme.CashParty (referenced by the manifest)
Fontsres/font/ — Fredoka and Plus Jakarta Sans, wired through ui/theme/arcade/ArcadeType.kt
Lottie animationsres/raw/
Android Studio's Image Asset wizard (right-click res → New → Image Asset) regenerates every density and the adaptive layers in one pass. Do that rather than hand-replacing PNGs.

🌐 Android Locales

Six languages ship: en, tr, de, es, it, ar. The default (untagged) res/values/strings.xml is English and is the fallback for any key a translation is missing.

Per-app language selection uses AppCompatDelegate locale storage — the manifest declares AppLocalesMetadataHolderService with autoStoreLocales=true, so the choice survives reinstall-free upgrades and appears in Android 13+ system settings. The in-app picker is ui/settings/LanguageScreen.kt, which lists what GET /api/v1/locales returns.

Keep the panel and the app in sync. A locale enabled at /admin/locales/all but with no values-xx/ folder shows an English UI with localised server content. Add the resource folder, or do not enable the locale.

Arabic ships with android:supportsRtl="true"; the Compose layouts use start/end padding rather than left/right throughout, so RTL mirrors correctly.

The app and the panel do not ship the same set, and that is intentional. The app has six locales including a complete values-ar; the panel and public site have five — there is no resources/lang/ar.

locales.is_active means "offered to the mobile app" and is what GET /api/v1/locales serves, so Arabic is active there. The panel's own switcher does not read that flag directly: ShareLocales lists only the locales that actually have a resources/lang/{code} directory. Add resources/lang/ar and the panel offers Arabic on the very next request — there is no second flag to remember, and the two cannot drift apart.

💳 Ads, Push & Offerwall SDKs

Ads

Two networks, both optional, both configured server-side:

  • Google AdMob — banner, interstitial, rewarded and native unit ids from config.ads.admob. The application id is the one exception and lives in the manifest via @string/admob_app_id.
  • Unity Ads — banner, interstitial and rewarded placements from config.ads.unity, managed by ads/UnityBannerAdManager.kt and ads/UnityRewardedAdManager.kt.

A rewarded view pays rewarded_ad_reward_coins, capped at daily_rewarded_ad_limit per day. Setting either network's enabled to false, or leaving its unit ids blank, removes the surface entirely — the app does not render an empty ad slot.

Push

Both channels are wired:

  • FCMservice/CashLoopMessagingService.kt, registered in the manifest for com.google.firebase.MESSAGING_EVENT. Tokens are posted to POST /auth/device.
  • OneSignal — initialised with config.push.onesignal_app_id; broadcast sends come from the panel at /admin/notifications. The REST key stays in the Laravel .env.

OneSignal is initialised in MainActivity only when config.push.onesignal_app_id is non-empty, and the user is identified with OneSignal.login(userId) so the panel can target an individual account.

POST_NOTIFICATIONS is declared in the manifest but never requested at runtime. On Android 13+ that means notifications stay off until the user enables them in system settings. If push matters to you, add a permission request — the natural place is right after onboarding completes, not on the first frame.

Offerwall SDKs

Four native SDKs are compiled in. None of them credits anything — they open a wall, and rewards arrive later by server-to-server postback:

PartnerSlugArtifactSettings keys it reads
BitLabsbitlabscom.prodege.bitlabs:coresdk_api_key
ayeT-Studiosayetio.ayet:android-sdk-v2sdk_app_id (numeric), sdk_placement
MyChipsmychipsio.mychips:offerwallsdk_api_key, sdk_placement
Digital Turbinedigital_turbinecom.fyber:fairbid-sdksdk_app_id, sdk_placement, sdk_api_key (salt)
Toroxtoroxdummylib/ToroxSDK-v6.4.aarHosted wall — opened by tracked URL
The registry always opens something. If a launcher claims the slug it gets first refusal; if the SDK is unavailable, misconfigured or fails, the tracked URL is opened in a Custom Tab instead. A tap that opens nothing is indistinguishable from a broken app, and the web version of an offer works for every partner.
MyChips publishes to its own Maven repository. settings.gradle.kts adds europe-west1-maven.pkg.dev/mychips-b31fe/mychips-android-sdk, scoped to the io.mychips group only. It is publicly readable and needs no credentials, but do not widen that scope — an unfiltered third-party repo can shadow an artifact from Maven Central.

📁 Android Project Structure

cashloop-android/
├── app/
│   ├── google-services.json          # Firebase identity — replace with yours
│   ├── build.gradle.kts              # applicationId, versions, API origins, dependencies
│   ├── proguard-rules.pro
│   └── src/
│       ├── main/
│       │   ├── AndroidManifest.xml
│       │   ├── res/                  # values(+ -en -tr -de -es -it -ar), drawable, font, mipmap, raw
│       │   └── java/com/cashparty/rewards/app/
│       │       ├── CashLoopApplication.kt
│       │       ├── api/              # Retrofit service, envelope, error codes, paging, interceptors
│       │       ├── ads/              # AdMob + Unity rewarded/banner managers
│       │       ├── contracts/        # Google Sign-In activity contract
│       │       ├── data/
│       │       │   ├── auth/         # AuthManager, TokenStore, DeviceIdProvider
│       │       │   ├── config/       # RemoteConfigService
│       │       │   ├── models/       # Wire models — one file per API area
│       │       │   └── repositories/ # 14 repositories, one per API area
│       │       ├── datastore/        # Proto + preferences persistence
│       │       ├── di/               # Hilt modules (App, Network, Firebase, Offerwall, Activity)
│       │       ├── offerwall/        # Launchers, registry, SDK config store, usage tracker
│       │       ├── service/          # FCM messaging service
│       │       ├── usage/            # UsageStatsManager observer, reporter, diagnostics
│       │       ├── ui/
│       │       │   ├── MainActivity.kt, CashLoopApp.kt, CashLoopAppState.kt
│       │       │   ├── shell/        # AppHeader, MainTabBar, MainTabs
│       │       │   ├── tabs/feed/    # Apps · Games · Surveys (one screen, three configs)
│       │       │   ├── tabs/profile/
│       │       │   ├── offers/       # List, detail, my offers, partners
│       │       │   ├── cashout/      # Catalogue, request sheet, validation
│       │       │   ├── wallet/       # Transactions
│       │       │   ├── engagement/   # Quests, streak, promo codes
│       │       │   ├── referral/ notifications/ settings/ auth/ onboarding/
│       │       │   ├── gate/         # Maintenance + force update
│       │       │   ├── splash/ welcome/ home/account/
│       │       │   ├── common/       # Shared composables, ads, arcade widgets
│       │       │   └── theme/arcade/ # Presets, tokens, type scale, motion
│       │       └── util/             # Money, colours, modifiers, tracked links, text field state
│       └── test/                     # 7 JVM test classes
├── core/datastore-proto/             # Protobuf schema for persisted settings
├── dummylib/ToroxSDK-v6.4.aar        # Torox SDK, not published to a public repo
├── gradle/libs.versions.toml         # Single source of truth for every dependency version
├── settings.gradle.kts               # Repositories, module list, JDK 17 check
├── debug.keystore                    # Bundled so debug builds from CI stay upgradable
└── build.gradle.kts
In plain English: data/ talks to the server, ui/ draws things, offerwall/ and usage/ are the two places that deal with partners, and di/ wires it all together. If you are adding a screen you will touch ui/ and one repository, and nothing else.

🏗 Build & Run (Gradle)

Requirements: Android Studio (Ladybug or newer), JDK 17+ (settings.gradle.kts fails the build below that), Android SDK 36, and an emulator or device on API 26+.
# Debug build + install
./gradlew :app:assembleDebug
./gradlew :app:installDebug

# Point a build at a different backend
./gradlew :app:installDebug -Pcashloop.apiBaseUrl=https://panel.example.com/

# Unit tests (JVM, no device needed)
./gradlew :app:testDebugUnitTest

# Instrumented tests (device/emulator required)
./gradlew :app:connectedDebugAndroidTest

# Release artefacts
./gradlew :app:assembleRelease      # APK
./gradlew :app:bundleRelease        # AAB for Play

# When Gradle misbehaves
./gradlew clean && ./gradlew --stop

Signing notes

The project ships a bundled debug keystore (debug.keystore) so debug builds from any machine or CI runner stay mutually upgradable. The release build type never falls back to it: with no release key configured, assembleRelease succeeds and produces app-release-unsigned.apk, which you cannot upload until you supply your own key.

That is deliberate. debug.keystore is inside this package, so everyone who has the source has the key — a release signed with it could be re-signed by anyone into an update your users' devices would accept.

Supply your key through Gradle properties or environment variables, never in the repository:

# ~/.gradle/gradle.properties — outside the project, so it is never committed
cashloop.releaseStoreFile=/absolute/path/to/release.jks
cashloop.releaseStorePassword=...
cashloop.releaseKeyAlias=cashloop
cashloop.releaseKeyPassword=...

# or as environment variables in CI
CASHLOOP_RELEASE_STORE_FILE      CASHLOOP_RELEASE_STORE_PASSWORD
CASHLOOP_RELEASE_KEY_ALIAS       CASHLOOP_RELEASE_KEY_PASSWORD
Back the keystore and its passwords up before you publish. Play will not accept an update signed with a different key, and there is no recovery path. See Release Signing.

Other build facts worth knowing

  • AGP 9 has Kotlin built in. The org.jetbrains.kotlin.android plugin must not be applied — doing so breaks the build.
  • Core library desugaring is on, so java.time works down to API 26.
  • MultiDex is enabled — with five ad/offerwall SDKs the method count is well past 64k.
  • R8 is on for releaseisMinifyEnabled and isShrinkResources are both true. That is what makes proguard-rules.pro do anything: it carries the keep rules for Retrofit, kotlinx.serialization and the offerwall SDKs, plus an -assumenosideeffects rule that strips android.util.Log from the shipped build. With minification off, none of that ran and the published APK carried readable class names and every debug log.
    The release build is verified to complete with R8 enabled. If you add an SDK that uses reflection, add its keep rules and re-run ./gradlew :app:assembleRelease before shipping.
  • Repositories are deliberately unfiltered in settings.gradle.kts. The ad SDKs pull transitive artifacts from unrelated group ids (Unity Ads → play-services-cronetorg.chromium.net:cronet-api), and a group regex filter turns each of those into an unresolved dependency.

Sanity checklist before the first Play upload

  1. applicationId is yours, not com.cashparty.rewards.app.
  2. releaseApiBaseUrl points at your panel and ends in a slash.
  3. google-services.json is your Firebase project.
  4. Release SHA-1 / SHA-256 are registered in Firebase (and in Play App Signing).
  5. admob_app_id and the Facebook ids are yours — the package ships placeholders, not working credentials (see Strings & SDK keys). The Google web client id is not in strings.xml at all; it is generated from your google-services.json.
  6. A real release keystore is configured — otherwise the build output is app-release-unsigned.apk and Play will reject it.
  7. versionCode is higher than anything you have uploaded before.
  8. Legal pages exist in the panel — Play requires a reachable privacy policy URL.
  9. The Play Data safety form matches what the app actually collects (advertising id, usage stats if you enable usage-verified offers).

🧭 Feature Walkthrough — the Five Tabs

The shell is five equal tabs with no centre action: Apps · Games · Surveys · Cashout · Profile. Tabs stay composed rather than being swapped, so scroll position and in-flight loads survive a tab switch.

App screenshots

Click any screenshot to enlarge it.

Adding your own screenshots. Drop a PNG into assets/images/screenshots/ using the filename shown in each dashed box and it appears automatically — no markup to edit. A box that is still dashed simply means that file is not there yet.

Capture against a panel seeded with php artisan db:seed --class=DemoSeeder, otherwise the feeds are empty and the wallet reads zero.

Above them sits the persistent header, fed by one call to GET /home:

  • Balance chip — the withdrawable available figure, formatted in the currency from config.economy.currency_code. Tapping it selects the Cashout tab rather than pushing a route, so there is no back stack to unwind.
  • Streak chip — day count, and a claim indicator when today's streak reward is available. Tapping selects Profile.
  • Notifications — unread count.
  • Settings.

The header refetches every time the shell resumes, not once per process: a balance moves on screens the header cannot see — an offer that converted, a withdrawal that was requested, a quest that was claimed.

Tab badges are derived from the same payload: a count badge on Profile when quests are claimable, and a plain dot on Apps when offers are in progress (what matters is that something is unfinished, not how many).

Back behaviour: on any secondary tab, back returns to Apps. On Apps, back asks for a second press within two seconds before leaving the app.

1. Apps Tab

The default tab. An admin-built section stack sits above an infinite, filterable offer list.

  • Sections come from GET /home?screen=earn — carousels, grids, lists, banners, partner rows and hero blocks, in whatever order the panel says. A section with no items is omitted server-side, never rendered empty.
  • The list is GET /offers?type=app, paginated, with sort chips for payout, new, popular, quick and rating, plus category and provider filters and search.
  • Payout display: when original_payout and boost_percent are present, the original is struck through and the boost badge renders. The app never computes a boost itself.

2. Games Tab

The same screen with type=game and screen=games. Apps, Games and Surveys are one implementation with three configurations — building them as three screens would have meant maintaining the same paging, sorting and section rendering three times over.

3. Surveys Tab

Backed by the dedicated GET /surveys endpoint rather than a type filter, with its own narrower set of sort chips — a chip the server would reject is never rendered. Survey partners are typically opened through their SDK or hosted wall rather than as individual offers.

4. Cashout Tab

Two panes: Cashout (the catalogue) and My Withdrawals.

  • Options arrive from GET /cashout/options grouped by brand, already filtered server-side to what this user can use — geo-blocked and out-of-stock rows are dropped, and a group that empties is dropped with them. The app should never show a card that errors on tap.
  • A card is either a fixed denomination or a range with a minimum. Bonus and fee percentages are null rather than zero when absent, so the +30% BONUS pill renders on presence, not on a magic value.
  • The payout field is labelled by the server (target_label: "Email address", "Wallet address", "Phone number"), so a new payout rail does not need an app release to be labelled correctly.
  • Requesting a withdrawal holds the money immediatelyavailable drops at once, cash_balance only when it is paid.
  • A rejected request returns every unmet rule at once in error.fields.reasons, so the user is not told about one blocker at a time.
  • Pending requests can be cancelled, which releases the hold.

5. Profile Tab

Identity, level progress and everything that is not an offer:

  • Balance summary and a link into the full transaction ledger (GET /wallet/transactions, filterable by type)
  • Quests and streak — both claimed, never auto-credited
  • Levels — progress and what the next level unlocks
  • Promo codes — redeemed in a sheet, rate-limited separately
  • Refer a friend — code, share sheet, friend list and referral earnings
  • My account, Language, Settings, Sign out

Settings itself carries dark mode, sound effects, push toggle, language, the legal/help links from config.links (a row is hidden when its URL is null), rate-this-app, sign-in for guests, and account deletion. Deleting an account soft-deletes it server-side — financial history is retained — and drops the user back to a fresh anonymous session rather than a sign-in wall.

Offer Detail & My Offers

Offer detail (GET /offers/{id}) adds two things the list does not carry: the reward ladder (steps — each with its own payout and completion criteria) and requirements (device, OS version, country, "new users only"). A troubleshooting endpoint (GET /offers/{id}/troubleshoot) backs the "I did this and wasn't paid" screen with self-help steps and the offer's current status.

My Offers (GET /me/offers) splits into in progress and completed, and is what the dot on the Apps tab refers to.

Partners lists every enabled offerwall (GET /providers); opening one calls POST /providers/{slug}/open and hands the result to the launcher registry.


💰 How Earning Works

Four ways money enters a wallet, and all four end in the same place — a ledger row.

SourceHow it is verifiedWho credits it
Offer / offerwall conversionPartner postback carrying the click idServer, on postback
Survey completionPartner postbackServer, on postback
Usage-verified offerOn-device play time, posted by the app, judged by the serverServer, on report
Rewarded videoAd network reward callback + daily capServer
Quest / streak / level / referral / promo codeServer-side rulesServer, on claim
The click id is the spine. Opening an offer calls POST /offers/{id}/click, which returns {click_id, url}. The app opens that URL — in a Chrome Custom Tab, exactly as given. The click id is embedded in it as the partner's sub-id, and their postback returns it. Hand the URL to a Play Store intent and the query string is dropped, the postback arrives with no sub-id, and the user is never paid.
SDKs never touch a wallet. Every offerwall SDK in the app is a viewer: it opens a wall and reports nothing financial. If you are integrating a new partner and their SDK offers a client-side "reward" callback, do not wire it to a balance — wire their server postback.

Money rules that matter

  • Spend against available, not cash_balance. pending is money that has arrived but is not yet withdrawable; a requested withdrawal is already held out of available.
  • Balances are decimal(14,6) handled by bcmath. Never floats — 0.1 + 0.2 !== 0.3, and a cent lost per conversion is real money.
  • Nothing writes a balance except WalletService. It locks the wallet row and writes the matching wallet_transactions entry in the same database transaction.
  • Ledger and audit rows are immutable. A mistake is corrected with a compensating entry; the models throw on update and delete.
  • wallet:verify runs hourly and exits non-zero if any balance disagrees with its ledger. Wire it to alerting — it is the canary for a balance written outside WalletService.

Usage-Verified Offers

For offers you run yourself (no partner postback to rely on), CashLoop can verify that the user actually used the installed app. This is the only feature that needs a sensitive permission.

  1. An offer is marked verification_mode = usage in the panel with a target package name and a play-time threshold.
  2. When the user opens it, the launcher registry starts watching before anything opens — the click time is the lower bound of the usage query, and a bound set afterwards would miss the session the user just played.
  3. The app asks for Usage access (PACKAGE_USAGE_STATS), which is a Settings screen the user must visit; it is requested in context, when the first such offer is opened, not at launch.
  4. On the next app resume, UsageReporter queries UsageStatsManager for every watched package and posts the result to POST /me/offers/{id}/usage.
  5. The server decides whether it qualifies and credits accordingly. The app measures, posts and acts only on the verdict — it never decides that something was earned.
  6. Anything credited that the user has not seen yet is shown as a celebration on the next resume; multiple offers are summed into one dialog rather than stacking.
Why on resume and not on a timer. UsageStatsManager is a historical store: one query covers the whole window since the click. A user who installs a game, plays it for a week and only then reopens CashLoop is credited in full on that single query. A periodic background job would change nothing about who gets paid — only how early the notification arrives — at the cost of a WorkManager dependency and a wakeup budget.
Play policy. PACKAGE_USAGE_STATS is a sensitive permission. Declare it honestly in the Data safety form, explain it in your listing, and disable usage-verified offers entirely if you do not intend to run first-party offers — the permission is only ever exercised for offers whose verification_mode is usage.

🗺️ System Flowcharts

User action Android app Laravel / MySQL Partner / third party Result

1. Master system architecture

┌──────────────────────────────────────────────────────────────┐
│                    CashLoop Android (Compose)                │
│   Hilt · Retrofit · kotlinx.serialization · DataStore Proto  │
└───────┬───────────────┬────────────────────┬─────────────────┘
        │ REST          │ SDKs               │ Firebase
        ▼               ▼                    ▼
┌────────────────┐ ┌──────────────────┐ ┌──────────────────────┐
│  Laravel 13    │ │ Offerwall SDKs   │ │ Auth (identity)      │
│  /api/v1       │ │ BitLabs · ayeT   │ │ Cloud Messaging      │
│                │ │ MyChips · DT     │ │ Remote Config        │
│  MySQL 8 —     │ └────────┬─────────┘ └──────────────────────┘
│  source of     │          │ user completes an offer
│  truth for     │          ▼
│  EVERYTHING    │ ┌──────────────────┐
│  financial     │◀│ Partner postback │  server-to-server only
└───────┬────────┘ └──────────────────┘
        │
        ▼
  wallet_transactions  →  quests · levels · referrals  →  cashout

There is no second datastore and no realtime database. MySQL is the source of truth for every balance, conversion and withdrawal; Firebase supplies identity, push and one config key.

2. Boot & authentication

App launches → SplashRemote Config fetched (api_base_url); failure is not fatal, the build default is used
GET /api/v1/configUnauthenticated on purpose — the maintenance and force-update verdict must be reachable before sign-in
Gate checkmaintenance_mode → Maintenance screen · must_update → Force Update screen. Both terminal: no back, no "continue anyway"
Firebase anonymous sign-inFirst launch only; the session persists across restarts
POST /auth/firebase {id_token, device_id?, referral_code?}Returns the JWT pair + user. Applies the signup bonus and referral attribution — both idempotent
Onboarding (first run) → Home shellGET /home fills the header and the first tab in one round trip
Later: sign in with Google / Facebook / emailUpgrades the anonymous Firebase user in place → same firebase_uid → same server account. Balance and streak carry over with no merge step
Nothing is lost when Firebase refuses to link. If the credential already belongs to another account, the anonymous user is dropped and the credential signs in to its own account — a different firebase_uid, so a different server-side account. The app then claims the guest's balance through POST /auth/relink, which moves cash, coins and progression across and retires the guest.

The app sends the guest's own refresh token as proof it held that session. A bare user id would let any signed-in caller name someone else's account and sweep its balance, so the server refuses a merge it cannot tie to a session the caller actually owned. The token is consumed either way, so a replay cannot pay twice. See POST /auth/relink.

3. Click → postback → wallet

User taps an offer
POST /offers/{id}/clickServer mints a uuid click id, records the click, returns {click_id, url}
Launcher registry resolves the providerClaiming SDK first; unavailable/failed/unclaimed → the tracked URL in a Custom Tab. Something always opens
Partner tracks the install / completionThe click id travels as their sub-id. A raw user id is never sent to a third party
Partner postback → GET/POST /api/postback/{slug}Adapter validates signature and IP allowlist. No secret and no allowlist ⇒ fails closed, rejects everything
Conversion recorded → WalletServiceRow locked, balance updated, wallet_transactions entry written in one transaction. Every hit logged with its result and reason
Quests, levels and referral share cascadeThe referrer's percentage is credited from the same event. The app sees it on its next header refresh

4. Cashout flow

GET /cashout/optionsAlready filtered to what this user can use — geo, stock, KYC
Pick a denomination, enter the payout target
POST /cashout/requestsEvery unmet rule returns at once in error.fields.reasons. On success the amount is HELD: available drops immediately
Admin queue: /admin/cashout-requestsapprove → processing → paid, or reject. Bulk actions and CSV export available. Fraud flags surface here
Paidcash_balance drops at this point; the ledger records the payout. Rejection or user cancellation releases the hold

5. Data ownership (what lives where)

DataHomeWhy
Users, offers, providers, wallets, conversions, cashouts, quests, levelsMySQL (Laravel)Durable, reportable, auditable, admin-editable
Identity (uid, providers linked)Firebase AuthAnonymous → real upgrade in place is what preserves guest balances
API originFirebase Remote ConfigThe one thing that must be changeable before the app can reach the API at all
Everything else configurableGET /configOne source, versioned with the backend, no console drift
Partner credentials & postback secretsprovider_settings (encrypted)Never in the binary; rotating a key is a panel edit
Theme, sound, language, onboarding-seenOn-device DataStoreDevice preferences, not account state
Pending usage-verified offersOn-device DataStoreWatch list only — the server still judges every report

🎨 Branding and Customization

Rebranding the app from scratch

1Nameapp_name in res/values/strings.xml (and each values-xx/ you want localised).
2Package idapplicationId and namespace in app/build.gradle.kts. Then recreate the Firebase Android app, replace google-services.json, regenerate the Google OAuth client, update the Facebook Android package, and use the new id in Play Console.
3Icon — Android Studio → right-click res → New → Image Asset. Regenerates every density plus the adaptive layers.
4ColoursArcadePresets.SELECTED, or add a tenth preset.
5Backend originreleaseApiBaseUrl in app/build.gradle.kts, plus the Remote Config api_base_url if you want to steer already-installed builds.
6Panel brandingAPP_NAME in the Laravel .env, logo and colours under /admin/settings/general, and the legal/help pages under /admin/content/pages.
7Economy — currency, signup bonus, minimum withdrawal and referral rates at /admin/settings/app. The app reads all of them; nothing is hardcoded.

Home sections (admin-driven)

Every feed screen is a stack of sections defined at /admin/app-sections. A section is a row in app_sections and looks like this on the wire:

{
  "key": "best_for_you",
  "title": "Best for You",
  "subtitle": null,
  "icon": null,
  "layout": "carousel",
  "data_source": "featured_offers",
  "show_view_all": true,
  "view_all_filters": {"sort": "payout"},
  "items": [ /* offers, providers, quests or banners */ ]
}
FieldAccepted values
screenhome, earn (Apps), games, surveys, more
layoutcarousel, grid, list, banner, partners, hero
data_sourcefeatured_offers, offers_by_type, surveys, providers, quests, banners, custom_ids
view_all_filtersAny query the /offers endpoint accepts — passed straight back when the user taps "View all"
Two safety behaviours worth knowing. A section with no items is omitted server-side, so the app never draws a titled shelf with nothing under it. And a data_source added to the server after a build shipped renders nothing rather than guessing — drawing the wrong card with real money on it is worse than drawing no card. Likewise a single malformed item is dropped, not fatal: one bad offer must not blank a whole shelf.

Android customization cheat sheet

I want to change…WhereNeeds a release?
Colours / themeArcadePresets.SELECTEDYes
App name / iconstrings.xml · mipmap-*Yes
Which tabs existPanel → config.featuresNo
Feed sections, order, titles, bannersPanel → App sectionsNo
Offers, categories, payouts, boostsPanel → Offers / EngagementNo
Cashout cataloguePanel → CashoutsNo
Ad networks and unit idsPanel → App settingsNo
Legal / help pagesPanel → Content pagesNo
Backend originRemote Config api_base_urlNo
Partner credentialsPanel → ProvidersNo
A new language's UI stringsres/values-xx/strings.xmlYes

🟢 Getting Started

What you are setting up, and why

CashLoop is two deployables plus a Firebase project. The app cannot do anything on its own: it holds no content, no economy and no balances. Getting it running means standing up the backend first, giving the app a way to reach it, and giving both sides the same Firebase identity.

The 6 stages at a glance

#StageYou end up with
1Android project opens and buildsA debug APK on an emulator
2Firebase projectgoogle-services.json + a service account JSON
3Remote ConfigOne published key: api_base_url
4Laravel panel running locallyAdmin login + a reachable /api/v1/config
5Laravel deployedAn HTTPS origin, a queue worker and one cron entry
6Partners configuredPostback URLs live, one partner in testing
In plain English: Backend first, then Firebase, then point the app at the backend. Do not try to debug an empty app before /api/v1/config returns JSON in your browser.

🚀 Run the App From Scratch — Complete Setup Guide

📋 Prerequisites

ForYou need
AndroidAndroid Studio (Ladybug+), JDK 17+, Android SDK 36, an emulator or device on API 26+
BackendPHP 8.3+, Composer 2, Node 20+, MySQL 8 or MariaDB 10.3+
PHP extensionsbcmath (money arithmetic — not optional), ctype, curl, dom, fileinfo, gd, mbstring, openssl, pdo_mysql, tokenizer, xml, zip
AccountsFirebase, Google Play Console, AdMob (optional), OneSignal (optional), and a publisher account with each offerwall partner you intend to run
Check bcmath before anything else. Without it every money operation fails. On MAMP/XAMPP it is usually present; on a fresh Ubuntu box it is php8.3-bcmath, installed separately.
php -v            # 8.3+
php -m | grep -E 'bcmath|pdo_mysql|gd|mbstring'
composer -V
node -v           # 20+
mysql --version   # 8+
java -version     # 17+

🔧 Step 1 — Android Project Setup

1Open the project. Android Studio → Open → select the cashloop-android/ folder (not a subfolder). Let Gradle sync finish; the first sync downloads the toolchain and every SDK.
2Confirm the application id. app/build.gradle.ktsapplicationId / namespace. Change it now if you are rebranding — changing it later means redoing Firebase, Google OAuth and Facebook.
3Point the app at your backend. Emulator + local panel needs no change (http://10.0.2.2:8000/). Physical device: ./gradlew :app:installDebug -Pcashloop.apiBaseUrl=http://192.168.1.x:8000/ or a tunnel URL. Remember the trailing slash.
4Build. ./gradlew :app:installDebug. At this point the app should launch and show the splash; empty feeds are expected until the backend is up.

🔥 Step 2 — Firebase Project Setup

1Create a Firebase project at console.firebase.google.com. Google Analytics is optional.
2Register the Android app with your exact applicationId. Add debug and release SHA-1 and SHA-256 fingerprints:
# Debug (bundled keystore)
keytool -list -v -keystore debug.keystore -alias androiddebugkey \
        -storepass android -keypass android

# Your release keystore
keytool -list -v -keystore /path/to/release.jks -alias <your-alias>
Download google-services.jsonapp/google-services.json.
3Enable the services:
  • Authentication → Sign-in method → enable Anonymous (required), Email/Password, Google, Facebook
  • Cloud Messaging
  • Remote Config
4Create a service account for the backend: Project settings → Service accounts → Generate new private key. Save the JSON to storage/app/firebase/service-account.json in the Laravel project and reference it from FIREBASE_CREDENTIALS. This file is a secret — never commit it.
Google Sign-In needs the Web client id, not the Android one. Firebase creates an OAuth "Web client (auto created by Google Service)" in Google Cloud Console. You do not copy it anywhere: it is inside the google-services.json you downloaded, and the Gradle plugin turns it into default_web_client_id at build time.

If Google Sign-In returns DEVELOPER_ERROR, the usual causes are a google-services.json from a different project, or missing SHA fingerprints — not a string to edit.

⚙️ Step 3 — Firebase Remote Config

CashLoop uses exactly one Remote Config parameter. Everything else that used to live in a console is served by GET /api/v1/config, so there is only one place a value can be wrong.

ParameterTypeValueRequired?
api_base_urlStringYour API origin, with a trailing slash — e.g. https://panel.example.com/Optional — blank means "use the build's baked-in origin"
  1. Firebase Console → Remote Config → Create configuration.
  2. Parameter key api_base_url, value = your origin with the trailing slash.
  3. Publish changes. An unpublished parameter does not exist as far as the app is concerned.
In-app defaults are empty, never product fallbacks. A plausible-looking hardcoded default hides an unset console key behind behaviour that half works. Here a blank value means one specific thing — fall back to BuildConfig.API_BASE_URL — and nothing else.
Debug builds use a zero minimum fetch interval so a key you just published arrives immediately. Release builds keep the one-hour throttle — a value changed in the console can take up to an hour to reach a running release build.

🗄️ Step 4 — Laravel Admin Panel Setup (Local)

1Install dependencies.
cd cashloop-admin-panel
composer install
npm install
2Create the database. An empty one — there is nothing to import.
mysql -u root -p -e "CREATE DATABASE cashloopdb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
3Configure .env.
cp .env.example .env
php artisan key:generate
php artisan jwt:secret
Then set at least:
APP_NAME="CashLoop"
APP_URL=http://127.0.0.1:8000

DB_DATABASE=cashloopdb
DB_USERNAME=root
DB_PASSWORD=root

# Seeded admin — set a real password BEFORE seeding.
ADMIN_EMAIL=you@your-domain
ADMIN_PASSWORD=<a real password>
ADMIN_NAME="CashLoop Admin"

# Firebase service account JSON, relative to the project root
FIREBASE_CREDENTIALS=storage/app/firebase/service-account.json
FIREBASE_PROJECT=app

SESSION_DRIVER=database
QUEUE_CONNECTION=sync         # database or redis under load
4Build the schema and seed.
php artisan migrate:fresh --seed   # schema + core data
php artisan storage:link           # offer icons, logos, banners

# Optional: demo users, offers, conversions, promo codes
php artisan db:seed --class=DemoSeeder
5Serve it.
npm run dev          # Vite, in one terminal
php artisan serve    # http://127.0.0.1:8000, in another
Sign in at /en/web-login with the credentials from .env.
6Verify the API is reachable.
curl -s http://127.0.0.1:8000/api/v1/config | head -c 400
You should get {"success":true,"data":{"config":{.... If you get HTML, the route is not registered or the app is erroring — check storage/logs/laravel.log.
php artisan config:cache is safe on this project. Every configuration read goes through a config/*.php file, and ConfigurationSafetyTest fails the build if a raw env() call appears outside one. (If you have used the older quiz-app panel, this is the opposite of what that codebase required.)

📊 Step 5 — Database (Migrations & Seeders)

There is no SQL dump, and none should ever be imported. The 17 migrations are the schema. This is deliberate: the previous generation of this product could not be recreated from its own code, and that is the single problem this rebuild set out to fix. Every schema change is a migration. No manual SQL, ever.

What the migrations create

Migration groupCovers
Reference, roles, users, auth tokensCountries, currencies, locales, staff roles, users, JWT refresh tokens
Queue, cache, settings, content, supportFramework tables, app settings, CMS pages, email templates, tickets
Providersproviders, provider_settings (encrypted), placements, geo rules
OffersOffers, categories, reward steps, requirements, clicks
Walletwallets, wallet_transactions — the ledger
ConversionsConversions and postback_logs
CashoutCashout catalogue, categories, requests
EngagementQuests, levels, streak rewards, promo codes, boosts, referrals
App layoutapp_sections, banners
IntegrityFraud rules, user flags, audit logs
Usage verificationAdds verification_mode, target package and threshold to offers

Seeders

SeederWhat it puts inProduction?
CoreSeederCountries, currencies, locales, roles, the admin user, settings, offer categories, providers, cashout catalogue, engagement defaults, fraud rules, pages, email templates, app sectionsYes — this is the production seeder
DemoSeederDemo users, offers, conversions and promo codes for screenshots and manual testingNever. It refuses to run in production — do not rely on that
CpaLeadOfferSeeder, ToroxOfferSeederSample offers shaped like each partner's real feedDevelopment only
# Fresh local rebuild
composer refresh          # migrate:fresh --seed
composer refresh-demo     # …plus demo fixtures

# Production
php artisan migrate --force
php artisan db:seed --class=CoreSeeder --force

Verify

php artisan tinker --execute="echo App\Models\Provider::count().' providers, '
  .App\Models\Offer::count().' offers, '
  .App\Models\User::count().' users';"

php artisan wallet:verify     # must exit 0

🔐 Step 6 — Firebase Auth Bridge

Firebase owns identity; the API owns everything else. The bridge is one endpoint.

1Enable the providers in Firebase Auth: Anonymous (required), Email/Password, Google, Facebook.
2Give Laravel the service account. FIREBASE_CREDENTIALS=storage/app/firebase/service-account.json. This is what lets the server verify an incoming Firebase ID token.
3Facebook — create a Meta app, add the Android platform with your package name and key hashes, copy the app id and client token into strings.xml, and paste the Facebook OAuth redirect Firebase gives you into the Meta app's Facebook Login settings.
4Google — nothing to copy by hand: the Web client id travels inside google-services.json and the Gradle plugin generates default_web_client_id from it. What you must do is register the SHA-1 and SHA-256 fingerprints in Firebase for both debug and release, then re-download google-services.json.
5Test the exchange:
curl -X POST http://127.0.0.1:8000/api/v1/auth/firebase \
  -H "Content-Type: application/json" \
  -H "X-Platform: android" -H "X-App-Version: 2.0" \
  -d '{"id_token":"<a real Firebase ID token>"}'
A valid token returns the JWT pair plus the user. An invalid one returns E_UNAUTHENTICATED — which is the correct answer, and proves the bridge is wired.
Guest upgrade, not account merge. Signing in links the credential to the existing anonymous Firebase user, so firebase_uid — and therefore the server-side account, balance and streak — is unchanged. There is no merge step because there is nothing to merge.
If the credential already belongs to a different account, Firebase refuses the link and the guest session is dropped — but its balance is not. The app immediately calls POST /auth/relink with the guest's refresh token, and the server moves the cash, coins and progression onto the account just signed in to. See POST /auth/relink.

🚀 Step 7 — Deploy Laravel to Production

git clone <repo-url> /var/www/cashloop && cd /var/www/cashloop

composer install --no-dev --optimize-autoloader
npm ci && npm run build

cp .env.example .env
php artisan key:generate
php artisan jwt:secret

php artisan migrate --force
php artisan db:seed --class=CoreSeeder --force
php artisan storage:link

php artisan config:cache && php artisan route:cache \
  && php artisan view:cache && php artisan event:cache

Production .env checklist

APP_ENV=production
APP_DEBUG=false                 # non-negotiable
APP_URL=https://your-domain

ADMIN_EMAIL=you@your-domain
ADMIN_PASSWORD=<12+ chars, not a common default>   # see the note below

FIREBASE_CREDENTIALS=storage/app/firebase/service-account.json
QUEUE_CONNECTION=database
SESSION_DRIVER=database
ONESIGNAL_APP_ID=…
ONESIGNAL_REST_API_KEY=…
ADMIN_PASSWORD is not optional and has no default. AdminUserSeeder skips creating the admin — with a warning — if it is blank, is one of the common defaults (password, admin, changeme, …), or is shorter than 12 characters. That applies in every environment, not only production: a local seed gets deployed, and this is the account that approves withdrawals.

If php artisan migrate --seed finishes but you cannot sign in, this is why — read the seeder's output.
Build the front-end assets. The panel renders through Vite, so a checkout with no public/build/ throws "Unable to locate file in Vite manifest" and every page returns 500 — not a missing stylesheet.
npm install
npm run build
If your package already contains public/build/ you can skip this, but re-run it after changing anything under resources/css/ or resources/js/.

Permissions

chown -R www-data:www-data storage bootstrap/cache
chmod -R 775 storage bootstrap/cache

Queues

Conversion crediting, referral payouts and push notifications run through event listeners. With QUEUE_CONNECTION=sync they run inline — fine at low volume and simplest to debug — but move to database or redis under load.

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

Scheduler — one cron entry, and it is not optional

* * * * * cd /var/www/cashloop && php artisan schedule:run >> /dev/null 2>&1
CommandWhenWhy it matters
wallet:verifyhourlyRe-sums every ledger. Exits non-zero on drift — wire it to alerting
offers:expirehourlyStops the feed advertising offers that can no longer pay out
logs:prune03:30 dailyTrims postback logs and unconverted clicks. Conversions, ledger, cashouts and audit logs are never pruned
auth:clear-resets, queue:prune-batchesdailyFramework housekeeping

Shared hosting (cPanel / Hostinger / Namecheap)

  1. Upload the project outside public_html; point the domain's document root at the project's public/ directory.
  2. Set the PHP version to 8.3 and enable bcmath, pdo_mysql, gd, zip, fileinfo.
  3. Create the database and user in cPanel, then run the artisan commands over SSH (or the host's terminal). Hosts with no SSH are a poor fit — you cannot run migrations or a queue worker.
  4. Add the scheduler cron entry through the cPanel Cron Jobs UI.
  5. Use QUEUE_CONNECTION=sync if you have no way to keep a worker alive.

Cloud / VPS

A Linux host with PHP 8.3, nginx, MySQL 8 and Supervisor is the intended target: nginx serving public/, PHP-FPM, Supervisor running the queue worker, and system cron running the scheduler. Managed platforms (Forge, Ploi, Vapor) work with no changes.

Routine deploys

php artisan down --render="maintenance"
git pull
composer install --no-dev --optimize-autoloader
npm ci && npm run build
php artisan migrate --force
php artisan config:cache && php artisan route:cache && php artisan view:cache && php artisan event:cache
php artisan queue:restart
php artisan up
php artisan queue:restart is not optional — workers hold the old code in memory until told to restart. And after any .env change: php artisan config:clear && php artisan config:cache.

Backups, in order of "cannot be reconstructed"

  1. wallet_transactions — the ledger. Balances are derived from it; lose it and you cannot prove what anyone is owed.
  2. conversions, cashout_requests — what was earned and what was paid.
  3. audit_logs, user_flags — who decided what.
  4. storage/app/public — uploaded icons, logos, banners.
  5. Everything else is recoverable from migrations, seeders and partner feeds.
Rolling back a migration that dropped a money table destroys the ledger. Prefer rolling forward with a corrective migration. If you must roll back application code, do it without rolling back the database — the schema is additive and older code tolerates extra columns.

🔁 Step 8 — Partner Postbacks

Each partner gets one URL:

https://your-domain/api/postback/{slug}

Copy it from the partner's edit screen in the panel, which also shows the query template to paste into the partner's dashboard. The slug selects the adapter, and the adapter owns the IP allowlist and signature check. There is no session or token auth on this route — partners cannot carry one — so rate limits are keyed per provider, and one misconfigured partner cannot exhaust everyone else's budget.

Before switching a partner to active

1Fill in its credentials at /admin/providers/{id}/edit. Secrets are encrypted at rest.
2Set postback_ip_allowlist. With neither a secret nor an allowlist the adapter fails closed and rejects everything — deliberately.
3Set the partner to testing first. Postbacks are accepted and logged, but its inventory stays hidden from the app. Fire a test conversion from the partner's dashboard.
4Check Money → Postback logs. Every hit is recorded with its result and reason, payload redacted. When a partner says "we sent it and nothing happened", that log is the answer.
5Only then set it to active. Its offers appear in the app on the next feed load.
Every adapter fails closed, not just the generic one. A provider row with no secret and no IP allowlist rejects every postback with no_secret_and_no_ip_allowlist. That is the whole point: an open postback URL is an open cash register for anyone who learns it. If a partner is not crediting, check the logs for that reason before anything else.

BitLabs and Torox are stricter still — both sign every callback, so an IP allowlist alone is not accepted for them.

Seeding credentials from .env (optional)

The panel is the authority for provider settings, but ProviderSeeder will write an initial value on first run from these, so a fresh deploy can come up already configured. Once a setting exists, re-seeding leaves it alone — changing it here later does nothing.

# CPAlead — its postback carries a `password` parameter rather than a signature.
# Set this OR CPALEAD_POSTBACK_IPS, or CPAlead postbacks are rejected.
CPALEAD_WALL_SLUG=
CPALEAD_PUBLISHER_ID=
CPALEAD_POSTBACK_PASSWORD=
CPALEAD_POSTBACK_IPS=
CPALEAD_COUNTRIES=TR,US,DE,GB

# BitLabs — every callback is HMAC-signed, so the secret is mandatory.
BITLABS_APP_TOKEN=
BITLABS_APP_SECRET=

# Torox — every postback is signed; the placement key is mandatory.
TOROX_APP_ID=
TOROX_PUB_ID=
TOROX_TOKEN=
TOROX_POSTBACK_IPS=44.212.211.226

Leave them blank and the seeder writes nothing, which is the correct behaviour for a production seed — a partner with no credentials simply never credits anyone.

Adding a partner is data, not code

Credentials live in provider_settings, dialect lives in an adapter class, and a partner with neither falls back to the config-driven GenericOfferwallAdapter. Dedicated adapters ship for BitLabs, CPAlead and Torox. Write a new adapter only when a partner's postback shape cannot be described in settings.

Never wire a client-side reward callback to a balance. If a partner SDK offers one, treat it as a UI hint at most. Money moves on the server postback, and only there.

🔗 Step 9 — Third-Party Services (Quick Setup)

ServiceRequired?What you set, and where
FirebaseRequiredgoogle-services.json in the app; service account JSON on the server
Offerwall partnersRequired to earn anythingCredentials in /admin/providers; postback URL in the partner dashboard
Google AdMobOptionaladmob_app_id in strings.xml; unit ids in /admin/settings/app
Unity AdsOptionalGame project id and placements in /admin/settings/app
OneSignalOptionalApp id served via config.push; REST key in Laravel .env
Google Sign-InRecommendedGenerated from google-services.json; you add SHA fingerprints
Facebook LoginOptionalApp id, client token, key hashes
Cloudflare TurnstileOptionalTURNSTILE_SITE_KEY / TURNSTILE_SECRET_KEY — protects the web forms, not the mobile API
SMTPRecommended/admin/settings/smtp — password resets and ticket replies
Object storageOptionalS3/Wasabi/Storj/B2/Spaces credentials in .env; local public disk is the default

Details for each in Integrations.


▶️ Step 10 — Run the App

# 1. Backend
cd cashloop-admin-panel
php artisan serve            # http://127.0.0.1:8000
npm run dev                  # separate terminal

# 2. App — emulator, default origin (10.0.2.2 → your host)
cd cashloop-android
./gradlew :app:installDebug

# 2b. App — physical device
./gradlew :app:installDebug -Pcashloop.apiBaseUrl=http://192.168.1.42:8000/
A physical device cannot reach 127.0.0.1 or 10.0.2.2. Use your machine's LAN IP or a tunnel, and remember cleartext HTTP is disabled outside the emulator default — a tunnel (HTTPS) is the path of least resistance.

✅ Verify Setup

Work down this list. Each line depends on the ones above it, so stop at the first failure.

#CheckExpected
1curl .../api/v1/config{"success":true,…}
2Admin login at /en/web-loginDashboard loads
3php artisan wallet:verifyExit code 0
4App launches past the splashNo gate screen, no infinite spinner
5Apps tab shows offersRequires at least one active provider with active offers
6Header shows a balanceSignup bonus applied if configured
7Tapping an offer opens a Custom TabURL contains a click id
8Panel → Offers → ClicksThe click you just made is recorded
9Fire a test postbackConversion appears; wallet and ledger both move
10Cashout tabCatalogue renders; a below-minimum request lists every unmet rule
11Quests / streakClaim credits the wallet and clears the badge
12Flip a feature flag off in the panel, relaunchThat tab is gone, not greyed out
Step 9 is the one that matters. A click that becomes a conversion that becomes a ledger row is the whole product. Everything else is presentation.

📡 Remote Config Reference

KeyTypeDefault in appMeaning
api_base_urlString""Origin of the CashLoop API, with a trailing slash. Blank means "use BuildConfig.API_BASE_URL" — never "no API".

That is the entire list, on purpose. Remote Config carries only what has to be settable before the app can reach the API at all — which is the API's own origin. Product configuration comes from GET /api/v1/config, where it is versioned alongside the backend that produces it.

SettingValueWhy
Fetch timeout10 sFailure is not fatal — the build default is used
Minimum fetch interval (debug)0 sA key you just published reaches the app immediately
Minimum fetch interval (release)3600 sThese change per release, not per minute; a shorter window only burns quota

🔌 Mobile API v1 Reference

Base URL: https://<host>/api/v1

This contract is frozen once the app ships against it. Additive fields are fine; breaking changes go to /api/v2 rather than mutating these shapes. This section is the authoritative copy of the contract.

Envelope

Every response — success or failure — has the same shape:

{
  "success": true,
  "data": { },
  "meta": { "page": 1, "per_page": 20, "total": 124, "last_page": 7, "has_more": true },
  "error": null
}
{
  "success": false,
  "data": null,
  "meta": null,
  "error": { "code": "E_CASHOUT_NOT_ALLOWED", "message": "…", "fields": { "reasons": ["…"] } }
}

error.code is stable and is what the client switches on. error.message is English and intended for logs — the app shows its own localised copy per code.

Error codes

CodeMeaning
E_INVALID_INPUTValidation failed; fields holds the per-field messages
E_UNAUTHENTICATEDMissing/expired token — refresh, then re-auth
E_FORBIDDENAuthenticated but not allowed
E_NOT_FOUNDNo such resource
E_OFFER_UNAVAILABLEOffer is paused, expired, capped or wrong platform
E_GEO_BLOCKEDNot available in the user's country
E_INSUFFICIENT_BALANCENot enough available balance
E_CASHOUT_NOT_ALLOWEDOne or more withdrawal rules failed; see fields.reasons
E_PROMO_CODE_INVALIDUnknown, expired, or already-used code
E_ALREADY_CLAIMEDStreak or quest reward already taken
E_ACCOUNT_BLOCKEDAccount suspended; message carries the reason
E_MAINTENANCEServer maintenance
E_RATE_LIMITEDToo many requests
E_SERVER_ERRORUnexpected failure

Headers

HeaderRequiredPurpose
Authorization: Bearer <jwt>On protected routesThe token from /auth/firebase
X-PlatformRecommendedandroid | ios | web — drives targeting
X-App-VersionRecommendedForce-update gate and section version gating
X-Device-IdRecommendedAdvertising id; used for tracking macros and fraud signals
X-LocaleOptionalStored on the user
X-Device-Model, X-OS-VersionOptionalAttached to support tickets

The server keeps the user's device record current from these headers, so there is no separate "update device" call. On Android they are added by api/interceptors/ContextHeaderInterceptor.kt.

Public endpoints

MethodPathNotes
GET/configEconomy, feature flags, ad units, force-update gate. Fetch before sign-in
GET/locales
GET/countries
GET/pages/{slug}Legal/help content (privacy-policy, terms-of-service, …)
POST/auth/firebase{id_token, device_id?, referral_code?} → tokens + user. Applies the signup bonus and referral attribution, both idempotent
POST/auth/refresh{refresh_token} → new tokens

Account

MethodPathNotes
GET/auth/me
PATCH/auth/profile
POST/auth/deviceRegister a push token
/auth/relink{guest_refresh_token}{merged, user}. Absorbs an abandoned guest account — see below
DELETE/auth/accountSoft-deletes; financial history is retained

POST /auth/relink — claiming an abandoned guest

Sent automatically by the app, once, after a sign-in where Firebase refused to link the anonymous user because the credential already had an account. That is the only path where the server-side account changes, and without this endpoint whatever the guest earned would be stranded.

POST /api/v1/auth/relink
Authorization: Bearer <jwt of the account just signed in to>

{ "guest_refresh_token": "<the abandoned guest's refresh token>" }

→ 200 { "merged": true,  "user": { … } }   cash, coins and progression moved over
→ 200 { "merged": false, "user": { … } }   token unknown or expired — nothing to do
→ 403 E_FORBIDDEN                          refused: see below

The guest's refresh token is the authorisation. A bare user id would let any signed-in caller name someone else's account and sweep its balance into their own, so the server only merges a session the caller can prove it held. The token is consumed either way, so a replay cannot pay twice.

SituationResponseWhy
Valid guest tokenmerged: trueBalance moves as a ledger pair that nets to zero — the merged cash still replays from wallet_transactions
Unknown or expired tokenmerged: falseNot an error: the app fires this once and cannot retry, and a lapsed guest session is the ordinary case
Token belongs to a full account403Otherwise signing in on a shared device would absorb the previous owner's balance
Guest has a withdrawal in review403Deleting it would leave an operator about to pay a user who no longer exists — settle it first
Progression carries over as well: the better of the two streaks wins and XP is summed. The guest is soft-deleted and all of its sessions revoked, including the one that authorised the merge.

Home

MethodPathNotes
GET/home?screen=homeHeader state (wallet, streak, level, unread count) plus the ordered sections. One round trip for the whole screen. screen also accepts earn, games, surveys, more

Offers

MethodPathNotes
GET/offerstype, category, provider, featured, search, sort=payout|new|popular|quick|rating, per_page
GET/offers/{id}Adds the reward ladder (steps) and requirements
POST/offers/{id}/click{click_id, url}. Open url, not the store directly
GET/offers/{id}/troubleshootSelf-help steps and current status
GET/surveysSurvey feed with its own sort tabs
GET/me/offersstatus=in_progress|completed
POST/me/offers/{id}/usageReport on-device usage for a usage-verified offer

Payout fields: payout is the boosted amount to display. original_payout and boost_percent are present only while a boost is running — the app renders the struck-through price when they are non-null and does not have to decide itself.

Partners

MethodPathNotes
GET/providers
GET/providers/{slug}/sdk-configPublisher-safe SDK keys as an open settings map
POST/providers/{slug}/open{placement?}{click_id, url}

Wallet & cashout

MethodPathNotes
GET/walletcash_balance, pending, available — spend against available
GET/wallet/transactionsFilterable by type
GET/cashout/optionsGrouped by brand, already filtered to what this user can use
POST/cashout/requests{cashout_id, amount, payout_target, payout_meta?}. Failure returns every unmet rule in error.fields.reasons
GET/cashout/requests"My Withdrawals"
DELETE/cashout/requests/{id}Cancel while pending

Requesting a withdrawal holds the money immediately: available drops right away, cash_balance only when it is paid.

Engagement, referral, notifications, support

MethodPath
GET / POST/quests · /quests/{id}/claim
GET / POST/streak · /streak/claim
GET/levels
POST/promo-codes/redeem{code}
GET/referral · /referral/friends
GET / POST/notifications · /notifications/{id}/read · /notifications/read-all
GET / POST/support/tickets

Quest and streak rewards are claimed, not auto-credited.

Never returned

revenue_cash on offers, provider credentials or revenue share, another user's email address, admin notes, or the risk flags on a withdrawal. If any of these appear in a response, that is a bug — report it rather than building against it.

Partner postback (not part of v1)

MethodPathAuth
GET / POST/api/postback/{provider}Adapter-owned: signature check + IP allowlist. No session, no token. Fails closed when neither is configured

🗂️ Project Structure (Developers)

Android app — cashloop-android/

See Android Project Structure for the annotated tree.

Laravel admin panel — cashloop-admin-panel/

cashloop-admin-panel/
├── app/
│   ├── Domain/                 # The business logic, by area
│   │   ├── Wallet/             # WalletService — the ONLY thing that writes a balance
│   │   ├── Conversions/        # Postback → conversion → credit
│   │   ├── Providers/Adapters/ # BitLabs, CPAlead, Torox, Generic, AbstractProviderAdapter
│   │   ├── Offers/ Cashout/ Engagement/ Referrals/ Fraud/ Geo/ Layout/ Usage/
│   ├── Http/
│   │   ├── Controllers/Api/V1/ # The frozen mobile API
│   │   ├── Controllers/Admin/  # Panel screens
│   │   ├── Resources/Api/V1/   # Response shaping — what is and is not exposed
│   │   └── Middleware/         # mobile.auth, mobile.context, setLocale, staff, admin
│   ├── Models/
│   └── Support/                # Money (bcmath), ApiResponse envelope
├── database/
│   ├── migrations/             # 17 — this IS the schema
│   └── seeders/                # 22, incl. CoreSeeder and DemoSeeder
├── routes/
│   ├── api.php                 # /api/v1 + /api/postback/{provider}
│   └── web.php                 # Public site + admin panel
├── resources/                  # Blade + Livewire (admin), Inertia + React (public site)
└── README.md

Admin panel screens

AreaRouteWhat you do there
Dashboard/dashboardRevenue, conversions, partner health, withdrawal queue depth
Providers/admin/providersPartners, credentials, placements, geo rules, postback URL and template
Offers/admin/offersOffers, payouts, reward steps, requirements, status toggles
Offer categories/admin/offer-categoriesCategory tree used by the feed filters
App sections/admin/app-sectionsHome/feed layout and banners — no app release needed
Engagement/admin/engagement/*Levels, streak rewards, quests, promo codes, boosts, referrals
Cashouts/admin/cashoutsThe withdrawal catalogue and its categories
Cashout requests/admin/cashout-requestsApprove → processing → paid, reject, bulk actions, CSV export
Conversions/admin/conversionsConversions, postback logs, manual credit, reversal
Wallets/admin/wallets/{userId}Inspect a ledger; adjust with a compensating entry
Integrity/admin/flags, /admin/fraud-rules, /admin/audit-logFraud rules, open flags, immutable audit trail
Users & sessions/admin/user/all, /admin/sessionsSearch, inspect, block, delete
Settings/admin/settings/{app,general,web,contact,smtp,social-login,captcha}Everything the mobile /config serves, plus web/panel config
Content/admin/content/pages, /admin/email-templatesLegal/help pages and transactional email copy
Notifications/admin/notificationsBroadcast push
Support/admin/ticket-feedbacks, /admin/contact-messagesIn-app tickets and website contact form

🧩 Environment Configuration (Developers)

Laravel .env keys that matter

KeyNotes
APP_NAME, APP_URL, APP_ENV, APP_DEBUGAPP_DEBUG=false in production, always
APP_KEYphp artisan key:generate. Changing it makes every encrypted provider secret unreadable
JWT_SECRETphp artisan jwt:secret. Changing it logs every mobile user out
DB_*MySQL 8 / MariaDB 10.3+
ADMIN_EMAIL, ADMIN_PASSWORD, ADMIN_NAMESeeded admin. ADMIN_PASSWORD has no default: left blank, set to a common default, or under 12 characters, the seeder skips the admin entirely — in every environment, not just production
FIREBASE_CREDENTIALS, FIREBASE_PROJECTService account JSON path relative to the project root
QUEUE_CONNECTION, SESSION_DRIVERdatabase in production
ONESIGNAL_APP_ID, ONESIGNAL_REST_API_KEYServer-side push
MAIL_*Password resets, ticket replies
GOOGLE_CLIENT_ID/SECRET, FACEBOOK_CLIENT_ID/SECRETSocial login for the website. The mobile app uses Firebase, not these
TURNSTILE_* / RECAPTCHA_*Web form protection
DEFAULT_STORAGE_PROVIDER + S3/Wasabi/Storj/B2/Spaces/GCS keysOptional object storage for uploads
DEMO_MODEon makes the panel read-only for demos
env() may only be called inside config/*.php. It returns null once the config is cached, and ConfigurationSafetyTest fails the build if a call appears anywhere else. This is the rule that makes config:cache safe here.

🔌 API Integration Points (Developers)

1. Laravel mobile API (REST)

Retrofit interface: api/ApiService.kt and api/AuthApi.kt. Three interceptors sit in front of it:

InterceptorJob
AuthInterceptorAttaches Authorization: Bearer to protected calls
ContextHeaderInterceptorAdds X-Platform, X-App-Version, X-Device-Id, X-Locale, device model and OS version
TokenRefreshInterceptorOn a 401, refreshes once and replays the request. Concurrent refreshes are serialised behind a mutex so a burst of parallel calls produces one refresh, not ten

api/ApiCall.kt wraps every call into a Resource — success, an ApiErrorCode, or a transport failure — so no ViewModel ever sees a raw exception. api/PagedSource.kt owns paging against the meta block.

2. Firebase

  • Auth — anonymous, Google, Facebook, email. The ID token is exchanged for the API JWT pair.
  • Cloud MessagingCashLoopMessagingService; the token is posted to /auth/device.
  • Remote Config — one key, api_base_url.

3. Offerwall SDKs

Each launcher implements OfferLauncher and declares the slugs it handles. OfferLauncherRegistry resolves a slug to a launcher, and falls through to UrlOfferLauncher on unavailable, failed or unclaimed. Two launchers claiming the same slug is logged as a wiring mistake and the first wins — a duplicate registration must not stop a user opening an offer.

SDK credentials come from SdkConfigStore, populated from GET /providers/{slug}/sdk-config. settings is an open Map<String, String> rather than named fields on purpose: every network names its keys differently and adds new ones between releases, and typing them would mean an app release each time a partner renamed a field. A key that is absent was never configured — the server omits empty values rather than sending null, so missing and empty are the same thing.

4. Ads

ads/RewardedAdManager.kt (AdMob) and the two Unity managers. Unit ids and the daily cap come from config.ads; a network with enabled=false or no unit id is not initialised at all.


🧱 Extending and Modifying the App (Developers)

1. Add a language

  1. Create res/values-xx/strings.xml and translate the keys you need — anything missing falls back to the default file.
  2. Add the locale in the panel at /admin/locales/all so GET /locales offers it.
  3. Translate the server-side content that the app displays: pages, email templates, offer copy.

2. Add a colour preset

  1. Copy an ArcadePreset(...) block in ArcadePresets.kt, name it, and fill in every role — the data class makes the compiler check you did.
  2. Add a branch to the when in active and set SELECTED.

3. Add an offerwall partner

  1. Panel first. Create the provider, set its integration_type, credentials and postback allowlist. If its postback dialect fits, the GenericOfferwallAdapter handles it with no PHP at all.
  2. Hosted wall? You are done — POST /providers/{slug}/open returns a tracked URL and the fallback launcher opens it.
  3. Native SDK? Add the dependency to libs.versions.toml, write a launcher implementing OfferLauncher with handles = setOf("your_slug"), read its keys from SdkConfigStore, and bind it into the Set<OfferLauncher> in di/OfferwallModule.kt.
  4. Return LaunchResult.Unavailable rather than throwing when a key is missing — the registry will open the web version instead.

4. Add a new home section type

  1. Add the wire value to SectionLayout or SectionDataSource in data/models/HomeFeed.kt.
  2. Add a branch to decodeItems and a renderer in ui/tabs/feed/SectionRenderers.kt.
  3. Add the option to the panel's App sections form.
  4. Old builds render the new section as nothing, which is the intended behaviour — no crash, no wrong card.

5. Add an API endpoint

  1. Laravel: add the route inside the v1 group with the right throttle bucket (mobile for reads, mobile-write for anything that moves money), a controller method and an API Resource. Additive only — a breaking change goes to /v2.
  2. Android: add the method to ApiService, a @Serializable model, and a repository method returning Resource<T> via apiCall { }.
  3. Never bypass WalletService on the server side. If your endpoint moves money, it appends a ledger row.

Ground rules (from the backend README)

  1. Every schema change is a migration. No manual SQL, ever.
  2. Money is decimal(14,6) via App\Support\Money (bcmath). Never floats.
  3. Never write a balance outside WalletService. Reaching for $wallet->update(...) means you wanted a ledger entry.
  4. env() only in config/*.php.
  5. Ledger and audit rows are immutable. Correct with a compensating entry.
  6. The mobile API contract is frozen.

📊 Rate Limits and Outages

BucketLimitApplies to
mobile180/min per userReads
mobile-auth20/min per IP/auth/firebase, /auth/refresh
mobile-write30/min per userClicks, claims, withdrawals, tickets
promo-code5/min per user/promo-codes/redeem — codes are guessable by design
postbackPer providerPartner postbacks, so one misconfigured partner cannot exhaust everyone else's budget

Limits key on the user where possible and fall back to IP: carrier NAT can put thousands of real users behind one address.

Service cost overview

ServiceTypical cost
Laravel hostingShared from a few $/month; a VPS from ~$6/month
Firebase Auth / FCM / Remote ConfigFree at this scale (Spark)
OneSignalFree tier covers a launch
Offerwall partnersNo cost — they pay you
Play Console$25 one-off
User payoutsYour largest expense. Set the minimum withdrawal and reward rates so payouts stay below partner revenue

Monitoring, in priority order

  1. wallet:verify exit code. Non-zero means a balance disagrees with its ledger. Nothing else matters more.
  2. Partner health. An active, configured partner sending nothing for 24 hours is the failure that raises no error and quietly stops revenue.
  3. Withdrawal queue depth. Users notice this before you do.
  4. Open fraud flags. They accumulate silently; nobody is paged.
  5. postback_logs rejection rate. A spike usually means a partner rotated a secret or changed IPs.
Points 2–5 are pull, not push — there is no alerting built in. That is a known gap: wire them into your own monitoring if you need to be paged.

Common outage symptoms

SymptomUsually means
Every screen empty, header at zeroAPI unreachable — wrong origin, DNS, or the panel is down
Feeds populate but the wallet does not movePostbacks rejected — check the logs and the IP allowlist
One partner's offers vanishThat provider was set to paused/testing, or offers:expire retired stale inventory
Everyone gets E_MAINTENANCEmaintenance_mode left on in the panel
Sudden 429sA client retry loop, or a shared IP under carrier NAT

🔗 Integrations Overview

ServiceRoleWhere its key lives
Firebase AuthIdentitygoogle-services.json (app) + service account (server)
Firebase Cloud MessagingPush transportgoogle-services.json
Firebase Remote ConfigAPI origin onlyFirebase Console
Offerwall partners ×8Inventory and revenueprovider_settings (encrypted, server)
Google AdMobBanner / interstitial / rewarded / nativeApp id in strings.xml; unit ids in the panel
Unity AdsSecond ad networkPanel
OneSignalBroadcast pushApp id via /config; REST key in .env
Google Sign-InAccount upgradegoogle-services.json (client id generated at build)
Facebook LoginAccount upgradestrings.xml
Cloudflare TurnstileWeb form protection.env

Offerwall Partners

Eight partners ship configured. Four are native SDKs, four are hosted walls opened by tracked URL. All eight pay by server-to-server postback.

PartnerSlugIntegrationPostback adapter
BitLabsbitlabsNative SDKBitLabsAdapter
ayeT-StudiosayetNative SDKGeneric
MyChipsmychipsNative SDKGeneric
Digital Turbinedigital_turbineNative SDK (FairBid)Generic
CPAleadcpaleadHosted wallCpaLeadAdapter
ToroxtoroxHosted wall (+ bundled AAR)ToroxAdapter
Revenue UniverserevuHosted wallGeneric
MM WallmmwallHosted wallGeneric

Setting one up

1Sign up as a publisher with the partner and get your app approved. Most require a live Play listing or at least a store URL.
2In the panel: /admin/providers → edit the provider → fill in credentials. For SDK partners the settings keys the app reads are sdk_app_id, sdk_placement and sdk_api_key — map the partner's naming onto those.
3Copy the postback URL (https://your-domain/api/postback/{slug}) and the query template into the partner's dashboard.
4Set postback_ip_allowlist from the IP list the partner publishes, and the signing secret if they offer one.
5Set the provider to testing, fire a test conversion, confirm it in Money → Postback logs, then go active.
The SDK key and the postback secret are different things. The SDK key is publisher-safe and is served to the app; the postback secret never leaves the server. If a partner gives you one value for both, treat it as a secret and use a separate SDK key if they offer one.

Google AdMob

1Create an AdMob app matching your applicationId.
2Put its application id in res/values/strings.xmladmob_app_id. This one is compiled in and read from the manifest — a missing or malformed value crashes the SDK at startup.
3Create the ad units you want (banner, interstitial, rewarded, native) and paste their ids into /admin/settings/app. Leave any of them blank to disable that format.
4Set rewarded_ad_reward_coins and daily_rewarded_ad_limit.
Play policy notes. Never place an interstitial where it can be tapped accidentally after an offer opens; keep interstitial_interval_minutes sane. Reward apps get scrutinised more than most — an ad that blocks a user from reaching their balance is a policy problem, not just a UX one.

Unity Ads

Create a Unity project, enable Monetization, and copy the game id and placement ids into /admin/settings/app under the unity block. Unity Ads pulls play-services-cronet transitively, which is why the Gradle repositories are deliberately unfiltered — see Build & Run.

OneSignal & FCM

1Create a OneSignal app and connect it to your Firebase project (it needs the FCM v1 service account).
2Put the OneSignal app id in /admin/settings/app — it reaches the app through config.push.onesignal_app_id.
3Put ONESIGNAL_APP_ID and ONESIGNAL_REST_API_KEY in the Laravel .env so the panel can send.
4Send a test from /admin/notifications.

Plain FCM works independently for server-triggered, per-user messages — CashLoopMessagingService handles those, and tokens are registered through POST /auth/device.

Google & Facebook Sign-In

ProviderWhat you needMost common mistake
GoogleSHA-1 + SHA-256 for debug and release registered in Firebase, then re-download google-services.jsonMissing release SHA, or a google-services.json from another project → DEVELOPER_ERROR
FacebookApp id, client token, Android platform with package name and key hashes, the Firebase OAuth redirect in Facebook Login settingsMissing release key hash → login works in debug only

With Play App Signing enabled, the fingerprints that matter are the ones Play shows you under App integrity, not your upload keystore's. Add both.

Cloudflare Turnstile

Protects the website's registration, login and contact forms — not the mobile API, which is protected by Firebase token verification and rate limits. Set TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY, then enable it at /admin/settings/captcha. reCAPTCHA is supported as an alternative.


🛡 Play Policy & Permissions

Read this before you buy hardware, brand the app or plan a launch date. A rewards app of this type has three approval gates that have nothing to do with code quality, and two of them can stop you shipping regardless of how well the app works.

None of this is a defect in the product, and none of it can be solved by us. These are policy positions held by Google and by the offerwall networks, and they apply to your account, your listing and your agreements. Plan for them.

Permissions the app declares

Four ordinary permissions and one sensitive one. Every entry is used — the manifest carries nothing "just in case", because an unjustifiable declaration is a rejection over a feature that does not exist.

PermissionUsed forPlay treatment
INTERNETEvery API callNormal
ACCESS_NETWORK_STATEOffline detection before a requestNormal
RECEIVE_BOOT_COMPLETEDPush re-registration after a rebootNormal
POST_NOTIFICATIONSPush (Android 13+)Runtime prompt
PACKAGE_USAGE_STATSUsage-verified offers onlySensitive — needs written justification
READ_MEDIA_IMAGES and READ_EXTERNAL_STORAGE used to be declared here and were never used by any code path — there is no avatar upload and no gallery picker. They have been removed. If you add a feature that needs storage access, expect to justify it at review.

Justifying PACKAGE_USAGE_STATS

This is the one Play will ask you about directly. It is what lets the app confirm that a user actually opened and played a rewarded app, rather than installing it and immediately uninstalling it — which is the single most common way offer fraud is committed against you.

Wording you can adapt for the Play Console declaration:

"The app rewards users for installing and using partner applications. Usage access is used solely to confirm that a user has genuinely opened and used an application they were rewarded for, for the minimum duration stated in the offer. Access is requested in context, only when the user opens an offer of this type, and never at launch. The data is limited to the package names of applications the user has explicitly chosen to engage with through our offer catalogue, is used only to validate that specific reward, and is not used for advertising, profiling or any other purpose."

You can switch this off entirely. The permission is only ever exercised for offers whose verification_mode is usage. If you run only offerwall inventory — where the partner verifies the conversion and tells you by postback — you never need it. To drop it:

  1. Set no offer to verification_mode = usage in the panel.
  2. Remove the PACKAGE_USAGE_STATS line from AndroidManifest.xml.

That removes the hardest approval question from your submission at the cost of one offer type. If you are launching for the first time, this is the safer path.

Incentivised installs

Rewarding users for installing other applications is permitted, but it sits in a policy area Google enforces actively, and the boundaries move. What matters in practice:

  • Never reward a Play Store rating, review or ranking action. This is unambiguously prohibited and is the fastest route to suspension. The app does not implement any such offer type and you should not add one.
  • Be honest in the listing. Describe it as a rewards app that pays for completing partner offers. Do not imply guaranteed earnings, "free money", or specific income figures.
  • Users must actually be paid. A rewards app with unpayable thresholds or withdrawals that never complete attracts complaints, and complaints attract review.
  • Ad-network rules apply on top. AdMob's own policies restrict how rewarded ads may be presented — an AdMob suspension costs you a revenue stream even if Play is content.
Check the current wording before you submit. Read Google Play's Monetisation and Ads policy, and the AdMob programme policies, at the time you publish. Anything written in a document like this one is a snapshot.

One product-level note: stake.png ships as a sample cashout option. Gambling-adjacent payout destinations carry their own policy weight in several markets — remove it unless you have deliberately decided otherwise.

You need your own offerwall accounts

This package does not come with offer inventory, and cannot. CPAlead, BitLabs, Torox, AyeT and the rest each require their own publisher account, and each approves publishers individually. Until you hold an approved account with at least one of them, the app has nothing to show and cannot generate a single cent.

What that means for your timeline:

  • Apply to the networks early — approval is manual and commonly takes days, sometimes longer, and some decline applicants with no traffic history.
  • Most will ask to see a live app or a real website before approving you. This is a chicken-and-egg problem every publisher hits; a working demo build and a real privacy policy help.
  • Each approved network gives you credentials that go in the panel, not in the app. See Postbacks.
  • Partner logos follow the same rule as inventory: your agreement with a network does not transfer, so treat the bundled logos as placeholders for your own approved branding.
The demo seeder (php artisan db:seed --class=DemoSeeder) populates the catalogue with sample offers so you can build, screenshot and demo the app before any network approves you. Those offers cannot pay real money — they exist so the UI is not empty.

Data safety form

Declare at minimum what the app actually collects:

DataCollectedPurpose
Email addressIf the user signs in with Google, Facebook or emailAccount management
Advertising IDYesOffer attribution and ad serving
Approximate location (from IP)Yes, server-sideGeo-targeting offers and fraud checks
Device identifiersYesMulti-account abuse detection
App usage (usage-verified offers only)Only if you enable that offer typeReward validation
Payout details (email, wallet address)On withdrawalPaying the user

A reachable privacy policy URL is mandatory. The panel serves one at /{locale}/page/privacy-policy — edit the content in Admin → Content pages and use that URL in the listing.


🔒 Security Hardening

What the app does to protect a signed-in session, and what you must not undo.

The session is encrypted at rest

The API access token and a 90-day refresh token live in the app's DataStore. That file is encrypted with AES-GCM under a key held in the Android Keystore, which never leaves the device and is hardware-backed where the device provides it.

It is applied in the DataStore serializer, so nothing else in the app has to know: SettingsCrypto encrypts on write and decrypts on read. Files written by an earlier build are plain protobuf; they are detected, read as-is and re-encrypted on the next write, so upgrading installs stay signed in.

If the file cannot be decrypted — tampering, or app data restored onto a different device — the app treats it as no session rather than crashing. The cost is one sign-in.

Backups are off

android:allowBackup="false", plus an @xml/data_extraction_rules that excludes everything from both cloud backup and device-to-device transfer. Android 12+ no longer lets allowBackup decide this alone, which is why both exist.

Do not turn backup on. With it enabled, adb backup extracts a 90-day refresh token from an unlocked device and it replays as that user until it expires, and cloud backup would place the same token on Google's servers and restore it onto another handset.

Cleartext traffic is disabled

usesCleartextTraffic="false". Every origin must be HTTPS, including whatever you set as the API base URL. The emulator default (http://10.0.2.2:8000/) is the one exception and it only applies to debug builds.

What the app never decides

The client measures and reports; the server decides. Rewards, balances and eligibility are computed server-side and the app acts only on the verdict, so a modified APK cannot pay itself. Every balance change is a row in wallet_transactions, and wallet:verify re-sums the ledger hourly and exits non-zero on drift.

Postbacks fail closed

A provider postback is an anonymous HTTP request whose payload says "credit this user". Every adapter refuses one unless that provider has either a shared secret it can verify or an IP allowlist. A partner configured with neither credits nobody — it does not credit them unverified. See Postbacks.


🤖 Android Release Checklist

1. Critical blockers — fix before upload

BlockerFix
Build output is app-release-unsigned.apkYou have not configured a release key. Play cannot accept it — see below. The build never falls back to debug.keystore
releaseApiBaseUrl still points at panel.cashloop.appPoint it at your own origin, with a trailing slash
cashloop.apiBaseUrl set in the project's gradle.propertiesRemove it. It overrides both variants, so your release APK will silently point at your dev machine
applicationId is still com.cashparty.rewards.appChange it, then redo Firebase / OAuth / Facebook / Play
Placeholder google-services.jsonReplace with your own project's — the shipped one is a template with replace-me ids and will not authenticate
Placeholder AdMob / Facebook ids in strings.xmlReplace with yours. Until you do, Facebook login is skipped and AdMob serves Google's test ads
No privacy policy URLPublish the page in the panel and link it in Play Console
Data safety form does not mention the advertising id or usage accessDeclare both honestly — see Data safety
PACKAGE_USAGE_STATS declared with no justification preparedEither prepare the declaration or drop the permission — see Justifying PACKAGE_USAGE_STATS
No approved offerwall account yetThe app will show an empty catalogue. Apply early — see You need your own accounts
Already handled for you, so they are not on this list: R8 and resource shrinking are enabled for release; the release build refuses to fall back to the debug keystore; backups are disabled and session tokens are encrypted at rest; unused permissions have been removed. See Security Hardening.

2. Release signing (keystore)

2a. Create an upload keystore (one time):

keytool -genkey -v -keystore release.jks -keyalg RSA -keysize 2048 \
        -validity 10000 -alias cashloop
Back this file and its passwords up somewhere you will still have them in five years. Without Play App Signing, losing the key means you can never update the app again.

2b. Tell Gradle where it is. No code change is needed — app/build.gradle.kts already reads these four values and builds the release signing config from them. Put them somewhere outside the repository:

# ~/.gradle/gradle.properties
cashloop.releaseStoreFile=/absolute/path/to/release.jks
cashloop.releaseStorePassword=…
cashloop.releaseKeyAlias=cashloop
cashloop.releaseKeyPassword=…

Or as environment variables, which is usually what you want in CI:

CASHLOOP_RELEASE_STORE_FILE       CASHLOOP_RELEASE_STORE_PASSWORD
CASHLOOP_RELEASE_KEY_ALIAS        CASHLOOP_RELEASE_KEY_PASSWORD
Do not put these in the project's own gradle.properties. That file is committed, and a keystore password in it is a published credential — the same mistake as shipping the keystore itself.

2c. Confirm it took effect. The filename tells you:

./gradlew :app:assembleRelease
ls app/build/outputs/apk/release/

app-release.apk            ← signed: your key was found
app-release-unsigned.apk   ← unsigned: Gradle did not see the properties

If it is still unsigned, the properties are not being read — check the path is absolute and that you edited ~/.gradle/gradle.properties, not the project's copy.

2d. Play App Signing (recommended). Let Google hold the app signing key and keep your keystore as the upload key only. If the upload key is ever lost you can reset it; the app signing key you cannot.

3. SHA fingerprints

keytool -list -v -keystore release.jks -alias cashloop

Add SHA-1 and SHA-256 to Firebase for the release key and for the Play App Signing key (Play Console → App integrity). Then re-download google-services.json. Skipping this is the single most common cause of "Google Sign-In works in debug, fails in production".

4. Versioning

Bump versionCode on every upload — Play rejects a duplicate. versionName is what users see and what X-App-Version sends, which is what the force-update gate compares against min_supported_version.

5. Build the production AAB

./gradlew clean
./gradlew :app:bundleRelease
# → app/build/outputs/bundle/release/app-release.aab

To test the exact artefact Play will serve:

bundletool build-apks \
  --bundle=app/build/outputs/bundle/release/app-release.aab \
  --output=app-release.apks \
  --mode=universal \
  --ks=release.jks --ks-key-alias=cashloop

6. Play Console

  1. Create the app with your final package name.
  2. Complete the store listing, content rating, target audience and Data safety form.
  3. Upload the AAB to internal testing first and run the whole earn → convert → withdraw path against production.
  4. Promote to production when the postback path is confirmed working on the release build.

7. Post-release

  • Watch wallet:verify and the postback rejection rate for the first 48 hours.
  • Keep one partner in testing as a canary when adding new ones.
  • Set min_supported_version only when you genuinely need to cut off old builds — it is a hard wall for the user.

🐛 Troubleshooting

App stuck on the splash screen

The config fetch is failing. Check, in order: the origin is right and ends in a slash; the device can actually reach it (emulator ⇒ 10.0.2.2, physical device ⇒ LAN IP or tunnel); the origin is HTTPS (cleartext is disabled); the panel returns JSON for curl .../api/v1/config.

Maintenance or "update required" screen you did not expect

Both come from the server. Check maintenance_mode and min_supported_version at /admin/settings/app. Remember the gate compares against X-App-Version, i.e. your versionName.

Feeds are empty

  • Is there at least one provider set to active (not testing/paused)?
  • Does it have offers, and are they active and in date?
  • Is the user's country blocked by a geo rule?
  • Did offers:expire retire everything because the seeded demo offers had past end dates? Re-seed with DemoSeeder.

API calls fail with 401 / E_UNAUTHENTICATED

  • Firebase Anonymous provider not enabled.
  • FIREBASE_CREDENTIALS missing, wrong path, or pointing at a service account from a different Firebase project than the app's google-services.json.
  • JWT_SECRET changed — every existing token is now invalid; users re-authenticate on next launch.

Offers open but nothing is ever credited

This is almost always the postback, not the app.
  1. Money → Postback logs. Are hits arriving at all? If not, the partner has the wrong URL.
  2. If they arrive and are rejected: the IP allowlist or the signature. A partner that rotated IPs is the usual cause.
  3. If they arrive and are accepted but no conversion appears: the click id is not coming back. Confirm the app opened the URL from /offers/{id}/click rather than a store link, and that the partner's sub-id macro is in their template.
  4. If a conversion exists but the balance did not move, run php artisan wallet:verify — and treat a non-zero exit as an incident.

Usage-verified offers never pay

  • Usage access was not granted — it is a Settings screen the user must visit, and it cannot be granted by a normal runtime prompt.
  • The offer's target package name does not match what was installed.
  • The report only fires on app resume. Reopening CashLoop is required.

Withdrawal is refused

The response lists every unmet rule in error.fields.reasons. Typical causes: below min_cashout_cash; the amount exceeds available (remember pending withdrawals are already held); the option is geo-blocked or out of stock; the account carries an open fraud flag; verification is required.

Google Sign-In fails on Android

Ninety percent of the time it is one of two things: the SHA fingerprint for that build type is not registered in Firebase, or google-services.json is from a different project than the one you enabled Google sign-in on. Add both the release key and the Play App Signing key, then re-download google-services.json and rebuild.

There is no default_web_client_id to edit — the Gradle plugin generates it from google-services.json, so a stale value almost always means a stale JSON file.

Ads not showing

  • A brand-new AdMob account has no fill for hours to days — test with Google's official test unit ids first.
  • admob_app_id missing or malformed crashes the SDK at startup; check Logcat.
  • The network is disabled or its unit id is blank in the panel — that is a deliberate "no ad slot", not a bug.

Admin panel 500s

  • "Unable to locate file in Vite manifest"public/build/ is missing or stale. Run npm install && npm run build. This takes down every page, so if nothing at all loads, check it first.
  • Missing bcmath — check php -m.
  • SESSION_DRIVER=database with no sessions table — run php artisan migrate.
  • Stale caches after a .env change — php artisan config:clear && php artisan config:cache.
  • Storage permissions — chmod -R 775 storage bootstrap/cache.

Seeding finishes but you cannot sign in to the panel

The admin was not created. AdminUserSeeder skips it — printing a warning — when ADMIN_PASSWORD is blank, is a common default, or is under 12 characters. Set a real one and re-run php artisan db:seed --class=AdminUserSeeder.

Password reset or "forgot password" pages 500

Same root cause as above: those pages are plain Blade and load the compiled stylesheet through Vite. Run npm run build.

Build issues

  • JDK below 17settings.gradle.kts fails the build with an explicit message.
  • Unresolved dependency from an ad SDK — do not add group filters to the Gradle repositories; the transitive artifacts live under unrelated group ids.
  • "Kotlin plugin applied twice" — AGP 9 has Kotlin built in; the org.jetbrains.kotlin.android plugin must not be applied.
  • MyChips artifact not found — its Maven repository entry in settings.gradle.kts is scoped to io.mychips; keep it.
  • Last resort: ./gradlew clean && ./gradlew --stop, then File → Invalidate Caches.

❓ FAQ

Q: Do I need to import a SQL dump?
A: No — and you should not. There isn't one. php artisan migrate on an empty database builds the complete schema. The migrations are the schema, deliberately.

Q: Can I run php artisan config:cache?
A: Yes, and you should in production. Every configuration read goes through a config file and a test fails the build if a raw env() call appears elsewhere.

Q: How many Firebase Remote Config keys do I need?
A: One — api_base_url, and even that is optional if the build's baked-in origin is correct. Everything else comes from GET /api/v1/config.

Q: Where do offerwall SDK keys go?
A: In the admin panel, under the provider. They are served to the app at runtime via /providers/{slug}/sdk-config. Nothing partner-specific is compiled into the binary.

Q: How does a user get paid for an offer?
A: The app opens the tracked URL from POST /offers/{id}/click; the partner returns the click id on their postback; the server records a conversion and appends a ledger row. SDKs never credit anything.

Q: Why does the app open a Custom Tab instead of the Play Store?
A: Because the click id lives in the URL's query string, and a store intent drops it. Without the click id the conversion cannot be attributed and the user is never paid.

Q: What is the difference between cash_balance, pending and available?
A: available is what can be spent or withdrawn right now. pending is money that has arrived but is not yet withdrawable. cash_balance is the total. Requesting a withdrawal drops available immediately; cash_balance only drops when it is paid.

Q: What happens to a guest's balance when they sign in?
A: It carries over. Signing in upgrades the anonymous Firebase user in place, so the firebase_uid — and therefore the server account — is unchanged. If the credential already belongs to a different account, Firebase refuses the link and the guest session is dropped; there is no supported way to move a balance between accounts.

Q: How do I hide a whole feature?
A: Turn its flag off in config.features. The tab is removed rather than greyed out — a greyed tab still advertises something the user cannot have.

Q: Can I reorder the home screen without shipping an update?
A: Yes. Sections come from /admin/app-sections and are read on the next launch. A section with no items is omitted automatically.

Q: Which languages does the Android app ship?
A: en, tr, de, es, it, ar under res/values(-*)/, matching the locales the API serves.

Q: How do I reskin the app?
A: Change ArcadePresets.SELECTED (1–9) and rebuild. Both light and dark come from the same preset.

Q: Do I need the usage-stats permission?
A: Only if you run first-party offers with verification_mode = usage. If you rely entirely on partner offers, the permission is never exercised — and you can remove it from the manifest.

Q: Can I launch without ads?
A: Yes. Leave both ad networks disabled in the panel. The offerwall partners are the revenue engine; ads are supplementary.

Q: How do I add a partner the app has never heard of?
A: If they are a hosted wall, entirely in the panel — no app change. If they require a native SDK, add a launcher class and bind it in di/OfferwallModule.kt. See Extending.

Q: What is the one thing I should monitor?
A: The exit code of wallet:verify. Non-zero means a balance disagrees with its ledger, and nothing else in the system matters more than that.

Q: Where is support?
A: Email morningstarcode@gmail.com.

🎉 Setup Complete! You now have a full guide for CashLoop Android — native Kotlin/Compose configuration and reskin, the Laravel 13 admin panel and its frozen mobile API, Firebase auth and push, offerwall partners and their postbacks, the wallet ledger, Gradle builds, and the Play release path.