Developers Console
Publishing API
Upload your app's signed build from CI after every release. The build goes through the same checks as one uploaded in the Developers Console: we hash it ourselves, scan it, and compare its signing key with the one on record. Clean builds signed by a known key are published on their own; anything else waits for our review. Publishing calls are free and are not charged against API credits.
Publishing keys#
Create a key under Console → Publishing keys. It belongs to one developer, needs the admin or owner role to create, and can upload builds for that developer's apps and nothing else. Catalogue API keys from /account/api are refused here, and a publishing key is refused by the catalogue API (403 KEY_SCOPE).
Send it as a bearer token: Authorization: Bearer <key>. Each call also checks that the member who created the key is still on the team with at least the uploader role, so a member who leaves takes their keys' access with them.
Upload flow#
- Mint —
POST /uploadswith the build's metadata, size and SHA-256. Every check that can refuse before a byte moves runs here. - PUT the file to
uploadUrl, straight to storage, with no auth header. The URL expires atexpiresAt. - Complete —
POST /uploads/{id}/complete. Answers202; we then hash the stored file, quarantine it and queue the scan. - Poll —
GET /uploads/{id}every 10–30 seconds untilstatusleavesverifying,scanningandpublishing.
#!/usr/bin/env bash
set -euo pipefail
: "${APKCUBE_PUBLISH_KEY:?set APKCUBE_PUBLISH_KEY (Developers Console → Publishing keys)}"
BASE="https://apkcube.com/api/v1/console"
AUTH="Authorization: Bearer $APKCUBE_PUBLISH_KEY"
FILE=app-release.apk
SIZE=$(wc -c < "$FILE" | tr -d ' ')
SHA=$(shasum -a 256 "$FILE" | cut -d' ' -f1)
# 1. Mint the upload.
MINT=$(curl -sS -X POST "$BASE/uploads" -H "$AUTH" -H "Content-Type: application/json" -d @- <<JSON
{"packageName":"com.example.app","versionName":"2.4.1","versionCode":2041,
"arch":"universal","minSdk":24,"format":"apk","fileSize":$SIZE,"sha256":"$SHA"}
JSON
)
ID=$(echo "$MINT" | jq -r .upload.id)
URL=$(echo "$MINT" | jq -r .uploadUrl)
# 2. PUT the bytes straight to storage (no auth header: the URL is signed).
curl -sS -X PUT --upload-file "$FILE" "$URL"
# 3. Tell us it is up.
curl -sS -X POST "$BASE/uploads/$ID/complete" -H "$AUTH"
# 4. Poll until it settles.
while :; do
STATUS=$(curl -sS "$BASE/uploads/$ID" -H "$AUTH" | jq -r .upload.status)
echo "status: $STATUS"
case "$STATUS" in verifying|scanning|publishing|uploading) sleep 15 ;; *) break ;; esac
donePOST /uploads#
| Field | Notes |
|---|---|
packageName | An app of the key's developer. |
versionName | As in the manifest, up to 100 characters. |
versionCode | Must match the manifest; a mismatch is rejected after the scan. |
arch | An ABI such as arm64-v8a, or universal. |
minSdk | The manifest's minSdkVersion. |
format | apk, xapk or apks. |
fileSize | Bytes. Over the console's upload limit is 413. |
sha256 | Of the exact file you will PUT. We re-hash it and reject a difference. |
certs | Optional signer certificates, if your pipeline can read them; otherwise the scan reads them. |
changelog | Optional release notes, up to 4,000 characters. |
willHold: "cert_mismatch" means the certificates you sent are not the app's recorded key: the upload is allowed, and will be held.
{
"packageName": "com.example.app",
"versionName": "2.4.1",
"versionCode": 2041,
"arch": "universal",
"minSdk": 24,
"format": "apk",
"fileSize": 48213504,
"sha256": "9f2c…64 hex…",
"changelog": "Fixes the login crash on Android 15."
}{
"upload": { "id": 1834, "status": "uploading", … },
"uploadToken": "0b9c…",
"expiresAt": "2026-10-10T12:15:00.000Z",
"uploadUrl": "https://…r2.cloudflarestorage.com/…",
"willHold": "cert_mismatch"
}Large files#
From 128 MB the mint returns a multipart plan instead of an uploadUrl. PUT each part to its URL, collect the ETag response headers, and pass them to complete. The plan carries one spare URL, so stop when the file runs out. A failed part can be retried on its own URL.
# Files of 128 MB and more get a multipart plan instead of uploadUrl:
# "multipart": { "uploadId": "…", "partSize": 16777216, "urls": ["…", "…", "…"] }
# PUT byte range [i*partSize, (i+1)*partSize) to urls[i] for as many parts as
# the file needs (the last URL is a spare and may go unused), keep each ETag
# response header, then complete with the parts you sent:
curl -sS -X POST "$BASE/uploads/$ID/complete" -H "$AUTH" \
-H "Content-Type: application/json" \
-d '{"multipart":{"uploadId":"…","parts":[{"partNumber":1,"etag":"\"…\""}]}}'GET /uploads/{id}#
| status | Meaning |
|---|---|
uploading | Minted; waiting for the file and complete. |
verifying | We are hashing the stored file. |
scanning | Queued for, or in, the malware and signature scan. |
publishing | Passed; being added to the catalogue. |
published | apkVersionId names the stored build. |
held | Waiting for our review; reason says why. |
rejected | Refused for good; code and message say why. |
withdrawn | Withdrawn from the console. |
failed | We could not verify the file after several tries. Upload again. |
expired | Never completed, or held too long. |
{
"upload": {
"id": 1834,
"status": "held",
"reason": "cert_mismatch",
"code": "SIGNATURE_MISMATCH",
"message": "This build is signed by a key that is not on record…",
"verdict": "verified",
"certMatch": "mismatch",
"sha256": "9f2c…",
"apkVersionId": null,
"checks": [{ "id": "page-size-16k", "severity": "warn", "title": "Not ready for 16 KB pages", … }],
…
},
"scan": { "vt": { "malicious": 0, "total": 71, … }, "certs": [ … ], … }
}Why a build is held#
| reason | What it means |
|---|---|
first_upload | This is the first build we have seen for this app, so its signing key becomes the app's record once an operator approves it. |
cert_mismatch | This build is signed by a key that is not on record for this app. If you use Play App Signing, upload the signed universal APK from Play Console's App bundle explorer rather than a build signed with your upload key. |
cert_unknown | No signing certificate could be read from this build. |
scan_flagged | The malware scan flagged this build. |
scan_unverified | The malware scan could not vouch for this build (VirusTotal had not seen it, or it is larger than the scanner accepts), so an operator will review it. |
scan_failed | The security scan could not be completed. |
app_blocked | This app is currently not distributed on APKCube, so an operator will review the build. |
manual_review | Uploads are reviewed by an operator before they are published. |
Pre-release checks#
After the scan, an upload carries a checks array of things Google Play or a phone may hold against the build, each with an id, a severity (warn or info), a title, a detail and a doc link to the Android guide. They are warnings only: a check never holds, rejects or delays a build. From 1 February 2027, Google Play refuses updates without 16 KB page support for apps targeting API 35 or higher.
| id | Fires when |
|---|---|
target-sdk | The build targets an API level below the one Google Play currently requires. |
debuggable | The manifest sets android:debuggable="true". |
no-arm64 | The build ships native code but none for arm64-v8a (a 32-bit split uploaded as its own architecture is exempt). |
page-size-16k | A 64-bit native library is built with ELF segments aligned below 16 KB, or is stored uncompressed at an offset not aligned to 16 KB. The libraries are named. |
sensitive-permissions-added | A sensitive permission is declared that the previous published version did not declare. |
cleartext-traffic | A note: the manifest allows unencrypted HTTP. |
page-size-unchecked | A note: the build ships native libraries our scan has not yet checked for 16 KB support. |
Errors#
Errors use the API's usual envelope: {"error":{"code":"…","message":"…"}}. A held or rejected upload carries the same codes in its code field, so a pipeline can handle an outcome one way whether it learnt it at mint or after the scan.
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | A malformed body or upload id. The message names the field. |
| 401 | API_UNAUTHORIZED | Missing, unknown or revoked key. |
| 403 | KEY_SCOPE | A catalogue key was sent instead of a publishing key. |
| 403 | APP_NOT_OWNED | The package is not one of this developer's apps, or the key's creator no longer has the uploader role. Polling an upload answers 404 UPLOAD_NOT_FOUND instead. |
| 403 | CONSOLE_CLOSED | The Developers Console is switched off on our side. |
| 403 | UPLOADS_CLOSED | Uploads are switched off on our side. |
| 404 | UPLOAD_NOT_FOUND | No upload of this developer's by that id. |
| 409 | APK_ALREADY_EXISTS | That version and architecture is already published or in progress. |
| 409 | VERSION_CODE_TOO_LOW | Older than the newest version published. |
| 409 | CLAIM_STALE | The Play listing's email changed since the claim was verified. Verify again in the console. |
| 409 | UPLOAD_NOT_OPEN | complete was called on an upload that is no longer waiting for its file. |
| 409 | UPLOAD_INCOMPLETE | The multipart uploadId isn't this upload's, or its parts couldn't be assembled. Start the upload again. |
| 413 | APK_TOO_LARGE | Larger than the upload limit. |
| 422 | DEBUG_SIGNED | Signed with the Android SDK debug key. |
| 429 | DAILY_UPLOAD_LIMIT | This developer has already started its daily limit of uploads in the last 24 hours. |
| 429 | RATE_LIMITED | Too many calls in a minute. Back off and retry. |
| 451 | SIGNATURE_BLOCKED | The signing key is on our blocklist. |
| 503 | UPSTREAM_UNAVAILABLE | Something on our side failed. Retry shortly. |
Two codes appear only on an upload's code field, after the scan, never as an HTTP error: SIGNATURE_MISMATCH (held: signed by a key not on record) and APK_NOT_SIGNED (held: no signing certificate could be read).