Build & Publish Guide

Everything about the PWA to APK/AAB Builder: debug and release builds, signing keys, versions, notification icons, publishing on Google Play and the HTTP API for curl, scripts and CI pipelines.

Quick start

  • Testing on your own phone? Build a Debug APK, copy it to your phone and install it.
  • Publishing on Google Play? Build a Release with your package ID. You get a ZIP with a signed .aab (upload to Play), a signed .apk and your new upload key.
  • Updating your app? Build a release again with the same package ID, a higher version code and your existing key.
  • Automating it? Everything works with curl. See API.
# Debug APK
curl -F file=@project.zip -F appName="Daily Maze" -o DailyMaze.apk https://finnfreitag.com/tools/pwabuilder/api

# First release for Google Play (new key is generated)
curl -F file=@project.zip -F buildType=release -F appId=com.yourcompany.dailymaze \
     -F versionName=1.0.0 -F versionCode=1 -o release.zip https://finnfreitag.com/tools/pwabuilder/api

Build types

DebugRelease
OutputOne .apkZIP: .aab, .apk, README.txt, key files (if generated)
Signed withThe builder's debug keyYour upload key (generated or uploaded)
Google PlayNot acceptedUpload the .aab
SideloadingYesYes, with the signed .apk
Package IDOptional (auto-generated)Required
WebView debuggingEnabled (chrome://inspect)Disabled
Exact notification timesYes (USE_EXACT_ALARM)Only if the user allows exact alarms, otherwise a few minutes inaccurate (Play policy)

Parameters

The same parameters are used by the web interface and the API (as multipart/form-data fields).

FieldTypeDescription
filefileA .html file or a .zip with your web project (needs an index.html, at the top level or in a single folder). Use either file or url.
urltextURL of a live website / PWA. The app loads this website. Use either file or url.
buildTypedebug | releasedebug (default): a single APK for testing. release: a ZIP with signed AAB + APK for Google Play.
appNametextName under the app icon. Default: short_name/name from manifest.json, else "My App".
appIdtextPackage ID like com.yourcompany.yourapp. Required for release builds. Never change it after publishing. Debug default: auto-generated.
versionNametextVersion shown to users, e.g. 1.2.0. Default: 1.0.
versionCodeintegerInternal version number (1 – 2100000000). Must be higher than the previous upload on Google Play. Default: 1.
notificationIconauto | none | fileauto (default): white silhouette generated from the app icon. none: Capacitor default icon. Or upload an image file (shape on transparent background).
keystorefileRelease only: your existing upload keystore (.jks, .keystore, .p12). If omitted, a new key is generated.
keystorePasswordtextRelease with keystore: keystore password (required).
keyAliastextRelease with keystore: key alias. Optional if the keystore contains only one key.
keyPasswordtextRelease with keystore: key password, if different from the keystore password.
keyOrganizationtextRelease without keystore: organization / developer name for the new key's certificate (optional).
keyCountrytextRelease without keystore: two-letter country code for the certificate, e.g. DE (optional).

Icons and colors come from your manifest.json: the largest icon becomes the launcher icon (scaled into the adaptive icon safe zone, on its own edge color or background_color), theme_color becomes the notification accent color.

Signing keys

Google Play uses Play App Signing: Google keeps the key that signs the app on users' devices, and you sign every upload with your own upload key. All uploads of an app must use the same upload key.

  • Generate a new key (no keystore field): the builder creates an RSA 2048 key valid for 30 years and puts upload-key.jks and KEY-INFO.txt (passwords, alias, fingerprints) into the ZIP.
  • Use an existing key: upload keystore with keystorePassword (and keyAlias / keyPassword if needed). Use this for all updates and if your company already has an upload key. Wrong passwords or aliases are reported before the build starts.
  • Lost the key? With Play App Signing (default for new apps), you can request an upload key reset in the Play Console under Setup > App signing.
  • Privacy: uploaded and generated keys only exist on the server during the build and inside the result ZIP. The result is deleted when you call DELETE (the web interface does this automatically after the download) or after 30 minutes at the latest.
Store upload-key.jks and KEY-INFO.txt in a password manager or company vault. Anyone with the key and password can sign updates for your app.

Versions and updates

  • versionCode is a whole number that must increase with every upload to Google Play (e.g. 1, 2, 3 or a date like 20261109).
  • versionName is the version users see (e.g. 1.0.0, 1.1.0).
  • Keep appId exactly the same for every update, otherwise Android and Google Play treat it as a different app.
  • Sideloaded APKs can only be updated by an APK signed with the same key. A release APK and the Play Store version are signed differently (Google re-signs Play apps), so they cannot update each other.
# Update: same appId, higher versionCode, your key
curl -F file=@project.zip -F buildType=release -F appId=com.yourcompany.dailymaze \
     -F versionName=1.1.0 -F versionCode=2 \
     -F keystore=@upload-key.jks -F keystorePassword='YOUR_PASSWORD' -F keyAlias=upload \
     -o release.zip https://finnfreitag.com/tools/pwabuilder/api

Notification icon

Android displays the small notification icon as a single-color silhouette. A colorful icon would show up as a grey square, so the builder creates a proper icon for notifications sent with AppNative.js.

  • auto (default): transparent icons use their shape; opaque icons use everything that differs from the background color.
  • Upload an image as notificationIcon for full control: a simple shape (any color) on a transparent background, PNG or SVG.
  • none: use Capacitor's default icon.
curl -F file=@project.zip -F notificationIcon=@silhouette.png -o app.apk https://finnfreitag.com/tools/pwabuilder/api

Release ZIP contents

FileDescription
AppName-1.0.0.aabSigned Android App Bundle. Upload it in the Play Console.
AppName-1.0.0.apkSigned APK for direct installation, your website or other stores.
README.txtPackage ID, versions, certificate fingerprints (SHA-1 / SHA-256) and next steps.
upload-key.jksOnly for generated keys: your new upload key (PKCS12).
KEY-INFO.txtOnly for generated keys: passwords, alias and a curl snippet for updates.

API

Base URL: https://finnfreitag.com/tools/pwabuilder/api. All build requests are multipart/form-data with the parameters above. Errors are returned as JSON {"error": "..."} with status 400 (invalid input), 409 (not ready), 404 (unknown/expired job), 503 (queue full) or 500 (build failed, with log).

MethodPathDescription
POST/tools/pwabuilder/apiSynchronous: queue a build, wait and return the file (.apk or .zip) directly. Waits up to 9 minutes.
POST/tools/pwabuilder/api/jobsAsynchronous: queue a build and return the job status (202) immediately.
GET/tools/pwabuilder/api/jobs/{id}Job status: queued (with position), building (with current step), done or failed.
GET/tools/pwabuilder/api/jobs/{id}/downloadDownload the result once the status is done (409 before that).
DELETE/tools/pwabuilder/api/jobs/{id}Cancel a queued job, or delete a finished result (incl. generated keys) from the server.
GET/tools/pwabuilder/api/queueBuilder load: is a build running, how many are waiting, estimated wait.

Queue

Only one build runs at a time; further requests wait in a first-in-first-out queue. A build takes about 1–3 minutes. The job id is a random secret: only you can query and download your job. Results are kept for 30 minutes.

Synchronous (simplest)

One request, the response is the file. Good for scripts when the queue is short. If the build takes longer than 9 minutes (including waiting), you get a 504 with the jobId and can continue with the asynchronous endpoints.

curl --fail-with-body --max-time 600 \
  -F file=@project.zip -F buildType=release -F appId=com.yourcompany.app \
  -o release.zip https://finnfreitag.com/tools/pwabuilder/api

Asynchronous (recommended for automation)

curl -F file=@project.zip -F buildType=debug https://finnfreitag.com/tools/pwabuilder/api/jobs

Response (202), and the same format for GET /api/jobs/{id}:

{
  "id": "3f6c0e8e-6a58-4f7e-9d0a-2b7f8f1d2c11",
  "status": "queued",            // queued | building | done | failed
  "buildType": "debug",
  "position": 2,                 // place in the queue while queued, else null
  "queueLength": 3,
  "step": "Waiting in queue",    // current build step while building
  "estimatedWaitSeconds": 140,
  "warnings": [],                // e.g. missing icon
  "error": null,                 // message if failed
  "log": null,                   // last lines of the build log if failed
  "fileName": null,              // set when done
  "createdAt": "2026-11-09T10:00:00.000Z",
  "startedAt": null,
  "finishedAt": null,
  "expiresAt": null,             // when the result is deleted
  "statusUrl": "/tools/pwabuilder/api/jobs/3f6c0e8e-...",
  "downloadUrl": "/tools/pwabuilder/api/jobs/3f6c0e8e-.../download"
}

Bash script

#!/usr/bin/env bash
set -euo pipefail
API=https://finnfreitag.com/tools/pwabuilder/api

JOB=$(curl -sf -F file=@project.zip -F buildType=release \
  -F appId=com.yourcompany.app -F versionCode="$VERSION_CODE" -F versionName="$VERSION_NAME" \
  -F keystore=@upload-key.jks -F keystorePassword="$KEYSTORE_PASSWORD" \
  "$API/jobs" | jq -r .id)

while true; do
  STATUS=$(curl -sf "$API/jobs/$JOB")
  STATE=$(echo "$STATUS" | jq -r .status)
  echo "$(echo "$STATUS" | jq -r '"\(.status) \(.position // "") \(.step)"')"
  [ "$STATE" = "done" ] && break
  [ "$STATE" = "failed" ] && { echo "$STATUS" | jq -r '.error, .log'; exit 1; }
  sleep 5
done

curl -sf -o release.zip "$API/jobs/$JOB/download"
curl -sf -X DELETE "$API/jobs/$JOB" > /dev/null   # remove the result from the server
unzip -o release.zip -d release

GitHub Actions

Store the keystore as a base64 secret (base64 -w0 upload-key.jks) and the password as a second secret.

name: Build Android release
on:
  push:
    tags: ["v*"]

jobs:
  android:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Package web app
        run: cd dist && zip -r ../project.zip .
      - name: Build signed AAB + APK
        env:
          KEYSTORE_B64: ${{ secrets.UPLOAD_KEYSTORE_B64 }}
          KEYSTORE_PASSWORD: ${{ secrets.UPLOAD_KEYSTORE_PASSWORD }}
        run: |
          echo "$KEYSTORE_B64" | base64 -d > upload-key.jks
          curl --fail-with-body --max-time 600 \
            -F file=@project.zip -F buildType=release \
            -F appId=com.yourcompany.app \
            -F versionName="${GITHUB_REF_NAME#v}" -F versionCode="${{ github.run_number }}" \
            -F keystore=@upload-key.jks -F keystorePassword="$KEYSTORE_PASSWORD" \
            -o release.zip https://finnfreitag.com/tools/pwabuilder/api
          unzip release.zip -d release
      - uses: actions/upload-artifact@v4
        with:
          name: android-release
          path: release/

Publishing on Google Play: checklist

  • Create a developer account in the Play Console (one-time fee). New personal accounts must run a closed test with at least 12 testers for 14 days before going to production.
  • Create the app and keep Play App Signing enabled. Upload the .aab from the release ZIP.
  • Store listing: description, 512×512 icon, 1024×500 feature graphic, at least 2 phone screenshots.
  • Privacy policy URL (required), Data safety form, content rating questionnaire, target audience, ads declaration.
  • Every update: same appId, higher versionCode, same upload key.
  • URL mode: Google may reject apps that only show a website ("minimum functionality" policy). Apps built from a ZIP with your web app included work offline and have a much better chance. Use AppNative.js to add native features like notifications, sharing and vibration.
  • Target API level: Google Play requires a recent Android target. The builder always targets the newest version supported by Capacitor (currently Android 16, API 36).

Limits & tips

  • Maximum upload size: 300 MB. Maximum queue length: 30 jobs (status 503 if full, retry later).
  • Results (and generated keys) are deleted after 30 minutes or when you call DELETE.
  • Use relative paths in your HTML (script.js, not /script.js) and include all assets in the ZIP.
  • Debug APKs keep the same debug signature over time, so a newer debug APK with the same package ID installs as an update.
  • Problems? Failed jobs include the last lines of the build log in log.
Support me | Give Feedback
Impressum/Imprint | Privacy Policy | Copyright © Finn Freitag 2024 - 2026, All rights reserved. | Other copyright notices