Skip to content
Black Friday30% off your first month. See prices →

Reference REST API Apps

Apps API

Upload iOS, Android, Apple TV, Android TV and Roku app builds, list and delete versions, and fetch build metadata such as bundle id and platform, over the API.

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:

  1. List apps with the exact external_app_key to recover the app’s public_id.
  2. Get /api/v1/apps/{public_id} and find the build whose content_hash, version and build_number match the upload. The hash is the SHA-256 of the uploaded file bytes, and the build’s id is its build identifier.
  3. 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

FieldTypeRequiredDescription
filefileYesApp binary (.zip/.tar.gz/.tgz iOS or Apple TV simulator bundle, .apk for Android or Android TV, channel .zip for Roku)
namestringNoApp display name (auto-detected)
package_namestringNoPackage identifier (auto-detected)
external_app_keystringNoStable, case-sensitive app identity within your organization; see External app identity
platformstringNoIgnored: the platform is always detected from the file — ios, android, tvos, androidtv or roku
notestringNoOptional 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:

ParameterTypeDescription
platformstringFilter by ios, android, tvos, androidtv or roku
external_app_keystringExact, 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:

FieldTypeRequiredDescription
notestringNoThe 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.
keepbooleanNotrue 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: true on an expired build that can no longer be restored. detail is {"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: true on 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.

FieldTypeDescription
keepbooleantrue when the build is kept however old it gets.
keep_reasonstring or nullWhy 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_ondate or nullThe 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.
expiredbooleantrue once the build has expired: it can’t be installed, run, downloaded or submitted.
file_expired_atdatetime or nullWhen it expired.
restorable_untildate or nullFor an expired build, the last day keep: true can bring it back; null once it can’t.
pinsobject[]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).

MethodPathBodyRole
GET/api/v1/app-familiesnoneviewer
GET/api/v1/app-families/suggestionsnoneviewer
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}nonedeveloper
POST/api/v1/app-families/{family_id}/members{"app_id": "<app id>"}developer
DELETE/api/v1/app-families/{family_id}/members/{app_id}nonedeveloper
GET/api/v1/app-families/{family_id}/members/{app_id}/unlink-impactnoneviewer

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.

MethodPathBody / 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}/dataSame 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

FieldTypeRequiredDescription
filefileYesApp binary
versionstringNoVersion string (auto-detected)
notestringNoOptional 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. detail is an object: message says what to do, remedy is upload_as_new_app and package_name is 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 an external_app_key, upload it with a new key.

Last updated 31 Oct 2026 Something wrong on this page? Tell us

Search the docs