Skip to content
Login

Public API Reference ​

Interactive API explorer: the full request/response schemas live in the OpenAPI spec — browse and try them at /api-docs (openapi.json).

Hands's public API lets apps check for updates, download release artifacts, submit feedback, and view share/history pages without a Hands admin session.

Use the admin API or CLI for publishing. Use the public API from clients.

Base URL ​

text
https://hands.build

Self-hosted deployments should use their own origin.

Check for Updates ​

http
GET /public/v2/apps/:appSlug/updates/check?channel=main&product_type=android-apk&current_version_code=1000000&platform=android&arch=arm64-v8a&filetype=apk

Query Parameters ​

NameRequiredDescription
channelNoRelease channel, such as main (default), preview, nightly, or debug.
current_version_codeYesInstalled client version code.
product_typeNoProduct type, such as android-apk.
platformNoClient platform, such as android.
archNoClient architecture, such as arm64-v8a.
filetypeNoDesired installable file type. Defaults to apk.
langNoPreferred changelog language, such as zh-CN or en. Also read from X-Hands-Lang or Accept-Language.
device_idNoStable per-install identifier. Also read from X-Hands-Device-Id. Required to participate in staged rollouts.

Staged rollouts ​

Releases can be published to a percentage of devices. The server buckets clients by hashing (release_id, device_id), so a device keeps its bucket while the percentage climbs. Clients that send no device id only ever see fully rolled-out releases; gated-out clients fall through to the previous active release. The Android SDK sends the header automatically.

For exact QA or customer-device targeting, publishers can create an app-scoped device group and use a device_group release scope. Membership is evaluated server-side against the same stable installation device_id; the client never receives or chooses the group name. A non-member falls through to the previous matching active release. Device groups use installation identifiers, not IMEI, hardware serial numbers, or account identities.

A single release may contain full:all plus one or more device_group scopes. Group members always receive that release, even when their percentage bucket is outside rollout_cohort_count. Non-members remain percentage-gated, and anonymous clients use the prior eligible full release until rollout reaches 100%. Do not model these two audiences as separate releases of the same version.

Scope writes are fail-closed: omitting scopes when creating a generic release defaults to full:all, but explicitly supplying scopes requires a non-empty array whose every entry has a non-empty scope_type and scope_value; duplicate entries are rejected. full:all may be combined only with device_group entries. Publish with the complete canonical set, for example:

json
{
  "expected_revision": 4,
  "expected_scopes": [
    { "scope_type": "full", "scope_value": "all" },
    { "scope_type": "device_group", "scope_value": "<group UUID>" }
  ]
}

Hands rechecks the exact set inside the guarded activation operation and returns 409 RELEASE_SCOPE_PRECONDITION_FAILED on missing, malformed, duplicate, unexpected, or drifted scope state. expected_scope remains a legacy single-scope compatibility field; new callers should always send expected_scopes. Release detail also exposes an integer revision; send that value as expected_revision on PATCH, publish, cancel, restore, rollout-bump, and force-update writes. A stale revision returns 409 RELEASE_REVISION_CONFLICT without changing the release, its scopes, fallback releases, or audit log.

The admin release API permits only one non-cancelled lifecycle for each app/channel/product/release-type/version code. Duplicate creation returns 409 RELEASE_VERSION_ALREADY_EXISTS with the current release/build/status coordinates. Cancelling disables that lifecycle and releases the version for a corrected build while retaining the old release, build, assets, and audit history. The rollback endpoint restores a cancelled row only if no replacement owns its coordinate; otherwise it returns the same structured 409. A cancelled row whose activated_at is null was never published, so an eligible restore returns it to draft and must pass normal publish readiness, exact-scope, external-target, and revision gates. Only a previously active row returns to active with a new activation time. Reusing a coordinate does not make clients that already received that version install different bits; publish client corrections with a higher version code.

To freeze a published release without revoking its existing share links, set its stable full-scope rollout to 0 in the admin release controls. This is a distribution-pointer mutation, not a release deletion or share revocation: existing /share/... URLs and their artifact downloads remain available, while the public latest/update resolver skips the zero-rollout release and resolves the previous eligible active release. Fresh-read both resolver responses and the existing share after the mutation. Record the release revision and the previous eligible release in the receipt. Do not claim that rollout 0 retroactively removes an already-installed build. For each resolver readback, record the querying client version and whether a device id was supplied. A no-device-id read is the deterministic probe for fully rolled-out releases; a single device-id read is not sufficient evidence of pointer correctness when the target rollout is strictly between 0 and 100, because the result depends on the device's stable bucket.

