Hands
Hands Docs

Public API Reference

Public update-check, latest-release, and client integration contracts.

> 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

https://hands.build

Self-hosted deployments should use their own origin.

Check for Updates

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:

{
  "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

{
  "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

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

Latest Release

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:

{
  "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:

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

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:

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

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.

{
  "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:

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:

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

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

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

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:

{
  "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:

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:

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:

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:

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.

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 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

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.

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.

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

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):

Disabled apps return 404.

App Icon

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.