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.

On this page

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#

  1. Mint — POST /uploads with the build's metadata, size and SHA-256. Every check that can refuse before a byte moves runs here.
  2. PUT the file to uploadUrl, straight to storage, with no auth header. The URL expires at expiresAt.
  3. Complete — POST /uploads/{id}/complete. Answers 202; we then hash the stored file, quarantine it and queue the scan.
  4. Poll — GET /uploads/{id} every 10–30 seconds until status leaves verifying, scanning and publishing.
One release, end to end
bash
#!/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
done

POST /uploads#

FieldNotes
packageNameAn app of the key's developer.
versionNameAs in the manifest, up to 100 characters.
versionCodeMust match the manifest; a mismatch is rejected after the scan.
archAn ABI such as arm64-v8a, or universal.
minSdkThe manifest's minSdkVersion.
formatapk, xapk or apks.
fileSizeBytes. Over the console's upload limit is 413.
sha256Of the exact file you will PUT. We re-hash it and reject a difference.
certsOptional signer certificates, if your pipeline can read them; otherwise the scan reads them.
changelogOptional 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.

Request and response
{
  "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."
}

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.

Multipart
bash
# 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}#

statusMeaning
uploadingMinted; waiting for the file and complete.
verifyingWe are hashing the stored file.
scanningQueued for, or in, the malware and signature scan.
publishingPassed; being added to the catalogue.
publishedapkVersionId names the stored build.
heldWaiting for our review; reason says why.
rejectedRefused for good; code and message say why.
withdrawnWithdrawn from the console.
failedWe could not verify the file after several tries. Upload again.
expiredNever completed, or held too long.
200 response
JSON
{
  "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#

reasonWhat it means
first_uploadThis 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_mismatchThis 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_unknownNo signing certificate could be read from this build.
scan_flaggedThe malware scan flagged this build.
scan_unverifiedThe 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_failedThe security scan could not be completed.
app_blockedThis app is currently not distributed on APKCube, so an operator will review the build.
manual_reviewUploads 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.

idFires when
target-sdkThe build targets an API level below the one Google Play currently requires.
debuggableThe manifest sets android:debuggable="true".
no-arm64The build ships native code but none for arm64-v8a (a 32-bit split uploaded as its own architecture is exempt).
page-size-16kA 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-addedA sensitive permission is declared that the previous published version did not declare.
cleartext-trafficA note: the manifest allows unencrypted HTTP.
page-size-uncheckedA 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.

StatusCodeWhen
400VALIDATION_ERRORA malformed body or upload id. The message names the field.
401API_UNAUTHORIZEDMissing, unknown or revoked key.
403KEY_SCOPEA catalogue key was sent instead of a publishing key.
403APP_NOT_OWNEDThe 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.
403CONSOLE_CLOSEDThe Developers Console is switched off on our side.
403UPLOADS_CLOSEDUploads are switched off on our side.
404UPLOAD_NOT_FOUNDNo upload of this developer's by that id.
409APK_ALREADY_EXISTSThat version and architecture is already published or in progress.
409VERSION_CODE_TOO_LOWOlder than the newest version published.
409CLAIM_STALEThe Play listing's email changed since the claim was verified. Verify again in the console.
409UPLOAD_NOT_OPENcomplete was called on an upload that is no longer waiting for its file.
409UPLOAD_INCOMPLETEThe multipart uploadId isn't this upload's, or its parts couldn't be assembled. Start the upload again.
413APK_TOO_LARGELarger than the upload limit.
422DEBUG_SIGNEDSigned with the Android SDK debug key.
429DAILY_UPLOAD_LIMITThis developer has already started its daily limit of uploads in the last 24 hours.
429RATE_LIMITEDToo many calls in a minute. Back off and retry.
451SIGNATURE_BLOCKEDThe signing key is on our blocklist.
503UPSTREAM_UNAVAILABLESomething 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).