---
title: "Public API Reference"
description: "Public update-check, latest-release, and client integration contracts."
category: "SDKs & API"
order: 16
---

# Public API Reference

> **Interactive API explorer:** the full request/response schemas live in
> the OpenAPI spec — browse and try them at [/api-docs](https://hands.build/api-docs)
> ([openapi.json](https://hands.build/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

| Name | Required | Description |
|---|---|---|
| `channel` | No | Release channel, such as `main` (default), `preview`, `nightly`, or `debug`. |
| `current_version_code` | Yes | Installed client version code. |
| `product_type` | No | Product type, such as `android-apk`. |
| `platform` | No | Client platform, such as `android`. |
| `arch` | No | Client architecture, such as `arm64-v8a`. |
| `filetype` | No | Desired installable file type. Defaults to `apk`. |
| `lang` | No | Preferred changelog language, such as `zh-CN` or `en`. Also read from `X-Hands-Lang` or `Accept-Language`. |
| `device_id` | No | Stable 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:

| File | `platform` | `arch` | `filetype` | `artifact_kind` |
|---|---|---|---|---|
| `latest.yml` | `win32` | `x64` or null | `yml` | `electron-metadata` |
| `latest-mac.yml` | `darwin` | `arm64` or `x64` | `yml` | `electron-metadata` |
| `latest-linux.yml` | `linux` | `x64` or `arm64` | `yml` | `electron-metadata` |
| `Raft Setup 1.2.3.exe` | `win32` | `x64` | `exe` | `installable` |
| `Raft Setup 1.2.3.exe.blockmap` | `win32` | `x64` | `blockmap` | `electron-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`.

| Field | Required | Description |
|---|---|---|
| `message` | Yes | Feedback text (max 10,000 chars). |
| `submission_id` | No | Client-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`. |
| `kind` | No | `feedback` (default), `bug`, or `crash`. |
| `contact` | No | Reply-to handle (email, Raft name, …). |
| `metadata` | No | JSON 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. |
| `attachments` | No | Inline files (multipart), up to 10 MB each, ≤9 total. |
| `presigned` | No | JSON 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](server-feedback.md#notify-the-products-user-or-agent).
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](server-feedback.md).

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](feedback-react.md).

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

| Code | Cause |
| --- | --- |
| `presigned_invalid` | Invalid JSON/non-array `presigned`, invalid array entry, or an `r2_key` outside this app's `feedback/{app_id}/presigned/` prefix. |
| `presigned_not_found` | The referenced R2 object does not exist. |
| `presigned_size_mismatch` | `size` is missing/nonpositive or differs from the stored object's actual byte size. |
| `attachments_limit_exceeded` | More 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

| Status | Meaning |
|---|---|
| `400` | Missing or invalid request parameters. |
| `404` | App, channel, release, or compatible artifact was not found. |
| `410` | Signed download URL expired. |
| `401` | Missing/invalid client key (feedback submissions). |
| `429` | Rate limited (feedback submissions). |
| `500` | Server 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.
