White label apps: sharing builds for many brands
· 7 min read
White label apps are a common agency business: one codebase, many branded builds. A restaurant ordering app, a gym booking app, a loyalty app, each shipped under a different client's name, icon and colours. The code is shared, but almost everything around the build is per client: the app id, the signing, the store account, the testers and the person who signs off.
That is where white label projects get messy. This guide covers how to set up per brand builds so they stay separate, how to handle signing when each client owns their own developer account, and how to share each brand's test builds without mixing them up.
One codebase, separate identities
Every brand needs its own app identity. Two builds with the same identity are, as far as the phone and the stores are concerned, the same app, and one will replace the other on a tester's device.
Android: product flavors
On Android, define a product flavor per brand with its own applicationId, app name, icon and resources:
android {
flavorDimensions += "brand"
productFlavors {
create("clientA") {
dimension = "brand"
applicationId = "com.clienta.orders"
}
create("clientB") {
dimension = "brand"
applicationId = "com.clientb.orders"
}
}
}
Brand specific resources go in src/clientA/res and src/clientB/res, and Gradle builds tasks such as assembleClientARelease. Keep brand config (API base URL, feature switches, analytics keys) in the flavor too, so a build can never ship with the wrong client's settings.
iOS: targets or configurations
On iOS, use a target per brand, or one target with a build configuration and .xcconfig file per brand. Each needs its own PRODUCT_BUNDLE_IDENTIFIER, display name, asset catalog for the icon, and Info.plist values. Per brand .xcconfig files keep the differences in plain text you can review, which is easier than hunting through build settings.
Cross platform projects
Flutter supports flavors on both platforms and maps them onto Android flavors and iOS schemes. React Native projects use the same native mechanisms underneath, usually with an environment file per brand. Whatever the stack, the rule is the same: the bundle id or package name comes from the brand config, never from a hand edit before a build. See sharing Flutter builds with testers for the upload side.
Signing per client
Signing is the part that needs a decision per client, because it depends on who owns the store account.
| Setup | iOS signing | Android signing |
|---|---|---|
| Client owns their developer accounts (most common for white label) | Certificates and profiles from the client's Apple team; you are added as a team member | Client's upload key, or one you create and hand over; Play App Signing holds the app signing key |
| Agency publishes under its own account | Your team's certificates | Your keystore per app |
A few things worth getting right early:
- One Android keystore per brand. A signing key is tied to the app for its life. If you sign every brand with one agency key and later hand a brand to the client or to another agency, that key goes with it. Separate keys keep each brand portable.
- Ad Hoc devices count per Apple account. If test builds for a brand are signed with the client's Apple team, their testers' UDIDs must be registered on the client's account. Apple allows 100 devices per product family per membership year, and removing one mid year does not free the slot.
- Keep secrets per brand in CI. Store each brand's certificate, profile, keystore and passwords as separate CI secrets, named by brand, so a pipeline can only sign with the identity it was given.
The common errors (no matching profile, wrong team, expired certificate) are covered in iOS code signing errors on test builds, and Android key handling in Android app signing for APK and AAB.
Organising test builds per client
With five brands and two platforms, you have ten streams of builds. The way to keep them straight is to treat each brand as its own project, everywhere: in CI, in your task tracker and in the place testers install from.
A layout that works:
- One project per brand, holding both the iOS and Android builds of that brand.
- Build names that start with the brand, then version and build number.
- Release notes that say which shared code changes and which brand specific changes are in the build.
- Testers invited per brand, so the client of brand A never sees brand B.
On AppForShare: each project keeps its own builds, and every upload is a new build with its own link and QR code. The "what changed" view compares a build with the previous build of the same app and platform, so it is a quick way to spot a brand build that picked up the wrong permissions, minimum OS or bundle settings. On the Team plan, private projects can only be opened by invited testers, testers are emailed when a new build lands, and you can see who installed. Roles (Admin, Developer, Viewer) control who on your side can upload or change things.
If a client needs a review and approval flow, client projects (Pro: 2 active, Team: unlimited) give each client a portal with their deliveries, a feedback thread per version and sign-off on the exact build, recorded with the file's SHA-256. More on that in sending app builds to clients.
Uploading every brand from CI
A matrix build is the natural fit: one job per brand and platform, each ending with an upload of that brand's file.
jobs:
android:
strategy:
matrix:
include:
- flavor: clientA
task: ClientA
- flavor: clientB
task: ClientB
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./gradlew assemble${{ matrix.task }}Release
- name: Share build
run: npx --yes appforshare@1 upload app/build/outputs/apk/${{ matrix.flavor }}/release/app-${{ matrix.flavor }}-release.apk --notes "${{ matrix.flavor }}: ${{ github.event.head_commit.message }}"
env:
APPFORSHARE_TOKEN: ${{ secrets.APPFORSHARE_TOKEN }}
Gradle task names capitalise the flavor, which is why the matrix carries both forms. The example assumes each flavor has a release signing config; without one, Gradle names the file app-clientA-release-unsigned.apk and it will not install. Check the output path your project actually produces. CI upload tokens are part of Pro and Team; see the CI docs.
Branded install pages and your own domain
When a client opens a test build, the install page is the first thing they see. For a white label business, it helps if that page looks like it came from you and not from a third party tool.
On AppForShare: the Team plan adds a white label install page (your logo and colours, and the "Shared with AppForShare" line hidden) and a custom domain for install links. The branding is set for your workspace, so treat it as your agency's brand on every client's page rather than a different look per client. The client's own app name and icon still come from the build itself.
Check each brand build before it goes out
The most embarrassing white label bug is a build with the wrong brand inside: the right name, the wrong icon, or a production key from another client. Before sending, open the file in the IPA inspector or APK analyzer. Both run in your browser without uploading the file and show the app name, icon, bundle id or package, version, and for iOS the signing team and provisioned devices. Thirty seconds per brand is cheaper than an apology.
Builds uploaded to an account also get a store readiness check, such as SDK version, privacy manifest, target API level and 16 KB page size. It is guidance, not a promise of store approval, and AppForShare does not publish to the stores or re-sign builds.
Set up your brands
For a white label agency, the Team plan at $39 a month is the usual fit: 5 seats, private projects per brand, tester emails, who installed, a branded install page and your own domain. Pro at $12 a month covers CI uploads, release notes and 2 client projects if you are starting with a smaller roster. Read more on the agencies page, compare plans on pricing, or sign up and create a project for your first brand.


