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.
.aab (upload to Play), a signed .apk and your new upload key.# 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| Debug | Release | |
|---|---|---|
| Output | One .apk | ZIP: .aab, .apk, README.txt, key files (if generated) |
| Signed with | The builder's debug key | Your upload key (generated or uploaded) |
| Google Play | Not accepted | Upload the .aab |
| Sideloading | Yes | Yes, with the signed .apk |
| Package ID | Optional (auto-generated) | Required |
| WebView debugging | Enabled (chrome://inspect) | Disabled |
| Exact notification times | Yes (USE_EXACT_ALARM) | Only if the user allows exact alarms, otherwise a few minutes inaccurate (Play policy) |
The same parameters are used by the web interface and the API (as multipart/form-data fields).
| Field | Type | Description |
|---|---|---|
file | file | A .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. |
url | text | URL of a live website / PWA. The app loads this website. Use either file or url. |
buildType | debug | release | debug (default): a single APK for testing. release: a ZIP with signed AAB + APK for Google Play. |
appName | text | Name under the app icon. Default: short_name/name from manifest.json, else "My App". |
appId | text | Package ID like com.yourcompany.yourapp. Required for release builds. Never change it after publishing. Debug default: auto-generated. |
versionName | text | Version shown to users, e.g. 1.2.0. Default: 1.0. |
versionCode | integer | Internal version number (1 – 2100000000). Must be higher than the previous upload on Google Play. Default: 1. |
notificationIcon | auto | none | file | auto (default): white silhouette generated from the app icon. none: Capacitor default icon. Or upload an image file (shape on transparent background). |
keystore | file | Release only: your existing upload keystore (.jks, .keystore, .p12). If omitted, a new key is generated. |
keystorePassword | text | Release with keystore: keystore password (required). |
keyAlias | text | Release with keystore: key alias. Optional if the keystore contains only one key. |
keyPassword | text | Release with keystore: key password, if different from the keystore password. |
keyOrganization | text | Release without keystore: organization / developer name for the new key's certificate (optional). |
keyCountry | text | Release 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.
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.
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.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.DELETE (the web interface does this automatically after the download) or after 30 minutes at the latest.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.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).appId exactly the same for every update, otherwise Android and Google Play treat it as a different app.# 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/apiAndroid 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.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| File | Description |
|---|---|
AppName-1.0.0.aab | Signed Android App Bundle. Upload it in the Play Console. |
AppName-1.0.0.apk | Signed APK for direct installation, your website or other stores. |
README.txt | Package ID, versions, certificate fingerprints (SHA-1 / SHA-256) and next steps. |
upload-key.jks | Only for generated keys: your new upload key (PKCS12). |
KEY-INFO.txt | Only for generated keys: passwords, alias and a curl snippet for updates. |
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).
| Method | Path | Description |
|---|---|---|
POST | /tools/pwabuilder/api | Synchronous: queue a build, wait and return the file (.apk or .zip) directly. Waits up to 9 minutes. |
POST | /tools/pwabuilder/api/jobs | Asynchronous: 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}/download | Download 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/queue | Builder load: is a build running, how many are waiting, estimated wait. |
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.
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/apicurl -F file=@project.zip -F buildType=debug https://finnfreitag.com/tools/pwabuilder/api/jobsResponse (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"
}#!/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 releaseStore 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/.aab from the release ZIP.appId, higher versionCode, same upload key.DELETE.script.js, not /script.js) and include all assets in the ZIP.log.