---
title: "POST /analytics/capture"
method: POST
path: "/analytics/capture"
tags: ["analytics"]
---

# POST /analytics/capture

`POST /analytics/capture`

Decode a GUI UI-interaction event and hand it to the daemon's analytics sink.
Fire-and-forget: `accepted: true` only confirms the event was decoded and
dispatched, not that it reached PostHog.

## Request body

- union — Tagged union of GUI-originated UI analytics events (discriminated by `kind`). Wire-compatible with the retired `capture_update_ui_event` Tauri command's `UpdateUiEvent` (same discriminators / field names), except `DialogOpened` now carries `install_kind` explicitly (see [`UiInstallKind`]).
  - object — User opened `UpdateDialog` / `PackageManagerUpdateDialog`.
    - `install_kind` 'macos' | 'windows' | 'windowsportable' | 'appimage' | 'deb' | 'rpm' | 'unknown', required — Mirrors `analytics::InstallKind`. wire (lowercase): `macos` | `windows` | `windowsportable` | `appimage` | `deb` | `rpm` | `unknown`. Cross-process note (ADR-008 D20): `install_kind` was historically probed backend-side and the webview "knew nothing". After the process split the install provenance of the *running app* is owned by the native GUI shell (it holds `current_exe` / the portable marker / the `APPIMAGE` env), while the daemon has no install-detection code. So the webview now supplies it — it reads `get_install_kind` (native, cached) and forwards the result here.
    - `kind` 'dialog_opened', required
    - `phase` 'available' | 'downloading' | 'ready', required — Mirrors `analytics::UpdatePhase`. wire: `available` | `downloading` | `ready`.
    - `source` 'notification' | 'sidebar_icon', required — Mirrors `analytics::DialogOpenSource`. wire: `notification` | `sidebar_icon`.
  - object — User dismissed the dialog (later / closed / cancelled).
    - `kind` 'dismissed', required
    - `phase` 'available' | 'downloading' | 'ready', required — Mirrors `analytics::UpdatePhase`. wire: `available` | `downloading` | `ready`.
    - `source` 'dialog_later' | 'dialog_closed' | 'package_manager_dialog_closed', required — Mirrors `analytics::DismissSource`. wire: `dialog_later` | `dialog_closed` | `package_manager_dialog_closed`.
  - object — A pure-UI action path (e.g. `Cancelled`). `error_kind` must be a short identifier (< 32 chars, e.g. `user_cancelled`) and MUST NOT contain paths / URLs / IPs (telemetry schema doc §6.1).
    - `action` 'download_bg' | 'install', required — Mirrors `analytics::UpdateAction`. wire: `download_bg` | `install`.
    - `error_kind` string, nullable
    - `kind` 'action_invoked', required
    - `outcome` 'started' | 'succeeded' | 'failed' | 'cancelled', required — Mirrors `analytics::UpdateActionOutcome`. wire: `started` | `succeeded` | `failed` | `cancelled`.
  - object — An update check completed (any source). Emitted by the GUI updater background task / tray / settings button — the daemon has no update-check code, so the GUI forwards the outcome here. `failure_kind` is present only when `outcome` is `failed` and disappears from the wire otherwise (no `null`).
    - `failure_kind` 'network' | 'http_error' | 'parse_error' | 'other' — Mirrors `analytics::UpdateFailureKind`. wire: `network` | `http_error` | `parse_error` | `other`. Only present when the check outcome is `failed`.
    - `install_kind` 'macos' | 'windows' | 'windowsportable' | 'appimage' | 'deb' | 'rpm' | 'unknown', required — Mirrors `analytics::InstallKind`. wire (lowercase): `macos` | `windows` | `windowsportable` | `appimage` | `deb` | `rpm` | `unknown`. Cross-process note (ADR-008 D20): `install_kind` was historically probed backend-side and the webview "knew nothing". After the process split the install provenance of the *running app* is owned by the native GUI shell (it holds `current_exe` / the portable marker / the `APPIMAGE` env), while the daemon has no install-detection code. So the webview now supplies it — it reads `get_install_kind` (native, cached) and forwards the result here.
    - `kind` 'check_performed', required
    - `outcome` 'available' | 'up_to_date' | 'failed', required — Mirrors `analytics::UpdateCheckOutcome`. wire: `available` | `up_to_date` | `failed`.
    - `source` 'startup' | 'scheduled' | 'manual' | 'window_show', required — Mirrors `analytics::UpdateCheckSource`. wire: `startup` | `scheduled` | `manual` | `window_show`. Cross-process note (ADR-008 D20): the update *check* runs in the GUI process (its updater background task / tray / settings button), not the daemon. The GUI therefore forwards the check outcome here so the daemon — the single authoritative sender — dispatches it with its own `EventContext`.
  - object — An update prompt was delivered to the user (already same-version deduplicated). Emitted by the GUI `update_scheduler` after opening the Sparkle-style updater window. `version` is the raw manifest version string (low cardinality — one new version per channel at a time).
    - `delivery_status` 'sent' | 'permission_denied' | 'send_failed', required — Mirrors `analytics::NotificationDeliveryStatus`. wire: `sent` | `permission_denied` | `send_failed`.
    - `install_kind` 'macos' | 'windows' | 'windowsportable' | 'appimage' | 'deb' | 'rpm' | 'unknown', required — Mirrors `analytics::InstallKind`. wire (lowercase): `macos` | `windows` | `windowsportable` | `appimage` | `deb` | `rpm` | `unknown`. Cross-process note (ADR-008 D20): `install_kind` was historically probed backend-side and the webview "knew nothing". After the process split the install provenance of the *running app* is owned by the native GUI shell (it holds `current_exe` / the portable marker / the `APPIMAGE` env), while the daemon has no install-detection code. So the webview now supplies it — it reads `get_install_kind` (native, cached) and forwards the result here.
    - `kind` 'notification_shown', required
    - `version` string, required

## Response `200`

Event accepted for dispatch

- CaptureUiEventEnvelope — Canonical success envelope: `{ "data": T, "ts": <unix millis i64> }`. `ts` is `chrono::Utc::now().timestamp_millis()`, set in the webserver handler via [`ApiEnvelope::now`] (the contract carries only the type + the clock helper, not a hard dependency on when the handler reads the clock). `rename_all = "camelCase"` is a no-op for the single-word fields here but is declared for forward-compat. IMPORTANT (utoipa v4): every concrete `ApiEnvelope<X>` that needs a named OpenAPI component is declared in the `#[aliases(...)]` block below. Add a new alias line whenever a new payload type needs enveloping. NEVER register the bare `ApiEnvelope` in `components(schemas(...))` — utoipa errors on a bare generic, and an un-aliased generic inlines an anonymous schema.
  - `data` CaptureUiEventResponse, required — Response for `POST /analytics/capture`. `capture` is fire-and-forget, so `accepted` only confirms the daemon decoded the event and handed it to the sink — not that it reached PostHog.
    - `accepted` boolean, required
  - `ts` integer, required — Server time when the response was built (unix epoch milliseconds).

---

[API](https://skmtc.net/uniclipboard/apis/uniclipboard-daemon-api.md) · [All operations](https://skmtc.net/uniclipboard/apis/uniclipboard-daemon-api/llms.txt) · [OpenAPI document](https://skmtc-service-staging.skmtc.workers.dev/v1/apis/uniclipboard/uniclipboard-daemon-api/versions/10dd20bcf330/schema)
