These endpoints upload, list, and delete the iOS, Android, Apple TV, Android TV and Roku app builds you run on a device. Authentication, error shapes and rate limits are covered in the REST API overview.
External app identity
Integrations can send an optional multipart external_app_key to POST /api/v1/apps/upload. Keys are case-sensitive, 1–160 ASCII characters, and match [A-Za-z0-9][A-Za-z0-9._:-]{0,159}. Omitted keys keep ordinary upload behavior; empty, whitespace or invalid keys return 422. Send the key as a multipart field; a query parameter on this endpoint returns 422.
Each key identifies one app within your organization. Different keys create separate apps even when their real package identifier, platform and binary match. Reusing a key adds builds to that app; conflicting package identifiers or platforms return 409 without changing its identity. A Roku channel has no package identifier of its own, so it is matched by key and platform alone: renaming it keeps it on the same app. A package override cannot conceal a different identifier in the binary. The real package identifier remains the installation and launch identity; the key is not a credential.
Uploads without a key, package-based run fallback and YAML imports resolve only apps created without a key. Explicit app IDs and linked app families keep their existing behavior. Identical uploads deduplicate within the chosen app; deleting one app does not delete another app’s builds.
Upload, list and detail responses include nullable external_app_key. GET /api/v1/apps/?external_app_key=<key> returns zero or one exact match in your organization, and can be combined with the platform filter. Invalid filter keys return 422. Upload and app detail build metadata include content_hash alongside the returned build’s version and build number, including deduplicated uploads.
To recover after losing an upload response:
- List apps with the exact
external_app_keyto recover the app’spublic_id. - Get
/api/v1/apps/{public_id}and find the build whosecontent_hash,versionandbuild_numbermatch the upload. The hash is the SHA-256 of the uploaded file bytes, and the build’sidis its build identifier. - If the upload’s outcome is still unknown, retry the same file and key. An identical upload returns the existing app and build with
deduplicated: true.
Later uploads can use /api/v1/apps/{public_id}/builds to target that app directly. Its key stays unchanged; sending external_app_key on this endpoint or in app build/version metadata updates returns 422.
Upload App
Upload a new application binary. Supported formats: a compressed .app simulator bundle (.zip/.tar.gz/.tgz archive) for iOS and Apple TV, .apk for Android and Android TV, and a sideloadable channel .zip for Roku. Maximum file size: 500 MB. The file content is validated against expected magic bytes.
POST /api/v1/apps/upload
Auth: Bearer token required. Developer role or above.
Request: multipart/form-data
| Field | Type | Required | Description |
|---|---|---|---|
file | file | Yes | App binary (.zip/.tar.gz/.tgz iOS or Apple TV simulator bundle, .apk for Android or Android TV, channel .zip for Roku) |
name | string | No | App display name (auto-detected) |
package_name | string | No | Package identifier (auto-detected) |
external_app_key | string | No | Stable, case-sensitive app identity within your organization; see External app identity |
platform | string | No | Ignored: the platform is always detected from the file — ios, android, tvos, androidtv or roku |
note | string | No | Optional note stored on the new build (hard cap 500 chars; 200 recommended) |
Response:
{
"id": 1,
"public_id": "my-app-k3x9q2ab",
"name": "My App",
"package_name": "com.example.myapp",
"external_app_key": "project:my-app:android",
"platform": "android",
"icon_url": "/api/uploads/icons/0b7e1c2a-5d3f-4e8a-9c61-2f4d8a7b3e10_icon.png",
"created_at": "2025-01-15T10:30:00",
"is_new": true,
"build_id": 4812,
"version": "1.4.0",
"build_number": "57",
"content_hash": "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592",
"warnings": []
}
public_id is the app’s id everywhere else in the API: pass it as app_id (for example when you start a session) and use it in /api/v1/apps/{app_id} paths. id is an internal number; don’t use it as app_id. build_id, version, build_number and content_hash describe the build this upload created. If the file is identical to a build the app already has, no new build is created: the response adds "deduplicated": true and describes that existing build (a note sent with the upload is not saved in that case). external_app_key is null for ordinary uploads.
List Apps
GET /api/v1/apps/
Auth: Bearer token required. Viewer role or above.
Query parameters:
| Parameter | Type | Description |
|---|---|---|
platform | string | Filter by ios, android, tvos, androidtv or roku |
external_app_key | string | Exact, case-sensitive match within your organization; zero or one result |
Response:
[
{
"id": 1,
"public_id": "my-app-k3x9q2ab",
"name": "My App",
"package_name": "com.example.myapp",
"external_app_key": "project:my-app:android",
"platform": "android",
"icon_url": "/api/uploads/icons/0b7e1c2a-5d3f-4e8a-9c61-2f4d8a7b3e10_icon.png",
"last_started_at": "2025-01-20T14:00:00",
"updated_at": "2025-01-20T14:00:00",
"created_at": "2025-01-15T10:30:00"
}
]
Get App
Returns app details including all builds.
GET /api/v1/apps/{app_id}
Auth: Bearer token required. Viewer role or above.
Response:
{
"id": 1,
"public_id": "my-app-k3x9q2ab",
"name": "My App",
"package_name": "com.example.myapp",
"external_app_key": "project:my-app:android",
"platform": "android",
"icon_url": "/api/uploads/icons/0b7e1c2a-5d3f-4e8a-9c61-2f4d8a7b3e10_icon.png",
"last_started_at": null,
"latest_version": "1.2.0",
"latest_build_number": "42",
"latest_uploaded_at": "2025-01-20T14:00:00",
"builds": [
{
"id": 1,
"version": "1.2.0",
"build_number": "42",
"content_hash": "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592",
"file_size": 15728640,
"uploaded_by": "dev@example.com",
"uploaded_at": "2025-01-20T14:00:00",
"tags": [],
"min_sdk_version": "21",
"target_sdk_version": "34",
"permissions": ["android.permission.INTERNET"],
"metadata": {},
"keep": false,
"keep_reason": null,
"expires_on": null,
"expired": false,
"file_expired_at": null,
"restorable_until": null,
"pins": []
}
]
}
Each build carries the build retention fields.
Get a Build
Returns one build by its id, with the app it belongs to. Use it when you have a build id but not its app id.
GET /api/v1/apps/builds/{build_id}
Auth: Bearer token required. Viewer role or above.
Response:
{
"app": { "public_id": "my-app-k3x9q2ab", "name": "My App", "platform": "android" },
"build": {
"id": 1,
"version": "1.2.0",
"build_number": "42",
"uploaded_at": "2025-01-20T14:00:00",
"note": null,
"is_debug": true,
"can_use_metro": true,
"keep": true,
"keep_reason": "share_link",
"expires_on": null,
"expired": false,
"file_expired_at": null,
"restorable_until": null,
"pins": [{ "kind": "share_link", "name": "Beta testers" }]
}
}
The build carries the build retention fields. A build id that isn’t in your organization returns 404 with Build not found: no build {build_id} in this organization.
Update a Build
Change a build’s note, its Keep setting, or both.
PATCH /api/v1/apps/{app_id}/builds/{build_id}
Auth: Bearer token required. Developer role or above.
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
note | string | No | The build’s note. An empty or blank value removes it. A body without keep always sets the note, so leaving note out of it removes the note; a body with only keep leaves the note as it is. |
keep | boolean | No | true keeps the build however old it gets, and brings back an expired build that can still be restored. false lets it expire on your plan’s schedule again; it never removes a build at once. |
Response: the build, as in Get App’s builds, with the build retention fields.
Errors:
404— the app or the build isn’t in your organization.410—keep: trueon an expired build that can no longer be restored.detailis{"error": "build_expired", "message": "..."}; upload the build again to bring it back.
Keep a Production Build
Turn Keep on or off for a production cloud build’s signed artifact.
PATCH /api/v1/builds/{public_id}
Auth: Bearer token required. Developer role or above.
Request body: {"keep": true} or {"keep": false}, as in Update a Build.
Response: the cloud build, with the build retention fields of its artifact.
Errors:
400— the build is a simulator build (keep it with Update a Build, on its app build), or it didn’t succeed.404— the build isn’t in your organization.409— the build has no artifact to keep (it was deleted).410—keep: trueon an expired artifact that can no longer be restored:{"error": "build_expired", "message": "..."}. Run the build again.
Build retention
From December 7, 2026, a build’s file is kept for a period that depends on your plan (see How long builds are kept). Builds in Get App, Get a Build and Update a Build, and cloud builds from GET /api/v1/builds and GET /api/v1/builds/{public_id}, carry these fields. A simulator cloud build carries its app build’s values; a production build its artifact’s.
| Field | Type | Description |
|---|---|---|
keep | boolean | true when the build is kept however old it gets. |
keep_reason | string or null | Why Keep is on: user (turned on by a person), share_link, embed_key or schedule (turned on when one started using the build). It stays after that link, key or schedule is gone. null when Keep is off. |
expires_on | date or null | The day the build expires (it is kept through that day), while it is due. null for a build that isn’t due, a kept build and an expired one. No build is due before the weeks leading up to December 7, 2026. |
expired | boolean | true once the build has expired: it can’t be installed, run, downloaded or submitted. |
file_expired_at | datetime or null | When it expired. |
restorable_until | date or null | For an expired build, the last day keep: true can bring it back; null once it can’t. |
pins | object[] | The share links, embeds and test schedules using the build now, as {"kind", "name"} with kind one of share_link, embed_key or schedule. A production build’s is always []. |
An expired build is refused with 410 wherever it would be handed out: Create Session with its build_id, Install Build, a CI run that names it as build_id, downloading it (GET /api/v1/apps/{app_id}/builds/{build_id}/download, or a cloud build’s GET /api/v1/builds/{public_id}/artifact), Start a Run with a device naming it as build_id, and submitting a production build to a store (POST /api/v1/submissions, and its check GET /api/v1/submissions/build-preflight). detail is an object:
{
"error": "build_expired",
"message": "Build 1.2.0 (42) has expired: its file was removed under your plan's build retention. The newest build of My App is 1.3.0 (43), build 2. Turn on Keep to restore it, if it can still be restored, or upload it again.",
"newer_build_id": 2
}
newer_build_id is the id of the app’s newest build that hasn’t expired, named in message too, or null when there is none. An expired production artifact’s answer has error and message only. Creating a share link, an embed key or a test schedule that names an expired build, or changing one to name it, is refused with 410 and error: "build_expired" too; turn on Keep for the build first, if it can still be restored.
Delete App
DELETE /api/v1/apps/{app_id}
Auth: Bearer token required. Developer role or above.
The app’s tests and groups stay, with no app; its schedules are deleted. When the app is linked to another app of the same product and form factor (see App families below), its tests, groups and schedules move to that app instead (the first in the order iOS, Android, Apple TV, Android TV, Roku), and the moved schedules drop the deleted app’s devices. A schedule left with no devices is paused and its owner is told; one that keeps other devices stays active and its owner is told which devices it lost.
Response:
{
"status": "deleted"
}
App families
Apps linked as the same product across platforms. GET /api/v1/apps and GET /api/v1/apps/{app_id} include family ({"public_id", "name"} or null).
| Method | Path | Body | Role |
|---|---|---|---|
| GET | /api/v1/app-families | none | viewer |
| GET | /api/v1/app-families/suggestions | none | viewer |
| POST | /api/v1/app-families | {"name": "ScriptX", "app_ids": ["<app id>", "<app id>"], "anchor_app_id": "<app id>"} | developer |
| PATCH | /api/v1/app-families/{family_id} | {"name": "ScriptX"} | developer |
| DELETE | /api/v1/app-families/{family_id} | none | developer |
| POST | /api/v1/app-families/{family_id}/members | {"app_id": "<app id>"} | developer |
| DELETE | /api/v1/app-families/{family_id}/members/{app_id} | none | developer |
| GET | /api/v1/app-families/{family_id}/members/{app_id}/unlink-impact | none | viewer |
A family has one app per platform; adding a second app of a platform, or an app already in another family, returns 409 with the reason. Removing the second-to-last app removes the family ({"family": null}). Unlinking never deletes builds, tests, runs or schedules. It does take the removed app’s devices out of the family’s schedules (and a schedule’s devices out of the removed app’s schedules); a schedule left with no devices, or whose tests or group are no longer in its product, is paused (paused_reason: "target_gone") and its owner is told. Removing the family (DELETE /api/v1/app-families/{family_id}) does the same for every app. A schedule that keeps other devices stays active, and its owner gets one notification naming the devices it lost. unlink-impact returns {"schedules_paused": ["<schedule name>", ...], "schedules_reduced": ["<schedule name>", ...]}: the schedules that removing the app would pause, and those that would lose some devices and keep running. It changes nothing and notifies no one.
{
"public_id": "f8k2m1qz0a",
"name": "ScriptX",
"created_at": "2026-10-11T09:00:00",
"apps": [
{"public_id": "a1b2c3", "name": "ScriptX", "platform": "ios", "package_name": "com.scriptx.app"},
{"public_id": "d4e5f6", "name": "ScriptX", "platform": "android", "package_name": "com.scriptx.app"}
]
}
anchor_app_id is optional: the app whose values become the shared ones when values differ (default: the first of app_ids; it must be one of them, 400 otherwise). Creating a family and adding a member return the family plus merge, what the link did, with key names only:
{"merge": {"shared": 4, "per_platform_keys": ["email"],
"environments": {"shared": ["staging"], "per_platform_keys": {"staging": ["email"]}},
"notes": "kept_separately",
"replay_mode": {"changed": true, "mode": "ask"}}}
A link that would give the family more than 200 test data values or more than 50 environments returns 409. Unlinking leaves the app its own copy of the family’s values, environments and notes.
Family test data, notes and environments
Reads need Viewer or above; writes need Developer or above. Secret values read [MASKED]; sending [MASKED] back keeps the stored value.
| Method | Path | Body / description |
|---|---|---|
| GET | /api/v1/app-families/{family_id}/data | {"platforms": [...], "items": [{"key", "secret", "kind", "shared": {"value", "updated_at"}, "overrides": {"<platform>": {"value", "kind", "updated_at"} or null}}]} |
| PUT | /api/v1/app-families/{family_id}/data/{key} | {"value", "secret", "kind", "platform"}. platform null sets the shared value; a platform sets that platform’s own value (the same as the shared value removes it). 404 for a platform value of a key with no shared value |
| DELETE | /api/v1/app-families/{family_id}/data/{key}?platform= | Without platform: removes the key for every platform. With platform: that platform uses the shared value again |
| GET / PUT | /api/v1/app-families/{family_id}/notes | {"shared": "...", "platforms": {"ios": "..."}}; PUT changes only the fields sent, up to 10000 characters each |
| GET | /api/v1/app-families/{family_id}/environments | {"items": [{"name", "members": {"<platform>": "<environment id>"}}]} |
| POST | /api/v1/app-families/{family_id}/environments | {"name": "Staging"}: creates it on every linked app (409 if the name exists) |
| GET | /api/v1/app-families/{family_id}/environments/{name}/data | Same shape as /data, for that environment |
| PUT / DELETE | /api/v1/app-families/{family_id}/environments/{name}/data/{key} | Same body and rules as /data/{key} |
| PUT | /api/v1/app-families/{family_id}/learned-path-mode | {"mode": "auto" | "ask" | "never" | null} for every linked app |
Upload Build
Upload a new build for an existing app, using its public_id as app_id. This keeps its external key, if any, unchanged.
POST /api/v1/apps/{app_id}/builds
Auth: Bearer token required. Developer role or above.
Request: multipart/form-data
| Field | Type | Required | Description |
|---|---|---|---|
file | file | Yes | App binary |
version | string | No | Version string (auto-detected) |
note | string | No | Optional note stored on the new build (hard cap 500 chars; 200 recommended) |
Response:
{
"id": 2,
"version": "1.3.0",
"build_number": "43",
"content_hash": "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592",
"file_size": 16000000,
"uploaded_at": "2025-01-21T09:00:00",
"note": "Fix login crash — hotfix for ticket #1234"
}
Errors:
400— the file’s package name (Android) or bundle identifier (iOS) differs from the app’s: it is a different app, and could never be launched under this one.detailis an object:messagesays what to do,remedyisupload_as_new_appandpackage_nameis the file’s identifier. Upload the file with Upload App instead; it creates a new app, or adds the build to your existing app with that identifier. For an app created with anexternal_app_key, upload it with a new key.