# Uploads

Source: https://phonebox.dev/docs/api-reference/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](/docs/api-reference/apps#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 [#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:

```bash
curl -X POST https://phonebox.dev/v1/apps/uploads \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

```json title="Response: new upload"
{
  "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](#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-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`:

```bash
curl -X POST "$UPLOAD_URL" \
  -H "Content-Type: application/vnd.android.package-archive" \
  --data-binary @app/build/outputs/apk/debug/app-debug.apk
```

```json
{ "storageId": "kg28ph47rkkczabaeeqf536a5n8faqa5" }
```

## Complete an upload [#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](#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. |

```json title="POST /v1/apps/uploads/{id}/complete"
{
  "storage_id": "kg28ph47rkkczabaeeqf536a5n8faqa5"
}
```

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

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

```bash
curl "https://phonebox.dev/v1/apps/uploads/upl_q3m7x2k6v4ta?wait=ready" \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

```json title="Response: upload"
{
  "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 [#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 [#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.

```bash
curl https://phonebox.dev/v1/apps/uploads \
  -H "Authorization: Bearer $PHONEBOX_API_KEY"
```

```json title="Response: uploads"
{
  "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-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.

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