To resume distribution, do not infer a target from the frozen release or blindly restore 100. The release owner must explicitly select the target version/release (and rollout percentage), taking into account any newer eligible release created while the pointer was frozen. Fresh-read the target release revision before changing it, apply the chosen rollout, then fresh-read the release, public latest/update responses, and the intended share URL again. Record the owner-selected target, resulting revision/rollout, and resolver and share readbacks in the receipt. Restoring rollout is not, by itself, proof that the public pointer resumed to the correct version. For each latest/update readback, record the querying client version and whether a device id was supplied; when the target rollout is strictly between 0 and 100, include the no-device-id deterministic probe and do not treat one device-id result as sufficient evidence.

Update Available ​

json
{
  "update_available": true,
  "app": { "slug": "raft-android", "platform": "android" },
  "channel": "main",
  "current_version_code": 1000000,
  "latest": {
    "build_id": "…",
    "version": "1.0.1",
    "version_code": 1000100,
    "changelog": "Bug fixes and improvements",
    "release_notes": {
      "en": "Bug fixes and improvements",
      "zh-CN": "修复问题并优化体验"
    },
    "force_update": false,
    "released_at": 1783162273735
  },
  "asset": {
    "platform": "android",
    "arch": "arm64-v8a",
    "filetype": "apk",
    "size_bytes": 29192396,
    "sha256": "9f86d081…",
    "download_url": "https://hands.build/public/r2/…"
  },
  "scoped": { "scope_type": "full", "scope_value": "all", "release_id": "…", "rollout_cohort_count": null }
}

changelog is localized: releases may carry per-language notes and the server returns the best match for the requested language (exact tag → language prefix → en → first available). release_notes is the structured per-language object for consumers that need all available languages without parsing the legacy changelog string.

No Update ​

json
{ "update_available": false, "current_version_code": 1000100, "latest_version_code": 1000100 }

Latest Release ​

http
GET /public/v2/apps/:appSlug/latest?channel=main&product_type=android-apk

Returns the latest compatible installable release for the channel, independent of the client's installed version. Accepts the same lang and device_id inputs as the update check. Pass product_type whenever the channel can carry more than one product form — without it the server picks the most recent active release across all types.

Unified asset shape ​

assets always has one shape, regardless of where the artifact bytes live:

json
{
  "platform": "linux",
  "arch": "x64",
  "variant": null,
  "filetype": "binary",
  "size_bytes": 119437512,
  "sha256": "b1946ac9…",
  "download_url": "https://hands.build/dl/my-cli/releases/rel-…/linux-x64"
}

