# 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.

Source: https://akaphone-docs.pages.dev/guides/install-apk/

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:

1. `POST /v1/uploads` declares the file and returns a presigned `put_url`.
2. `PUT` the file bytes to `put_url`.
3. `POST /v1/uploads/:id/complete` confirms the file arrived.
4. `POST /v1/devices/:id/apps` installs it on a running device.
5. `GET /v1/devices/:id/apps/:install_id` tells 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

```bash
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\"}"
```

```json
{
  "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 `_`. `.aab` bundles are rejected with `400`.
- `size_bytes`: the exact size of the file in bytes, at most 314,572,800 (300 MB).
- `content_type`: optional, defaults to `application/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.

```bash
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

```bash
curl -s -X POST https://akaphone.abhgnx-417.workers.dev/v1/uploads/$UPLOAD_ID/complete \
  -H "Authorization: Bearer $AKAPHONE_API_KEY"
```

```json
{ "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

```bash
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\"}"
```

```json
{ "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`:

```bash
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"
```

```json
{ "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](https://akaphone-docs.pages.dev/api/stream/#events).

## Everything in one script

```bash title="curl"
#!/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 ]
```
```typescript title="TypeScript"
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");
}
```
```python title="Python"
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 `.aab` files are rejected. Build a universal APK from your bundle (for example with `bundletool 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_found` for an unknown upload, `409 conflict` if the upload is not complete or the device is not running, `400 invalid_request` for a bad file name or size.

Endpoint reference: [Uploads API](https://akaphone-docs.pages.dev/api/uploads/) and [Devices API › Install an app](https://akaphone-docs.pages.dev/api/devices/#install-an-app).
