Install an APK
Install your own app on a device by dragging it into the dashboard, or over the API with a presigned upload, a completion check and an install request.
You can install any APK up to 300 MB on a running device. Files go straight from you to storage through a presigned URL, never through the API server, and the device downloads them from there.
From Claude Code, Cursor or Codex#
The MCP tool device_install_apk does the whole flow from a local file path and waits up to 5 minutes for the result:
Install ./app/build/outputs/apk/debug/app-debug.apk on checkout-test, then open the app and take a screenshot.
In the dashboard#
Open a running device and drag the .apk file onto the page. You see upload progress, then Verifying upload, then the install. When the device reports back you get a toast with the package name, for example Installed com.example.shop, or the installer's error.
Over the API#
The flow has five calls:
POST /v1/uploadsdeclares the file and returns a presignedput_url.PUTthe file bytes toput_url.POST /v1/uploads/:id/completeconfirms the file arrived.POST /v1/devices/:id/appsinstalls it on a running device.GET /v1/devices/:id/apps/:install_idtells you when it is installed.
An upload can be installed on as many devices as you like for 7 days, after which it is deleted.
1. Create the upload#
FILE=app-release.apk
SIZE=$(wc -c < "$FILE" | tr -d ' ')
curl -s -X POST https://akaphone.abhgnx-417.workers.dev/v1/uploads \
-H "Authorization: Bearer $AKAPHONE_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"filename\": \"$FILE\", \"size_bytes\": $SIZE, \"content_type\": \"application/vnd.android.package-archive\"}"{
"upload": { "id": "upl_01m5vqsywgw57haqkjnexz", "filename": "app-release.apk", "size_bytes": 48211337, "status": "pending" },
"put_url": "https://<account>.r2.cloudflarestorage.com/akaphone-artifacts/apks/usr_01m5vqsywg3mxj7dyxv9q9/upl_01m5vqsywgw57haqkjnexz/app-release.apk?X-Amz-Algorithm=AWS4-HMAC-SHA256&...",
"headers": { "content-type": "application/vnd.android.package-archive" },
"expires_in": 900
}Rules for the request:
filename: 5 to 128 characters, letters, digits,.,_,-and spaces, starting with a letter or digit and ending in.apk. Spaces become_..aabbundles are rejected with400.size_bytes: the exact size of the file in bytes, at most 314,572,800 (300 MB).content_type: optional, defaults toapplication/vnd.android.package-archive.
2. Upload the bytes#
PUT the file to put_url within expires_in seconds (15 minutes). Send every header from headers, and send exactly size_bytes bytes: the URL is signed for that content type and that length, and storage rejects anything else.
curl -s -X PUT "$PUT_URL" \
-H "Content-Type: application/vnd.android.package-archive" \
--data-binary @"$FILE"Do not send your Authorization header to put_url; the signature in the URL is the authorization.
3. Complete#
curl -s -X POST https://akaphone.abhgnx-417.workers.dev/v1/uploads/$UPLOAD_ID/complete \
-H "Authorization: Bearer $AKAPHONE_API_KEY"{ "upload": { "id": "upl_01m5vqsywgw57haqkjnexz", "status": "uploaded" } }AkaPhone checks that the object exists in storage. If you call this before the PUT has finished you get 409 conflict (Object not found in storage). Calling it again after success is harmless.
4. Install#
curl -s -X POST https://akaphone.abhgnx-417.workers.dev/v1/devices/$DEVICE_ID/apps \
-H "Authorization: Bearer $AKAPHONE_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"upload_id\": \"$UPLOAD_ID\"}"{ "install_id": "ins_01m5vqsywg99189k5xj7f5" }The response is 202: the device's host downloads the file and runs adb install -r -g, which replaces an existing install of the same package and grants all runtime permissions up front.
5. Wait for the result#
Poll GET /v1/devices/:id/apps/:install_id until status is installed or failed:
while :; do
INSTALL=$(curl -s https://akaphone.abhgnx-417.workers.dev/v1/devices/$DEVICE_ID/apps/$INSTALL_ID -H "Authorization: Bearer $AKAPHONE_API_KEY")
case $(echo "$INSTALL" | jq -r .status) in installed|failed) break;; esac
sleep 3
done
echo "$INSTALL"{ "install_id": "ins_01m5vqsywg99189k5xj7f5", "status": "installed", "package": "com.example.shop", "created_at": "2026-10-26T09:20:00.000Z", "finished_at": "2026-10-26T09:20:06.000Z" }On failure error holds the installer's message. An install still pending after 15 minutes fails with timeout. The same result is also pushed as an app.installed event to anyone watching the live stream.
Everything in one script#
#!/usr/bin/env bash
set -euo pipefail
API=https://akaphone.abhgnx-417.workers.dev
AUTH="Authorization: Bearer $AKAPHONE_API_KEY"
FILE=${1:-app-release.apk}
DEVICE_ID=$2
SIZE=$(wc -c < "$FILE" | tr -d ' ')
UP=$(curl -sf -X POST "$API/v1/uploads" -H "$AUTH" -H "Content-Type: application/json" \
-d "{\"filename\": \"$(basename "$FILE")\", \"size_bytes\": $SIZE}")
UPLOAD_ID=$(echo "$UP" | jq -r .upload.id)
PUT_URL=$(echo "$UP" | jq -r .put_url)
CTYPE=$(echo "$UP" | jq -r '.headers["content-type"]')
curl -sf -X PUT "$PUT_URL" -H "Content-Type: $CTYPE" --data-binary @"$FILE"
curl -sf -X POST "$API/v1/uploads/$UPLOAD_ID/complete" -H "$AUTH" > /dev/null
INSTALL_ID=$(curl -sf -X POST "$API/v1/devices/$DEVICE_ID/apps" -H "$AUTH" -H "Content-Type: application/json" \
-d "{\"upload_id\": \"$UPLOAD_ID\"}" | jq -r .install_id)
for i in $(seq 1 100); do
STATUS=$(curl -sf "$API/v1/devices/$DEVICE_ID/apps/$INSTALL_ID" -H "$AUTH" | jq -r .status)
[ "$STATUS" != pending ] && break
sleep 3
done
echo "install: $STATUS"
[ "$STATUS" = installed ]import { readFile } from "node:fs/promises";
import { basename } from "node:path";
const API = "https://akaphone.abhgnx-417.workers.dev";
const auth = { Authorization: `Bearer ${process.env.AKAPHONE_API_KEY}` };
async function installApk(path: string, deviceId: string) {
const bytes = await readFile(path);
const up = await fetch(`${API}/v1/uploads`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({ filename: basename(path), size_bytes: bytes.length }),
}).then((r) => r.json());
const put = await fetch(up.put_url, { method: "PUT", headers: up.headers, body: bytes });
if (!put.ok) throw new Error(`upload failed: ${put.status}`);
await fetch(`${API}/v1/uploads/${up.upload.id}/complete`, { method: "POST", headers: auth });
const res = await fetch(`${API}/v1/devices/${deviceId}/apps`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({ upload_id: up.upload.id }),
});
const { install_id } = await res.json();
for (let i = 0; i < 100; i++) {
const inst = await fetch(`${API}/v1/devices/${deviceId}/apps/${install_id}`, { headers: auth }).then((r) => r.json());
if (inst.status !== "pending") return inst; // { status: "installed" | "failed", package?, error? }
await new Promise((r) => setTimeout(r, 3000));
}
throw new Error("install still pending");
}import os, time, requests
API = "https://akaphone.abhgnx-417.workers.dev"
AUTH = {"Authorization": f"Bearer {os.environ['AKAPHONE_API_KEY']}"}
def install_apk(path: str, device_id: str) -> dict:
size = os.path.getsize(path)
up = requests.post(f"{API}/v1/uploads", headers=AUTH,
json={"filename": os.path.basename(path), "size_bytes": size}).json()
with open(path, "rb") as f:
requests.put(up["put_url"], headers=up["headers"], data=f).raise_for_status()
requests.post(f"{API}/v1/uploads/{up['upload']['id']}/complete", headers=AUTH).raise_for_status()
r = requests.post(f"{API}/v1/devices/{device_id}/apps", headers=AUTH,
json={"upload_id": up["upload"]["id"]})
r.raise_for_status()
install_id = r.json()["install_id"]
for _ in range(100):
inst = requests.get(f"{API}/v1/devices/{device_id}/apps/{install_id}", headers=AUTH).json()
if inst["status"] != "pending":
return inst # {"status": "installed" | "failed", "package"?, "error"?}
time.sleep(3)
raise TimeoutError("install still pending")Notes and limits#
- APKs only. Devices install with
adb install, which cannot install an Android App Bundle, so uploads of.aabfiles are rejected. Build a universal APK from your bundle (for example withbundletool build-apks --mode=universal) and upload that. - No Google Play Services. Devices are AOSP builds. Apps that require Play Services or Play Integrity may install but fail at runtime.
- ABI. Devices run on x86_64 hosts. Apps that ship only ARM native libraries may fail to install with
INSTALL_FAILED_NO_MATCHING_ABIS; include an x86_64 build or a universal APK. - Errors.
404 not_foundfor an unknown upload,409 conflictif the upload is not complete or the device is not running,400 invalid_requestfor a bad file name or size.
Endpoint reference: Uploads API and Devices API › Install an app.