Uploads
Upload your own APK, such as the debug build your coding agent just made, so you can install it on any of your phones.
An upload holds one APK of yours. You create it, send the file to the one-off upload_url it gives you, and complete it. Phonebox then reads the APK's manifest and stores it, and once its status is ready, Install an app with {"upload": "upl_…"} puts it on a phone. The file never passes through /v1, so the API's 4 MB body limit doesn't apply.
Uploads belong to the project, and every key of the project can use them, except a key limited to certain phones: it sees and uses only the uploads it created itself, so the customers a shared project serves never see each other's builds.
The CLI and the SDK do all of it in one call: phonebox apps "$PHONE" install ./app-debug.apk, or phone.installApk("./app-debug.apk").
| Limit | Value |
|---|---|
| File size | 524,288,000 bytes (500 MiB), sent within 2 minutes |
| A project's uploads | 524,288,000 bytes (500 MiB) in all, counting uploads that are processing or ready |
| New uploads | 50 a project each UTC day, for a project with credits that isn't frozen |
| Upload URL | For one file, sent within an hour |
| Kept | 7 days after the upload's last install, or after its file arrived if it was never installed. Once a newer build of the same package is ready, an older upload is kept only 24 hours after its last install. |
| The same file again | Completing an upload with a file the project already has ready answers with that ready upload, which is kept 7 more days. The new upload becomes a duplicate that names it. |
Create an upload
POST /v1/apps/uploads needs the phones:control scope.
Takes no body, or an empty JSON object. It answers 201 Created with the upload and where to send its file:
curl -X POST https://phonebox.dev/v1/apps/uploads \
-H "Authorization: Bearer $PHONEBOX_API_KEY"{
"object": "upload",
"id": "upl_q3m7x2k6v4ta",
"status": "awaiting_file",
"package": null,
"version_code": null,
"version_name": null,
"min_sdk": null,
"size_bytes": null,
"sha256": null,
"created_at": "2026-09-29T10:00:00.000Z",
"expires_at": "2026-09-29T11:00:00.000Z",
"error": null,
"upload_url": "https://phonebox-files.example/api/storage/upload?token=…",
"upload_method": "POST",
"upload_headers": { "Content-Type": "application/vnd.android.package-archive" },
"max_bytes": 524288000
}| Field | Type | Meaning |
|---|---|---|
upload_url | string | Where the file goes: send it one file, within an hour. It needs no API key, so keep it to yourself. |
upload_method | string | POST. |
upload_headers | object | Headers to send with the file. |
max_bytes | integer | The largest file it takes. |
The rest is the upload object.
| Error | When |
|---|---|
402 insufficient_credits | The project has no credits. Uploads need a funded project. |
402 project_frozen | The project is frozen. |
409 storage_limit_reached | The project's uploads already hold 500 MiB. Delete ones you no longer need. details.limit_bytes and details.used_bytes say how much. |
429 rate_limited | The project created 50 uploads today (UTC). Retry-After and details.retry_after_seconds say when it can create more. |
503 storage_full | Phonebox's app storage is full right now. Try again later. |
Send the file
Send the APK's bytes as the request body to upload_url, with upload_method and upload_headers. It answers with the file's storageId:
curl -X POST "$UPLOAD_URL" \
-H "Content-Type: application/vnd.android.package-archive" \
--data-binary @app/build/outputs/apk/debug/app-debug.apk{ "storageId": "kg28ph47rkkczabaeeqf536a5n8faqa5" }Complete an upload
POST /v1/apps/uploads/{id}/complete needs the phones:control scope.
Tells Phonebox the file has arrived. It answers 202 Accepted with the upload processing: Phonebox reads the APK's manifest and stores the APK, which takes a few seconds, and up to about a minute for a large one. Wait with Get an upload and ?wait=ready.
| Field | Type | Default | Rules |
|---|---|---|---|
storage_id | string | None | The storageId that upload_url answered with. The answer itself, {"storageId": "…"}, is taken as it is too. |
{
"storage_id": "kg28ph47rkkczabaeeqf536a5n8faqa5"
}curl https://phonebox.dev/v1/apps/uploads/upl_q3m7x2k6v4ta/complete \
-H "Authorization: Bearer $PHONEBOX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"storage_id": "kg28ph47rkkczabaeeqf536a5n8faqa5"}'Since complete takes the upload URL's answer as it is, one pipeline sends the file and completes the upload:
curl -sS -X POST "$UPLOAD_URL" -H "Content-Type: application/vnd.android.package-archive" \
--data-binary @app/build/outputs/apk/debug/app-debug.apk |
curl -sS https://phonebox.dev/v1/apps/uploads/upl_q3m7x2k6v4ta/complete \
-H "Authorization: Bearer $PHONEBOX_API_KEY" -H "Content-Type: application/json" -d @-A file the project already has ready answers 200 with that ready upload, not this one: install the upload in the answer. This upload becomes a duplicate that names it, in its error.next, and in upload_not_ready if you install it. A file over the size limit ends the upload at once as too_large, answered 200. Completing the same upload again with the same storage_id answers with the upload as it is.
| Error | When |
|---|---|
400 validation_failed | No file has this storage_id, it wasn't sent to this upload's upload_url, another upload has it, or this upload already has its file. details.issues says which. |
404 upload_not_found | No upload with this ID exists in the project. |
409 storage_limit_reached | This file would take the project's uploads past 500 MiB. The upload keeps its file: delete other uploads, then complete it again. |
503 storage_full | Phonebox's app storage is full right now. The upload keeps its file for the rest of its hour: complete it again later. |
Get an upload
GET /v1/apps/uploads/{id} needs the phones:read scope.
| Parameter | Type | Default | Rules |
|---|---|---|---|
wait | string | None | ready: wait while the upload is processing. It answers as soon as the upload is ready or has ended, and at once for an upload in any other status. |
timeout | integer | 50 | 1 to 55 seconds of waiting. |
curl "https://phonebox.dev/v1/apps/uploads/upl_q3m7x2k6v4ta?wait=ready" \
-H "Authorization: Bearer $PHONEBOX_API_KEY"{
"object": "upload",
"id": "upl_q3m7x2k6v4ta",
"status": "ready",
"package": "com.example.app",
"version_code": 42,
"version_name": "1.4.2",
"min_sdk": 26,
"size_bytes": 8388608,
"sha256": "3b6f0c1f3e0d8b2f2c9a8e1f7d4c5b6a9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b",
"created_at": "2026-09-29T10:00:00.000Z",
"expires_at": "2026-10-06T10:00:07.000Z",
"error": null
}Poll again only while status is processing.
The upload object
| Field | Type | Meaning |
|---|---|---|
object | string | upload. |
id | string | The upload's ID, upl_ and 12 characters. |
status | string | awaiting_file, processing, ready, invalid_apk, too_large, storage_full, failed, duplicate or deleted. |
package | string or null | The package the APK's manifest declares, once it has been read. An install by this upload installs this package. |
version_code | integer or null | The APK's versionCode. |
version_name | string or null | The APK's versionName. |
min_sdk | integer or null | The lowest Android API level the APK supports, when it says. |
size_bytes | integer or null | The file's size. |
sha256 | string or null | The file's SHA-256, in hex. |
created_at | timestamp | When the upload was created. |
expires_at | timestamp | When Phonebox deletes the upload: an hour after it was created while it waits for its file; then 7 days after its file arrived, and 7 days after each install; once a newer build of the same package is ready, 24 hours after its last install. |
error | object or null | Why the upload ended without becoming ready: {code, message, next}. |
| Status | Meaning | Next |
|---|---|---|
awaiting_file | Waiting for its file and a complete. | Send the file, then complete it. |
processing | Phonebox is reading and storing it. | Wait with ?wait=ready. |
ready | It can be installed. | Install it with {"upload": "upl_…"}. |
invalid_apk | The file isn't an APK Phonebox can install: error.next says why. | Build an APK and upload it. |
too_large | The file is over 500 MiB. error.code is payload_too_large. | Upload a smaller build. |
storage_full | Phonebox's app storage is full. | Try again later with a new upload. |
failed | Phonebox couldn't store the file. error.code is upload_failed, or platform_not_configured when Phonebox can't store apps at all right now. | Create a new upload and send the file again, later for platform_not_configured. |
duplicate | Its file is another upload of the project, which is ready. error.code is duplicate_upload, and error.next names that upload. | Install the upload error.next names. |
deleted | Deleted, or expired. | Upload the APK again. |
| Error | When |
|---|---|
400 validation_failed | wait or timeout is out of range. |
404 upload_not_found | No upload with this ID exists in the project. |
List uploads
GET /v1/apps/uploads needs the phones:read scope.
Lists the project's uploads, newest first, leaving out deleted ones. It takes limit (1 to 100, 50 by default) and cursor, as every list does.
curl https://phonebox.dev/v1/apps/uploads \
-H "Authorization: Bearer $PHONEBOX_API_KEY"{
"data": [
{
"object": "upload", "id": "upl_q3m7x2k6v4ta", "status": "ready", "package": "com.example.app", "version_code": 42,
"version_name": "1.4.2", "min_sdk": 26, "size_bytes": 8388608,
"sha256": "3b6f0c1f3e0d8b2f2c9a8e1f7d4c5b6a9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b",
"created_at": "2026-09-29T10:00:00.000Z", "expires_at": "2026-10-06T10:00:07.000Z", "error": null
}
],
"next_cursor": null
}Delete an upload
DELETE /v1/apps/uploads/{id} needs the phones:control scope.
Deletes the upload and the APK Phonebox stored for it. Apps already installed from it stay on their phones. It answers with the upload, status: "deleted"; deleting it again answers the same.
curl -X DELETE https://phonebox.dev/v1/apps/uploads/upl_q3m7x2k6v4ta \
-H "Authorization: Bearer $PHONEBOX_API_KEY"| Error | When |
|---|---|
404 upload_not_found | No upload with this ID exists in the project. |