On this page
Set up AutoPromote end to end
This guide takes you from a new account to releases that move themselves from internal testing to 100% of users, for Google Play and the App Store. Most teams finish setup in about 20 minutes.
How AutoPromote works
Your CI uploads a build to the store as it does today, then makes one HTTP call to AutoPromote. From there AutoPromote moves the build through each testing stage, waits a soak period between stages, watches store reviews, and rolls it out to production. You can pause, approve, skip a soak or halt at any point from the console.
- Internal testinginstant
- Closed testingsoak 24h
- Open testingsoak 48h
- Approvaloptional
- Production10% → 50%
- 100%all users
- TestFlight internalinstant
- TestFlight externalBeta App Review
- Approvaloptional
- App Store reviewApple
- Phased release7 days
- 100%all users
Soak lengths, which stages run, and whether production needs a human approval are set per app. Every stage change can be sent to Slack, Microsoft Teams, PagerDuty or your own webhook.
What you need
- An app that is already published (or set up as a draft) in Google Play Console or App Store Connect.
- Admin access to that store account, to create an API key for AutoPromote.
- A CI pipeline that uploads builds (GitHub Actions, GitLab CI, Bitrise, fastlane, Xcode Cloud or anything that can run
curl).
Create your workspace
- Create an account with your work email. You become the owner of a new workspace.
- Open Settings & Profile → Billing, choose how many apps you want to monitor and subscribe. Pro costs $8 per app per month or $36 per app per year, and each app includes two team seats. An Android app and an iOS app count as two apps.
- Invite teammates from Team & Access. They receive an email with a link to join your workspace. See Team and roles for what each role can do.
You can change the number of apps later from the same billing card.
Connect a Google Play app
AutoPromote talks to Google Play with a service account that you create and control. You can revoke it at any time from Play Console.
Create the service account
- In Google Cloud Console, open (or create) a project and enable the Google Play Android Developer API. To use crash-rate checks, also enable the Google Play Developer Reporting API.
- Go to IAM & Admin → Service accounts, create a service account (no Cloud roles are needed), then open it and add a key of type JSON. Your browser downloads the key file.
- In Google Play Console, open Users and permissions → Invite new users and enter the service account's email address (it ends in
iam.gserviceaccount.com). - Under App permissions, add your app and grant: Release apps to testing tracks, Release to production, exclude devices, and use Play App Signing, and View app information (read-only). Send the invitation.
New permissions can take up to an hour to reach the Google Play API. If validation fails with "permission denied" right after inviting the account, wait and try again.
Add the app in AutoPromote
- In the console, open Onboard New App and choose Android (Google Play).
- Enter the app name and the package name exactly as it appears in Play Console, for example
com.yourcompany.app. - Upload the JSON key and press Validate. AutoPromote signs in as the service account and opens a test edit on your app; nothing is changed.
- Choose your stages and soak times (see Release policy), then finish. The last step shows your app's ingestion token once; copy it into your CI secrets.
Connect an App Store app
AutoPromote uses an App Store Connect API key to distribute builds in TestFlight, submit them for review and control the release.
Create the API key
- In App Store Connect, open Users and Access → Integrations → App Store Connect API → Team Keys. Only the Account Holder or an Admin can do this.
- Generate a key with the App Manager role.
- Download the
.p8file. Apple lets you download it only once, so store it safely. - Note the Key ID shown next to the key and the Issuer ID shown above the key list.
Prepare TestFlight
- Create at least one external testing group for the app, and fill in the Beta App Description and feedback email under TestFlight → Test Information. Apple requires both before Beta App Review.
- Declare export compliance: set
ITSAppUsesNonExemptEncryptiontoNOinInfo.plistif your app only uses standard encryption, or turn on Auto-declare no non-exempt encryption when onboarding. Builds without this declaration pause until it is answered.
Add the app in AutoPromote
- Open Onboard New App and choose iOS (App Store).
- Enter the app name and its bundle ID, for example
com.yourcompany.app. An iOS app may share its identifier with your Android app; they are tracked separately. - Paste the Issuer ID and Key ID, upload the
.p8file and press Validate. AutoPromote looks your app up on App Store Connect to confirm the key works. - Optionally limit which TestFlight groups receive builds, and add the "What to Test" text sent to testers. Finish and copy the ingestion token.
Build numbers (CFBundleVersion) must keep increasing across marketing versions, as CI run numbers or date-based numbers do. If your team restarts build numbers for each new version, use Reset on the app before the first build of the new version.
Release policy
Each app has its own policy. Edit it during onboarding or later from Applications → your app → Policies.
| Setting | What it does | Default |
|---|---|---|
| Stages | Turn testing stages on or off. A disabled stage is skipped. | All on |
| Soak hours | How long a build stays on a testing stage before moving on. Set 0 for no soak. | Internal 0h, closed/external 24h, open 48h, each production stage 24h |
| Staged production rollout | Google Play: 10% → 50% → 100% with a soak between steps. App Store: Apple's 7-day phased release. Turn off to release to 100% at once. | On |
| Require manual approval | Stops before production (Google Play) or before App Store submission (iOS) until a release manager approves. | Off |
| Crash-free threshold | Google Play only. During each soak AutoPromote checks the build's crash-free rate every 6 hours and halts if it drops below this value. | 99.5%, off until enabled |
| Cancel superseded builds | When a newer build arrives, stop the older in-flight release. | On |
Apple has no near-real-time crash API, so iOS has no automatic crash threshold. Use Halt from monitoring to let Crashlytics, Sentry or Datadog stop an iOS release instead.
Trigger releases from CI
After your pipeline uploads a build, call the trigger endpoint with the app's ingestion token. Store the token as a CI secret named AUTOPROMOTE_TOKEN. You can reveal or rotate it later under Applications → your app → CI.
Send the package name and the Android versionCode (plus an optional readable versionName).
- name: Upload to Google Play internal track
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
packageName: com.yourcompany.app
releaseFiles: app/build/outputs/bundle/release/app-release.aab
track: internal
- name: Start AutoPromote release
run: |
curl -fsS -X POST https://autopromote.knoje.com/api/releases/trigger \
-H "Authorization: Bearer ${{ secrets.AUTOPROMOTE_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"packageName":"com.yourcompany.app","versionCode":${{ github.run_number }},"versionName":"${{ github.ref_name }}"}'Send the bundle ID, the build number (CFBundleVersion) and the version string (CFBundleShortVersionString).
- name: Upload to App Store Connect
run: |
xcrun altool --upload-app -f build/App.ipa -t ios \
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
- name: Start AutoPromote release
run: |
curl -fsS -X POST https://autopromote.knoje.com/api/releases/trigger \
-H "Authorization: Bearer ${{ secrets.AUTOPROMOTE_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"platform":"ios","bundleId":"com.yourcompany.app","buildNumber":"${{ github.run_number }}","versionString":"1.4.0"}'AutoPromote waits up to two hours for App Store Connect to finish processing the upload, so you can call the trigger straight after uploading.
Other CI systems
Any system that can make an HTTPS request works. The call is the same; only the way you read secrets changes.
lane :release do
upload_to_play_store(track: "internal")
sh("curl", "-fsS", "-X", "POST", "https://autopromote.knoje.com/api/releases/trigger",
"-H", "Authorization: Bearer #{ENV['AUTOPROMOTE_TOKEN']}",
"-H", "Content-Type: application/json",
"-d", { packageName: "com.yourcompany.app", versionCode: ENV["BUILD_NUMBER"].to_i }.to_json)
endautopromote:
stage: deploy
script:
- >
curl -fsS -X POST https://autopromote.knoje.com/api/releases/trigger
-H "Authorization: Bearer $AUTOPROMOTE_TOKEN"
-H "Content-Type: application/json"
-d "{\"packageName\":\"com.yourcompany.app\",\"versionCode\":$CI_PIPELINE_IID}"Responses
| Status | Meaning | What to do |
|---|---|---|
200 | Release started. | Follow it in the console. |
400 | The body is invalid, or the app has no store credentials. | Check the fields above and the app's credentials. |
401 | The token is missing, wrong or was rotated. | Update the CI secret with the current token. |
402 | The workspace has no active subscription. | Subscribe or update payment in Billing. |
409 | The build is not newer than the one already in flight. | Nothing; duplicate CI retries are ignored safely. |
Signed requests
Instead of sending the token in a header, you can sign the request with it. Send the current Unix time in X-AutoPromote-Timestamp and an HMAC-SHA256 of <timestamp>.<raw body> in X-AutoPromote-Signature. Signatures older than five minutes are rejected.
BODY='{"packageName":"com.yourcompany.app","versionCode":42}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$AUTOPROMOTE_TOKEN" -hex | sed 's/^.* //')
curl -fsS -X POST https://autopromote.knoje.com/api/releases/trigger \
-H "Content-Type: application/json" \
-H "X-AutoPromote-Timestamp: $TS" \
-H "X-AutoPromote-Signature: sha256=$SIG" \
-d "$BODY"Alerts
Add channels under Notification Channels, choose which events each one receives, and press Test to send a sample message.
| Channel | What you paste |
|---|---|
| Slack | An incoming webhook URL (https://hooks.slack.com/…) from a Slack app with Incoming Webhooks enabled. |
| Microsoft Teams | A Workflows or incoming webhook URL for the channel. |
| PagerDuty | The 32-character integration key of an Events API v2 integration. |
| Webhook | Any HTTPS URL. AutoPromote shows a signing secret once after you create it. |
Events
| Event | Sent when |
|---|---|
| Build uploaded | CI triggers a release. |
| Promotion succeeded | A build is live on a new stage. |
| Review approved / pending / rejected | Google or Apple finishes, delays or rejects a review. |
| Soak completed | A soak period ends without problems. |
| Approval required | A release waits for manual approval. |
| Crash SLA breached | The crash-free rate fell below your threshold and the rollout was halted. |
| Emergency halt | Someone or something halted the release. |
| Workflow failed / Release completed | The release stopped with an error, or reached 100%. |
Verify webhook signatures
Webhook deliveries carry the same X-AutoPromote-Timestamp and X-AutoPromote-Signature headers as signed CI requests, made with the channel's signing secret.
import crypto from 'node:crypto';
export function verifyAutoPromote(req, rawBody, secret) {
const ts = req.headers['x-autopromote-timestamp'];
const sig = (req.headers['x-autopromote-signature'] || '').replace('sha256=', '');
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = crypto.createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex');
return sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}Halt from monitoring
Point a crash or error alert at the halt-signal endpoint to stop a release automatically. It uses the same ingestion token as the trigger and works for both platforms.
curl -fsS -X POST https://autopromote.knoje.com/api/releases/halt-signal \
-H "Authorization: Bearer $AUTOPROMOTE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"platform":"ios","bundleId":"com.yourcompany.app","reason":"Crash-free sessions below 99%"}'For Android, send "packageName" instead of "bundleId" and leave out platform. The endpoint returns 200 when it halted a release and 409 when nothing was in flight.
Run a release
The Releases page shows the active build on each stage, the current soak countdown and the event log. These actions are available while a release is in flight:
| Action | Effect |
|---|---|
| Pause / Resume | Stops or restarts the automation. Resume also restarts a release that paused for a store review or an approval timeout. |
| Skip soak | Ends the current soak and moves on. During an iOS phased release this releases to all users. |
| Approve production | Lets a release that waits for manual approval continue. |
| Halt | Stops the release and freezes the store rollout where possible. See below. |
| Reset | Clears the app's release history so the next build starts fresh. |
When a store review takes longer than expected (about four hours on Google Play, seven days on the App Store), AutoPromote pauses the release and sends a "Review pending" alert. If a review is rejected, fix the issue in the store console and press Resume; the same build is resubmitted.
What halt does
| When | Google Play | App Store |
|---|---|---|
| Testing stages | Stops the automation. Testers keep the build. | Stops the automation. TestFlight testers keep the build; an open Beta App Review is not cancelled. |
| Waiting for review | Stops the automation. | Stops the automation. The App Store submission stays open; nothing is released because AutoPromote controls the release. |
| Production rollout | Halts the staged rollout in Google Play. | Pauses Apple's phased release. Users who already updated keep the build. |
| Fully released | Nothing to stop. Ship a fix as a new build. | |
Team and roles
| Role | Can do |
|---|---|
| Owner | Everything, including billing and promoting other owners. |
| Admin | Connect and configure apps, rotate tokens, manage team and alert channels, run releases. |
| Member | Trigger, pause, resume, approve, skip and halt releases. |
| Viewer | See everything, change nothing. |
Removing someone signs them out everywhere immediately. A workspace always keeps at least one owner.
Billing
- Pro is billed per monitored app: $8 per app per month, or $36 per app per year. Each app includes two team seats.
- Change the number of apps from Settings & Profile → Billing. You can't go below the number of apps already connected.
- Manage billing opens the Polar customer portal for invoices, payment methods and cancellation. After cancelling, access continues until the end of the paid period.
- First-time subscribers can request a refund within 14 days; see the refund policy.
Troubleshooting
Validation says permission denied
Google Play: check the service account was invited to the right app with the release permissions listed above, and wait up to an hour for new permissions. App Store: the key needs the App Manager role and must belong to the team that owns the app.
The trigger returns 401
The token was rotated or copied incompletely. Reveal the current token under Applications → your app → CI and update the CI secret. For signed requests, check the server clock and that you sign the exact raw body.
The release is paused with "Review pending"
The store has not finished its review yet. Once the build is approved or live in the store console, press Resume.
An iOS release says "Missing export compliance"
Answer the encryption question for the build in App Store Connect, or set ITSAppUsesNonExemptEncryption in Info.plist, then press Resume.
An iOS build is ignored with 409
Its build number is not higher than the last one AutoPromote saw. Use increasing build numbers, or press Reset on the app before starting a new numbering scheme.
Still stuck? Email contact@knoje.com with your workspace email and the app's package name or bundle ID.
API reference
These endpoints use the app's ingestion token. Everything else is done from the console.
| Endpoint | Body |
|---|---|
POST /api/releases/trigger | Android: packageName, versionCode, optional versionName. iOS: platform: "ios", bundleId, buildNumber, versionString. Optional stage overrides: enableInternal, enableAlpha, enableBeta, enableProdStaged. |
POST /api/releases/halt-signal | packageName or bundleId, optional platform and reason. |
GET /api/health | No authentication. Returns {"ok":true} when the service is up. |