Private APK lifecycle with the API
A private APK is uploaded once and then delivered through a policy. This guide covers the three parts of its life:
- Upload the APK. Three calls, whatever its size; AndroidNexus reads the app's identity from the file.
- Install it on a policy's devices for the first time, by adding it to the policy. This works before any device has enrolled.
- Update it with a staged rollout: a new version reaches a few devices first, and each stage waits for installs to succeed before the next one starts.
Private APKs are installed by the AndroidNexus Companion App. The target policy must use target_deployment_mode: fully_managed or target_deployment_mode: dedicated and must contain configuration.companion_app.enabled: true. You can create this policy and add private APKs before a factory-reset device enrolls; no device record, installed status, or linked status is required to prepare or deploy the policy. After enrollment, use the device status as a verification signal: the companion should become installed and linked before it installs the private APKs.
Before you start
Create an API key whose creator holds androidnexus.content:read, androidnexus.content:write, androidnexus.policies:read, androidnexus.policies:write, and androidnexus.devices:read. Uploading needs content:write; rollouts need policies:read to watch and policies:write to create and act on them. Keep the key in a secret manager and expose it only to the current shell:
export ANDROIDNEXUS_API_KEY='nxk_...'
export BASE_URL='https://api.androidnexus.ai/api/v1'
export APK_PATH='./app-release.apk'
export POLICY_ID='your-policy-uuid'
The examples use curl and jq. Every successful response wraps its payload in data; every refusal carries a stable error.code, a message for a person, and sometimes error.details (see the API introduction).
Sign every version of an app with the same release key, and give each new version a strictly higher versionCode.
1. Upload the APK
Start an upload session with purpose: "private_apk". APKs may be up to 200 MB:
FILE_SIZE=$(wc -c < "$APK_PATH" | tr -d ' ')
UPLOAD=$(curl --fail-with-body --silent --show-error \
-X POST "$BASE_URL/uploads" \
-H "Authorization: Bearer $ANDROIDNEXUS_API_KEY" \
-H 'Content-Type: application/json' \
--data "$(jq -n --arg filename "$(basename "$APK_PATH")" --argjson size "$FILE_SIZE" \
'{total_size:$size, purpose:"private_apk", filename:$filename}')")
SESSION_ID=$(jq -r '.data.session_id' <<< "$UPLOAD")
CHUNK_SIZE=$(jq -r '.data.chunk_size_bytes' <<< "$UPLOAD")
TOTAL_CHUNKS=$(jq -r '.data.total_chunks' <<< "$UPLOAD")
You may also send the file's hex SHA-256 as sha256. AndroidNexus always computes the digest itself; a declared one only adds a check that the bytes that arrived are the bytes you meant to send.
Send the file in chunks of chunk_size_bytes, numbered from 1. Every chunk except the last must be exactly chunk_size_bytes long; the server works out each chunk's byte range from its number, so no Content-Range header is needed. A file smaller than one chunk is a single request:
for ((n=1; n<=TOTAL_CHUNKS; n++)); do
dd if="$APK_PATH" bs="$CHUNK_SIZE" skip=$((n - 1)) count=1 status=none | \
curl --fail-with-body --silent --show-error \
-X PUT "$BASE_URL/uploads/$SESSION_ID/chunks/$n" \
-H "Authorization: Bearer $ANDROIDNEXUS_API_KEY" \
-H 'Content-Type: application/octet-stream' \
--data-binary @- > /dev/null
done
Complete the upload. The request needs no body:
COMPLETE=$(curl --fail-with-body --silent --show-error \
-X POST "$BASE_URL/uploads/$SESSION_ID/complete" \
-H "Authorization: Bearer $ANDROIDNEXUS_API_KEY")
CONTENT_ID=$(jq -r '.data.content.content_id' <<< "$COMPLETE")
jq '.data.content' <<< "$COMPLETE"
AndroidNexus reads the APK and answers the private app it created under content:
{
"content_id": "550e8400-e29b-41d4-a716-446655440000",
"package_name": "com.acme.kiosk",
"version_code": 42,
"version_name": "4.2.0",
"label": "Acme Kiosk",
"signing_fingerprint": "P46OViV4SmY62Yk6OHen98I9gZv3aUoNgNUpiGJusd4=",
"min_sdk": 26,
"sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"size_bytes": 1234567,
"created": true
}
- The package name, version, label, and signing certificate come from the APK itself, so there is nothing to declare and nothing that can disagree with the file.
- Uploading the same bytes again answers the app that already holds them, with
created: false. Completing the same session twice answers the same app, so a retriedcompleteis safe. - A refused file answers
400withSHA256_MISMATCH(the bytes differ from a declaredsha256),APK_UNREADABLE(not a readable APK), orAPK_UNSIGNED(no signature). A refused upload is discarded: start a new session.
If a transfer is interrupted, call GET /uploads/{session_id}/status and resend only the chunks listed in missing_chunks. DELETE /uploads/{session_id} abandons a session and discards what it received.
2. Install the app for the first time
To put a new app on a policy's devices, add it to the policy. This also covers devices that have not enrolled yet: they install the app as they enroll.
First GET /policies/{policy_id} and preserve its entire existing configuration. Add the content ID to configuration.companion_app.custom_app_content_ids and set enabled to true:
{
"configuration": {
"companion_app": {
"enabled": true,
"custom_app_content_ids": ["550e8400-e29b-41d4-a716-446655440000"],
"wallpaper_url": ""
}
}
}
Do not add a private APK to configuration.applications. AndroidNexus derives the app's install declaration, signing certificate, and download from custom_app_content_ids.
PUT /policies/{id} saves the policy configuration you send. Merge these fields into the current configuration; do not replace unrelated applications, kiosk settings, restrictions, networks, or managed configurations.
Save, then deploy. Saving alone does not push the policy to devices:
curl --fail-with-body --silent --show-error \
-X PUT "$BASE_URL/policies/$POLICY_ID" \
-H "Authorization: Bearer $ANDROIDNEXUS_API_KEY" \
-H 'Content-Type: application/json' \
--data @updated-policy.json
curl --fail-with-body --silent --show-error \
-X POST "$BASE_URL/policies/$POLICY_ID/deploy" \
-H "Authorization: Bearer $ANDROIDNEXUS_API_KEY" \
-H 'Content-Type: application/json' \
--data '{}'
This installs the app on every device on the policy at once. To ship a later version, use a staged rollout.
3. Roll out a new version in stages
Upload the new version exactly as in step 1: the same package name and signing key, and a higher versionCode. Then roll it out to the devices on the policy.
A rollout does its own policy work. Starting it adds the new version to the policy and pushes the policy to devices, so you do not edit or deploy the policy yourself. Devices outside the stages reached so far keep the version they have.
Plan with a dry run
Describe the rollout and send it with dry_run: true. Nothing is stored or changed, and the answer shows how many devices each stage reaches:
cat > rollout.json <<EOF
{
"policy_id": "$POLICY_ID",
"content_id": "$CONTENT_ID",
"stages": [10, 50, 100],
"gates": { "min_success_percent": 95, "max_failure_percent": 5, "observe_minutes": 30 },
"advance": "automatic",
"target": { "all": true }
}
EOF
curl --fail-with-body --silent --show-error \
-X POST "$BASE_URL/app-rollouts" \
-H "Authorization: Bearer $ANDROIDNEXUS_API_KEY" \
-H 'Content-Type: application/json' \
--data "$(jq '. + {dry_run: true}' rollout.json)" \
| jq -r '.data.stages[] | "stage \(.number): \(.percent)% adds \(.device_count) devices"'
stage 1: 10% adds 3 devices
stage 2: 50% adds 12 devices
stage 3: 100% adds 15 devices
The request fields:
| Field | Meaning | Default |
|---|---|---|
policy_id, content_id | The policy whose devices receive the app, and the version to install. The policy must enable the Companion App. | Required |
stages | Cumulative percentages of the target devices, rising and ending at 100, at most 20 stages. An element may be an object, {"percent": 10, "min_success_percent": 90}, to give one stage its own gates. | Required |
gates | What a stage must show before the next one starts, in whole percents and minutes: at least min_success_percent of the stage's devices have a verified install, at most max_failure_percent failed, and observe_minutes have passed since the stage started. | 95, 5, 0 |
advance | automatic: AndroidNexus starts the next stage as soon as the gates pass. manual: you call advance. | automatic |
target | Exactly one of {"all": true}, {"tags": ["pilot"]} (devices carrying any of the tags), or {"device_ids": [...]}. Only enrolled devices on the policy are reached. | Every device on the policy |
start | Start the rollout in the same call. | false (a draft) |
dry_run | Validate and answer the plan without storing anything. | false |
name | A label for the rollout. | The app's label and version |
replaces_rollout_id | Create a forward fix for an earlier rollout (see Fix forward). | None |
Each stage's device_count is fixed when the rollout is created, from the devices on the policy at that moment. The same devices stay in the same stages for the rollout's whole life. A device that joins the policy later is not part of the rollout's stages. What it installs depends on the target:
- With
{"all": true}, the rollout's version becomes the policy's version once the rollout completes. A device that joins after that installs it, like the rest of the policy. A device that joins while the rollout is still running installs the version the policy had before (or nothing, if the rollout is the app's first delivery) until the rollout completes, and then installs the new version. - With
tagsordevice_ids, the rollout only ever reaches the devices it was created with. A device that joins later, even one carrying the tag, installs the version the policy had before the rollout (or nothing, if the rollout is the app's first delivery). Create a new rollout to reach it.
When a stage would reach no device
On a small fleet a low percentage can round down to no device at all. AndroidNexus refuses such a plan with EMPTY_STAGE rather than letting an empty stage pass instantly, and suggests the smallest percentages that give every stage at least one device. For three devices and [10, 50, 100]:
{
"status": "error",
"error": {
"code": "EMPTY_STAGE",
"message": "Stage 1 (10%) reaches 0 of 3 devices. Try [34, 67, 100].",
"details": {
"device_total": 3,
"stages": [
{ "number": 1, "percent": 10, "device_count": 0 },
{ "number": 2, "percent": 50, "device_count": 1 },
{ "number": 3, "percent": 100, "device_count": 2 }
],
"suggested_stages": [34, 67, 100]
}
}
}
Send the request again with details.suggested_stages as stages.
Other refusals worth handling when you create a rollout:
| Code | Status | What to do |
|---|---|---|
ROLLOUT_ALREADY_OPEN | 409 | A draft, running, or paused rollout already targets this app on the policy; details.rollout_id names it. Finish or cancel it first, or, to fix a paused rollout, name it in replaces_rollout_id (see Fix forward). |
REPLACED_ROLLOUT_RUNNING | 409 | The rollout named in replaces_rollout_id is still running; details.rollout carries it. Pause it, then send the request again. |
WOULD_DOWNGRADE | 409 | Some devices already run a newer version; details.devices lists them. Upload a higher version code. |
NO_TARGET_DEVICES, DEVICE_NOT_IN_POLICY | 409 | The target reaches no enrolled device on the policy, or names devices that are not on it (details.device_ids). |
POLICY_COMPANION_DISABLED | 409 | Enable configuration.companion_app on the policy. |
APP_NOT_FOUND, POLICY_NOT_FOUND | 404 | The content ID or policy ID does not exist in your organization. |
INVALID_STAGES, INVALID_GATES, INVALID_TARGET | 400 | The message names the field to correct. |
Create and start
Send the same request with start: true:
ROLLOUT_ID=$(curl --fail-with-body --silent --show-error \
-X POST "$BASE_URL/app-rollouts" \
-H "Authorization: Bearer $ANDROIDNEXUS_API_KEY" \
-H 'Content-Type: application/json' \
--data "$(jq '. + {start: true}' rollout.json)" \
| jq -r '.data.id')
The answer is 201 with the rollout running on stage 1. Without start, the rollout is created as a draft; start it later with POST /app-rollouts/{id}/start, or remove it with DELETE /app-rollouts/{id} while it has not started. If the rollout is created but cannot start, the answer is START_FAILED and error.details.rollout carries the draft, which you can start again.
Watch the rollout
GET /app-rollouts/{id} answers the whole rollout, evaluated at server_time. Every rollout call answers this same object. An abbreviated example, part way through stage 2:
{
"id": "7f1c2a8e-7c1e-4a53-9c0e-3b7b1f6d2a10",
"name": "Acme Kiosk 4.2.0",
"policy_id": "409002c6-974e-404f-b406-e00ebd7a937e",
"app": { "content_id": "550e8400-e29b-41d4-a716-446655440000", "package_name": "com.acme.kiosk", "version_code": 42, "version_name": "4.2.0" },
"status": "running",
"paused_reason": null,
"advance": "automatic",
"gates": { "min_success_percent": 95, "max_failure_percent": 5, "observe_minutes": 30 },
"current_stage": 2,
"stages": [
{ "number": 1, "percent": 10, "device_count": 3, "status": "completed",
"devices": { "installed": 3, "failed": 0, "in_progress": 0, "pending": 0, "cancelled": 0 } },
{ "number": 2, "percent": 50, "device_count": 12, "status": "active",
"started_at": "2026-09-30T10:00:00Z", "observe_until": "2026-09-30T10:30:00Z",
"devices": { "installed": 7, "failed": 0, "in_progress": 2, "pending": 3, "cancelled": 0 } },
{ "number": 3, "percent": 100, "device_count": 15, "status": "pending",
"devices": { "installed": 0, "failed": 0, "in_progress": 0, "pending": 15, "cancelled": 0 } }
],
"devices": { "total": 30, "targeted": 15, "picked_up": 12, "installed": 10, "failed": 0, "in_progress": 2, "pending": 18, "cancelled": 0 },
"gate": { "can_advance": false, "reason_code": "OBSERVING", "reason": "minimum observation window has 12m30s remaining", "observe_until": "2026-09-30T10:30:00Z" },
"next_action": "wait",
"failure_groups": [],
"replaces_rollout_id": null,
"server_time": "2026-09-30T10:17:30Z"
}
statusisdraft,running,paused,completed, orcancelled.paused_reasonsays why a paused rollout stopped:manualorfailure_threshold.- Each stage's
devicescounts only that stage's devices and adds up to itsdevice_count. The gates judge the active stage by these counts. The top-leveldevicescovers the whole rollout:targetedis the devices in the stages reached so far, andpicked_upis how many of those have started, installed, or failed. gatesays whether the active stage may advance now.reason_codeis stable for a script;reasonis for a person.failure_groupsgroups the failed devices by the error their Companion App reported, such asINSTALL_FAILED_OLDER_SDK.
Branch on next_action. It is the one field a script needs:
next_action | Meaning | What to do |
|---|---|---|
start | The rollout is a draft. | POST /app-rollouts/{id}/start |
wait | Nothing is required. Devices are installing or the observation window is open; with automatic advance, AndroidNexus starts the next stage when the gates pass. | Poll again. |
advance | Manual advance, and the active stage passes its gates. | POST /app-rollouts/{id}/advance |
retry_failed | The active stage cannot pass because devices failed. | Inspect the failed devices, then retry them. |
resume_or_cancel | The rollout is paused. | Resume, or cancel. |
none | The rollout is completed or cancelled. | Nothing. |
The gate.reason_code values:
reason_code | Meaning |
|---|---|
OBSERVING | The stage's observation window is open until gate.observe_until. |
WAITING_FOR_INSTALLS | Too few verified installs so far, but the devices still installing or pending could reach min_success_percent. |
SUCCESS_BELOW_MINIMUM | Even if every remaining device installs, the stage cannot reach min_success_percent. |
FAILURE_ABOVE_MAXIMUM | More than max_failure_percent of the stage's devices failed. Advancing pauses the rollout instead. |
NOT_RUNNING | The rollout is a draft, paused, completed, or cancelled. |
READY | Every gate passes. |
A polling loop that stops when the rollout needs a decision:
while true; do
ROLLOUT=$(curl --fail-with-body --silent --show-error "$BASE_URL/app-rollouts/$ROLLOUT_ID" \
-H "Authorization: Bearer $ANDROIDNEXUS_API_KEY")
jq -r '.data | "\(.status) stage \(.current_stage)/\(.stages | length) installed \(.devices.installed)/\(.devices.total) failed \(.devices.failed) \(.gate.reason_code) \(.gate.reason) next: \(.next_action)"' <<< "$ROLLOUT"
case "$(jq -r '.data.next_action' <<< "$ROLLOUT")" in
wait) sleep 15 ;;
*) break ;;
esac
done
Advance by hand
With advance: "manual", call POST /app-rollouts/{id}/advance when next_action is advance. It completes the active stage and starts the next one, or completes the rollout after the last stage.
- A stage its gate still holds is refused with
409 GATE_NOT_MET;error.details.rolloutcarries the rollout and itsgate. - When the stage's failure rate is above
max_failure_percent, the rollout is paused instead, and the answer is200withstatus: "paused"andpaused_reason: "failure_threshold". Checkstatusin the answer, not just the HTTP status.
With advance: "automatic", the same failure check applies: once the observation window has closed, a stage whose failure rate is above the maximum pauses the rollout with paused_reason: "failure_threshold".
Pause, resume, and cancel
POST /app-rollouts/{id}/pausepauses a running rollout (paused_reason: "manual"). No further stage starts; devices already released keep installing.POST /app-rollouts/{id}/resumeresumes a paused rollout, whatever paused it. After afailure_thresholdpause, retry or fix the failed devices first: an automatic rollout whose stage is still over the failure maximum pauses again.POST /app-rollouts/{id}/cancel, with an optional{"reason": "..."}recorded in the audit log, ends a draft, running, or paused rollout. No further device receives the version. A device that installed it keeps it, every other device gets the version the policy otherwise installs, and nothing is uninstalled. Cancelling frees the policy for a new rollout of the app.
An action the rollout's state does not allow is refused with 409 INVALID_TRANSITION, and error.details.rollout carries the rollout as it is now.
Inspect and retry failed devices
List the failed devices, with the error each one reported:
curl --fail-with-body --silent --show-error \
"$BASE_URL/app-rollouts/$ROLLOUT_ID/devices?status=failed&limit=100" \
-H "Authorization: Bearer $ANDROIDNEXUS_API_KEY" \
| jq -r '.data.devices[] | "\(.device_code // .device_id) stage \(.stage) \(.error_code): \(.error_message)"'
The device list filters by status (pending, downloading, installing, installed, failed, cancelled) and stage, and pages with limit and cursor: pass data.next_cursor as cursor until it is null. Add include=sources to see what the Companion App and the Android Management API each last reported for the app. For one device's full history, GET /app-rollouts/{id}/devices/{device_id}/timeline answers its latest install attempt and every event the Companion App reported for it.
Once the cause is fixed on the devices (for example, storage freed), give them a new attempt:
curl --fail-with-body --silent --show-error \
-X POST "$BASE_URL/app-rollouts/$ROLLOUT_ID/retry" \
-H "Authorization: Bearer $ANDROIDNEXUS_API_KEY" \
-H 'Content-Type: application/json' \
--data '{}' | jq '.data.retried'
With no device_ids, every failed device in the stages reached so far is retried. With {"device_ids": [...]}, exactly those devices are retried, and the whole request is refused with 409 DEVICE_NOT_RETRYABLE if any of them is not a failed device in a reached stage (error.details.devices says why for each). The answer is the rollout plus retried, the devices given a new attempt. Retrying works on a running or paused rollout and does not resume a paused one.
Fix forward with a newer version
A rollout is never rolled back in place. When a version is bad, pause its rollout if it is still running (the failure gate may already have paused it), build a fix with a higher versionCode signed with the same key, upload it, and create a rollout that names the old one:
curl --fail-with-body --silent --show-error \
-X POST "$BASE_URL/app-rollouts" \
-H "Authorization: Bearer $ANDROIDNEXUS_API_KEY" \
-H 'Content-Type: application/json' \
--data "$(jq -n --arg policy "$POLICY_ID" --arg content "$FIXED_CONTENT_ID" --arg replaces "$ROLLOUT_ID" \
'{policy_id:$policy, content_id:$content, replaces_rollout_id:$replaces, stages:[50, 100], start:true}')"
The fix reaches exactly the devices the replaced rollout had reached, so it takes no target. The replaced rollout must be paused, completed, or cancelled. A paused rollout is cancelled in the same request that creates the fix, so there is no separate cancel step; its audit entry records replaced by the fix's ID. A dry_run of the fix cancels nothing. A running rollout is refused with 409 REPLACED_ROLLOUT_RUNNING: pause it first. The replaced rollout must also be on the same policy, for the same package, and signed with the same key, and the fix's version code must be higher; otherwise the request is refused with 409 INVALID_REPLACEMENT, whose error.details.minimum_version_code gives the lowest acceptable version code.
If the replaced rollout targeted every device and completed, the fix covers every device it reached, and once the fix completes it becomes the policy's version, so devices that join later install the fix. Otherwise, when the fix's rollout completes, create an ordinary rollout of the fixed version to bring the rest of the policy's devices up to it.
Verify on a device
-
Confirm the deployed policy contains
companion_app.enabled: true:curl --fail-with-body --silent --show-error "$BASE_URL/policies/$POLICY_ID" \-H "Authorization: Bearer $ANDROIDNEXUS_API_KEY" \| jq '.data.policy.configuration.companion_app' -
After a device enrolls, confirm the companion becomes installed and linked. This is a post-enrollment verification step, not a prerequisite for creating or deploying the policy:
curl --fail-with-body --silent --show-error "$BASE_URL/devices/$DEVICE_ID" \-H "Authorization: Bearer $ANDROIDNEXUS_API_KEY" \| jq '.data | {companion_app: .integrations.companion_app, nexus_policy_id, nexus_policy_version, nexus_policy_version_pending}' -
Confirm the installed package and version code in device inventory:
curl --fail-with-body --silent --show-error "$BASE_URL/devices/$DEVICE_ID/applications" \-H "Authorization: Bearer $ANDROIDNEXUS_API_KEY" \| jq --arg package com.acme.kiosk '.data.applications[] | select(.package_name == $package)'
If the package is absent, first resolve companion readiness, then check that the device is a member of the expected policy, that the policy has been deployed (for a first install) or that the device is in a stage the rollout has reached (for an update), that the APK uses the expected signing certificate, and that its version code is higher than the installed version.