Two storage arms feed it:

  • Uploaded assets (APKs, IPAs, installers): Hands stores the bytes and download_url is a signed, time-limited URL. filetype reflects the artifact kind (apk, dmg, …).
  • External builds (cli-binary — Node SEA or CLI executables whose bytes stay on the publisher's CDN): Hands acts as the registry. At release time the publisher declares each target's source URL, size, and SHA-256; Hands records that identity immutably while the CDN remains the byte authority. /latest projects those declarations into the same shape — platform/arch derive from the target name, filetype is binary, and download_url is the immutable release-bound /dl/:appSlug/releases/:releaseId/:target route, which 302s to the declared source URL and keeps resolving the same bytes even after the release is superseded.

Installers should not care which arm served an entry: select by platform/arch, download, verify, install.

CLI Version Index ​

http
GET /public/v2/apps/:appSlug/versions?channel=alpha&platform=linux&arch=x64&limit=20

Lists exact cli-binary versions that remain universally installable for one channel and machine target. Active and superseded full-scope releases are included only when their rollout is complete (rollout_cohort_count is null or 100); partial-rollout, draft, cancelled, hidden, future, and target-incompatible releases are excluded. Partial-rollout rows remain governed by the device-aware update resolver. Superseded universal versions remain downloadable through their immutable release-bound /dl route and can therefore be used for an explicit rollback.

json
{
  "schema_version": 1,
  "app": { "id": "…", "slug": "raft-computer-cli", "platform": "desktop" },
  "channel": "alpha",
  "target": { "platform": "linux", "arch": "x64" },
  "truncated": false,
  "versions": [
    {
      "version": "1.0.22",
      "version_code": 1000022,
      "status": "active",
      "published_at": 1788136459536,
      "release_id": "…",
      "sha256": "0f72382c…",
      "size_bytes": 156503232
    }
  ]
}

The endpoint validates every returned target identity. If two releases claim the same version with different bytes, or if integrity metadata is malformed, the request fails closed instead of returning a partial index. limit defaults to 20 and is bounded to 100; truncated: true tells the caller the bounded response omitted older versions.

Verifying downloads with sha256 ​

Every asset entry carries the lowercase hex SHA-256 of the artifact bytes as registered at release time. Verify downloaded bytes against it before installing:

sh
curl -fL -o app "$download_url"
echo "$sha256  app" | sha256sum --check --strict

This binds "the version Hands resolved" to "the bytes the CDN served": a half-written upload, a stale cache edge, a mispointed URL, or an accidental object overwrite fails the check instead of installing the wrong bytes. The hash is not a substitute for transport security — it complements HTTPS by catching content drift that transport cannot see.

Release Notes JSON ​

http
GET /public/v2/apps/:appSlug/release-notes?version_code=1000100&lang=zh-CN

Returns public release notes as JSON for clients that should not parse the HTML /notes/:appSlug page. The app must have public history enabled. version_code is optional; when present, the response includes that version and older non-cancelled versions. Draft notes are returned only when the draft matches the requested version_code, which supports preview flows before publish.

json
{
  "app": { "slug": "raft-android", "name": "Raft Android", "platform": "android" },
  "requested_version_code": 1000100,
  "lang": "zh-CN",
  "releases": [
    {
      "release_id": "…",
      "status": "active",
      "channel": "main",
      "version": "1.0.1",
      "version_code": 1000100,
      "released_at": 1783162273735,
      "changelog": "修复问题并优化体验",
      "release_notes": {
        "en": "Bug fixes and improvements",
        "zh-CN": "修复问题并优化体验"
      }
    }
  ]
}

Authenticated release APIs also expose release.release_notes on GET /api/apps/:appId/releases/:releaseId and release list rows. Publishers can write structured notes with release_notes:

http
PATCH /api/apps/:appId/releases/:releaseId
Content-Type: application/json

{
  "release_notes": {
    "en": "Bug fixes and improvements",
    "zh-CN": "修复问题并优化体验"
  }
}

changelog remains the localized display string. release_notes is the canonical structured object for consumers that need every language.

Electron Generic Provider ​

Electron apps using electron-updater can point the generic provider at Hands:

ts
autoUpdater.setFeedURL({
  provider: "generic",
  url: "https://hands.build/electron/:appSlug/:channel"
});

The app then requests electron-builder's standard files directly:

http
GET /electron/:appSlug/:channel/latest.yml
GET /electron/:appSlug/:channel/latest-mac.yml
GET /electron/:appSlug/:channel/latest-linux.yml
GET /electron/:appSlug/:channel/:installerFile
GET /electron/:appSlug/:channel/:installerFile.blockmap

Hands serves these from the active electron-installer release on that channel. Hands intentionally hosts electron-builder's generated files as-is: upload latest*.yml, installers, and .blockmap files as build assets. Use artifact_kind = electron-metadata for latest*.yml; use the original filename in variant or metadata_json.filename so relative URLs inside the yml resolve unchanged.

Example asset conventions:

Fileplatformarchfiletypeartifact_kind
latest.ymlwin32x64 or nullymlelectron-metadata
latest-mac.ymldarwinarm64 or x64ymlelectron-metadata
latest-linux.ymllinuxx64 or arm64ymlelectron-metadata
Raft Setup 1.2.3.exewin32x64exeinstallable
Raft Setup 1.2.3.exe.blockmapwin32x64blockmapelectron-blockmap

macOS updates still require signed app artifacts. Hands only hosts the already-built and signed files; it does not sign Electron applications.

Submit Feedback ​

http
POST /public/v2/apps/:appSlug/feedback
Content-Type: multipart/form-data
X-Hands-Client-Key: qk_...

Requires the app's client key (Sentry-DSN model: it identifies the app, it is not a user secret). Pass it in the X-Hands-Client-Key header (or a client_key query parameter); missing or mismatched keys get 401. The legacy X-Quiver-Client-Key header is still accepted for backward compatibility. Admins find and rotate the key in the app's Settings tab or via GET /api/apps/:id/client-key / POST /api/apps/:id/rotate-client-key.

FieldRequiredDescription
messageYesFeedback text (max 10,000 chars).
submission_idNoClient-generated UUID for idempotent retries. Keep it fixed for one draft. An exact replay returns the original ticket; reusing it with different content returns 409.
kindNofeedback (default), bug, or crash.
contactNoReply-to handle (email, Raft name, …).
metadataNoJSON string: version_name, version_code, channel, device_id, device_model, os_version, arch, locale, plus custom keys. Crash tickets add crash_exception_class / crash_top_frame (grouping signature). QNC2 Android native reports add crash_native_frames ({ index, offset, soname, build_id, source }), crash_registers, crash thread fields, and crash_native_images; server symbolication requires an exact per-frame BuildId match against the build's native-symbols asset. API 30+ reports may also include crash_exit_* metadata and a retained system trace attachment.
attachmentsNoInline files (multipart), up to 10 MB each, ≤9 total.
presignedNoJSON array of { r2_key, filename, content_type, size } for files already uploaded via the presign flow (below).

Returns 201 for a new ticket. An exact submission_id replay returns 200 with the same ticket and idempotent_replay: true. Both responses include the full ticket UUID in id, plus a copyable reference and ticket_url. attachment_refs exposes each attachment's stable Hands UUID and filename; the legacy attachment_names list remains available. New submissions and idempotent replays return the same attachment references, for example:

json
{
  "id": "<ticket UUID>",
  "status": "open",
  "attachment_names": ["diagnostics.zip"],
  "attachment_refs": [
    { "id": "<attachment UUID>", "filename": "diagnostics.zip" }
  ],
  "reference": "raft-android · 1.0.4 (1000400) · ticket <ticket UUID>\nattachments:\n<attachment UUID> · diagnostics.zip",
  "ticket_url": "https://app.hands.build/apps/<appId>/feedback/<ticket UUID>"
}

Rate limit: 10 submissions per hour per app + client IP. Tickets appear in the admin Feedback tab; a feedback:new webhook fires for subscribed endpoints (crash tickets can additionally trigger crash:new_group / crash:spike). The Android SDK's HandsFeedback.submit(...) wraps this endpoint and attaches device metadata automatically.

Trusted server-proxy rate identity ​

A server that proxies many signed-in users through one egress IP can avoid a shared global bucket by sending both:

http
Authorization: Bearer qvdt_...  # granted feedback:write
X-Hands-Reporter-Id: <stable opaque base64url value>

The bearer must be a non-expired, non-revoked, role-free app token bound to an active reporter integration. Its non-empty scope set must contain feedback:write and may contain only feedback:write, feedback:read, feedback:comment, and feedback:route. The trusted conversation flow binds an immutable route subject before its first submission. Scoped tokens do not inherit the legacy viewer/publisher role and cannot authenticate to Hands admin/read APIs unless a route explicitly requires one of their named permissions. The optional reporter id is an external subject identifier chosen by the integrator. It should be stable, opaque, and app/integration scoped — for example a random id stored in the integrator's account table or a keyed pseudonym — never a raw email, username, or internal database id. Hands stores it as the pseudonymous ticket owner and returns it through authenticated ticket and webhook contracts; the integrator decides whether to keep a reverse mapping for two-way support. Omitting the id yields anonymous, non-addressable tickets. Hands applies the 100/hour safety limit to the app + reporter id; the trusted proxy can enforce its own lower product limit. Supplying the reporter header without a matching app deploy token returns 401; ordinary SDK submissions that omit it continue to use the 10/hour app + client-IP bucket. Rate-limit responses include a dynamic Retry-After header.

Reporter-owned conversation API ​

Trusted server proxies can list and continue only their own reporters' tickets:

http
GET  /api/apps/:appId/reporter-feedback
GET  /api/apps/:appId/reporter-feedback/:ticketId
POST /api/apps/:appId/reporter-feedback/:ticketId/comments
POST /api/apps/:appId/reporter-feedback/:ticketId/close
GET  /api/apps/:appId/reporter-feedback/:ticketId/attachments/:attachmentId
Authorization: Bearer qvdt_...
X-Hands-Reporter-Id: <stable opaque value>

The token must be role-free, bound to an active reporter integration, contain the route's feedback:read or feedback:comment scope, and contain no non-feedback scope. Ownership is the exact tuple (app, reporter integration, reporter id). Lists return only owned tickets; targeted ownership misses and malformed ticket UUIDs return 404 without revealing another reporter's data. List and detail responses include Hands-authoritative unread state: each ticket has unread and unread_count, while the response has unread_total (the number of owned tickets with unread visible staff/system replies). A successful detail read advances a monotonic receipt through the latest visible reply in the returned comment page; concurrent or later replies remain unread; reporter comments and internal staff notes never create unread state.

Before a trusted reporter creates its first ticket, bind its immutable opaque route subject with feedback:route permission:

http
PUT /api/apps/:appId/reporter-feedback/route-subject
Authorization: Bearer qvdt_EXAMPLE_DO_NOT_USE
X-Hands-Reporter-Id: <stable opaque value>
Content-Type: application/json

{ "route_subject": "rfr_v1_<opaque base64url value>" }

A new binding returns 201; exact replay returns 200; a different subject for the same ownership tuple returns 409. Trusted submissions without a binding return 409 route_required before ticket or attachment persistence.

Reporter comments require a client-generated UUID submission_id: new comments return 201, exact replays return 200, and reuse with different trimmed text or attachment bytes returns 409. JSON remains supported for text-only replies. Multipart replies use body, submission_id, and up to three attachments; each file must be GIF, JPEG, PNG, or WebP and at most 10 MiB. Original submission attachments and reporter-comment attachments are served with private/no-store, safe-disposition, and no-sniff headers.

Optional short-lived server session (disabled by default) ​

When Hands enables reporter sessions for a server integration, the trusted proxy can exchange its deploy token for a 30-second session:

http
POST /api/apps/:appId/reporter-feedback/session
Authorization: Bearer qvdt_...
X-Hands-Reporter-Id: <stable opaque value>
Content-Type: application/json

{ "scopes": ["feedback:read", "feedback:comment"] }

The mint still performs the full deploy-token, app, active-integration, and scope checks. A successful 201 returns session_token, epoch-seconds expires_at, exact reporter_integration_id, and the canonical granted scopes. The integration id is a required component of the proxy's session cache key. Keep the session on the trusted proxy: do not persist it, log it, or return it to a browser. The same reporter routes accept the session as their bearer and still require the exact same X-Hands-Reporter-Id; app/reporter mismatches fail before ticket access. The reporter header selects a reporter under the deploy token's existing integration authority; it is not independent reporter authentication.

Minting is fail-closed rate limited by source token, integration, and an audit-HMAC reporter pseudonym. Responses use Cache-Control: private, no-store. When the feature is disabled the mint route returns 404 and reporter routes retain their deploy-token-only behavior.

For app-admin webhook creation, exact request bodies, permissions, and reporter subscription binding, see Backend & CLI Feedback. The app endpoint is POST /api/apps/:appId/webhooks; the organization endpoint requires org admin even when an app id is supplied in its body.

Reporter comment and status webhooks carry a stable logical event id in both the signed JSON body and X-Hands-Event-Id. Each subscription delivery also has a stable X-Hands-Delivery-Id. Retries reuse the exact same body, signature, event id, and delivery id; attempt timestamps and counters live only in the delivery ledger. Legacy delivery rows without a logical event id omit X-Hands-Event-Id while retaining the existing signature/event headers.

For a text-only backend or CLI integration, see Backend & CLI Feedback.

For a complete browser → trusted backend → Hands implementation, including credential setup, opaque reporter coordinates, the React transport, and a production checklist, see React Feedback Inbox.

Metrics Ingest ​

http
POST /public/v2/apps/:appSlug/metrics
Content-Type: application/json
X-Hands-Client-Key: qk_...
X-Hands-Device-Id: <stable per-install uuid>

A lightweight launch/install ping (client throttles to ≤1/day/device) that powers active-device and version-distribution analytics. Body is a JSON metadata object (version_name, version_code, channel, platform, arch, os_version, device_model, locale). The server upserts one row per (app, device id) — no PII; the device id is a random per-install UUID. Requires the app client key (same as feedback). Returns 202. Legacy SDKs may still post the same payload to /public/v2/apps/:appSlug/devices; new SDKs should use /metrics.

Authenticated admins and agents can read the aggregated version view at GET /api/apps/:id/analytics/versions?window_days=30. It joins these metrics pings with release update-check counters, feedback/crash tickets, and artifact download counters to report per-version metrics such as active_devices, total_devices, update_current_count, update_offered_count, feedback_count, crash_count, and download_count. window_minutes is available for recent-report windows, but the SDK ping is throttled and should not be treated as true online presence.

Presigned attachment upload (large files) ​

For attachments too large for an inline multipart submit (up to 200 MB), request a direct-to-R2 upload URL, PUT the bytes to it, then submit the ticket referencing the uploaded object.

http
POST /public/v2/apps/:appSlug/feedback/presign
Content-Type: application/json
X-Hands-Client-Key: qk_...

Body: { "files": [{ "filename": "...", "content_type": "...", "size": <bytes> }] } (≤9 files). Returns { "uploads": [{ attachment_id, r2_key, upload_url, expires_at }] }.

  1. PUT each file's bytes to its upload_url with the same Content-Type.
  2. Submit feedback with a presigned form field = JSON array of { r2_key, filename, content_type, size } for the uploaded files.

Returns 501 if direct upload isn't configured on the server. Total attachments (inline + presigned) may not exceed 9.

Submission validation errors ​

POST /public/v2/apps/:appSlug/feedback returns HTTP 400 with { "code": "<stable_code>", "error": "<human-readable message>" } when an attachment reference fails validation. Branch on code, not the message text. Older servers may return only error.

CodeCause
presigned_invalidInvalid JSON/non-array presigned, invalid array entry, or an r2_key outside this app's feedback/{app_id}/presigned/ prefix.
presigned_not_foundThe referenced R2 object does not exist.
presigned_size_mismatchsize is missing/nonpositive or differs from the stored object's actual byte size.
attachments_limit_exceededMore than 9 attachments in total (inline plus presigned).

These checks run before creating the ticket or its attachment records; no partial ticket is created, even if an earlier attachment passed validation. If the submission_id has not been used, the user may remove the diagnostic attachment and submit again with a new submission_id. If that id already created a ticket, the same validation failure returns 409 with submission_id already used with a different payload; do not create a second ticket with that id. This guarantee applies to the listed validation failures, not network timeouts or other responses.

The upload URL expires after 15 minutes; this does not expire the uploaded object. Unreferenced objects have no automatic cleanup guarantee. References are app-scoped, but are not reporter-bound or consumed only once. Uploaded references appear and download as ordinary ticket attachments.

Pure ArkTS clients that cannot send a file as a raw request body may add "upload_mode": "r2_multipart_proxy". The response then contains upload_id and part_size instead of upload_url. Upload each sequential raw part to PUT .../feedback/multipart/part, complete with the returned part ETags at POST .../feedback/multipart/complete, and call POST .../feedback/multipart/abort on failure. The server fixes the part size at 5 MiB and streams each bounded part into R2 without buffering the complete attachment.

Share Pages ​

  • GET /share/:token — public download page for one release (view/download stats, QR code on desktop, localized changelog). Optionally password-protected: the page shows an unlock form; POST /share/:token/unlock sets a short-lived cookie scoped to that share.
  • GET /share/:token/download — the artifact download (302 to a signed URL).
  • GET /share/:token/icon — the app icon for that release's build (falls back to the app-level icon).

Share links are created from the admin Shares tab, the CLI, or the API, and can be renewed, revoked, and password-protected after creation.

Version History ​

When enabled per app (Settings → Public version history):

  • GET /apps/:appSlug/history — public page listing published versions with localized changelogs, sizes, and downloads.
  • GET /apps/:appSlug/history/:releaseId/download — per-version download (302 to a signed URL).
  • GET /apps/:appSlug/latest — landing page for the newest active release (version, changelog, download); optional ?channel=.
  • GET /apps/:appSlug/v/:version — the same landing page pinned to one published version_name (e.g. /apps/raft-android/v/1.12.0). Superseded versions still resolve, so release announcements can link it permanently; drafts, cancelled, hidden and QA builds do not. Optional ?channel= when several channels shipped the same version (default prefers main).

Disabled apps return 404.

App Icon ​

http
GET /public/apps/:appSlug/icon

Serves the app icon. Per-build icons are extracted automatically from uploaded APKs (aapt); an app-level icon can also be uploaded from the admin Settings page as a fallback.

Download URLs ​

download_url values are signed, time-limited URLs. Clients should use them promptly and request a fresh update check if the URL expires.

The response includes a readable download filename through Content-Disposition when the artifact is fetched.

Client Behavior ​

Recommended client flow:

  1. Send the installed versionCode, configured channel, system language, and the SDK's persistent device id.
  2. If update_available is false, do nothing.
  3. If true, show release information or begin the update flow.
  4. Download the artifact from asset.download_url.
  5. Verify size/hash if the client update framework supports it.
  6. Install or hand off to the platform installer.

Errors ​

StatusMeaning
400Missing or invalid request parameters.
404App, channel, release, or compatible artifact was not found.
410Signed download URL expired.
401Missing/invalid client key (feedback submissions).
429Rate limited (feedback submissions).
500Server error. Retry later or contact the Hands operator.

Compatibility ​

Public update checks are read-only and do not require authentication. Admin and publishing APIs require Hands auth or an app-scoped deploy token.