---
title: "Ingest Client Events"
method: POST
path: "/v1/client/events"
tags: ["client-events"]
---

# Ingest Client Events

`POST /v1/client/events`

## Request

- Base URL: `https://app.tokumalabs.com/api`
- URL: `https://app.tokumalabs.com/api/v1/client/events`
- Auth: OAuth 2
  - Password
  - Token URL: `v1/auth/token`

## Request body

- ClientEventBatchRequest: Request body for `POST /api/v1/client/events`. `device_id` matches the precedent set by `span_updates` (request body, not header). `events[]` length cap matches `span_updates`.
  - `device_id` string, required
  - `events` union[], required
    - union
      - PermissionStateChange
        - `type` 'permission_state_change', required
        - `occurred_at` string, date-time, required
        - `payload` PermissionStateChangePayload, required
          - `permission_key` string, required
          - `from` string, required
          - `to` string, required
      - PermissionRequestDenied
        - `type` 'permission_request_denied', required
        - `occurred_at` string, date-time, required
        - `payload` PermissionRequestDeniedPayload, required
          - `permission_key` string, required
          - `target` string, nullable
          - `denial_kind` string, required
      - PermissionEntitlementMissing
        - `type` 'permission_entitlement_missing', required
        - `occurred_at` string, date-time, required
        - `payload` PermissionEntitlementMissingPayload, required
          - `entitlement` string, required
          - `app_version` string, required
      - AppStarted
        - `type` 'app_started', required
        - `occurred_at` string, date-time, required
        - `payload` AppStartedPayload, required
          - `app_version` string, required
          - `os` string, required
          - `os_version` string, required
          - `install_id` string, required
          - `permissions` object
      - AppStopped
        - `type` 'app_stopped', required
        - `occurred_at` string, date-time, required
        - `payload` AppStoppedPayload, required
          - `reason` string, required
      - AppFocusChanged
        - `type` 'app_focus_changed', required
        - `occurred_at` string, date-time, required
        - `payload` AppFocusChangedPayload, required
          - `focused` boolean, required
          - `window` string, required
      - SystemSleep
        - `type` 'system_sleep', required
        - `occurred_at` string, date-time, required
        - `payload` SystemEventPayload, required
          - `gap_seconds` integer
      - SystemWake
        - `type` 'system_wake', required
        - `occurred_at` string, date-time, required
        - `payload` SystemEventPayload, required
          - `gap_seconds` integer
      - ScreenLocked
        - `type` 'screen_locked', required
        - `occurred_at` string, date-time, required
        - `payload` ScreenLockEventPayload, required: One OS screen-lock edge (mirrors ``SystemEventPayload``). ``locked_secs`` is 0 on the lock edge (the period is only measurable once it closes) and the measured duration on unlock.
          - `locked_secs` integer
      - ScreenUnlocked
        - `type` 'screen_unlocked', required
        - `occurred_at` string, date-time, required
        - `payload` ScreenLockEventPayload, required: One OS screen-lock edge (mirrors ``SystemEventPayload``). ``locked_secs`` is 0 on the lock edge (the period is only measurable once it closes) and the measured duration on unlock.
          - `locked_secs` integer
      - ConnectivityChanged
        - `type` 'connectivity_changed', required
        - `occurred_at` string, date-time, required
        - `payload` ConnectivityChangedPayload, required: One server-reachability transition observed by the desktop client. ``offline_secs`` is the duration of the offline period that just ended (0 when ``online`` is false — the period is only measurable once it closes).
          - `online` boolean, required
          - `offline_secs` integer
      - BackgroundTaskError
        - `type` 'background_task_error', required
        - `occurred_at` string, date-time, required
        - `payload` BackgroundTaskErrorPayload, required: A desktop-client background task reported an abnormal condition. ``task_name`` is the stable name the task was spawned/instrumented under (e.g. ``"sync_engine"``, ``"recording_pusher"``); ``stalled_seconds`` carries the observed heartbeat gap for stalls. ``error_kind`` is an open, client-defined vocabulary — deliberately a bounded ``str`` rather than an enum, so a client can report a new condition without a server deploy ahead of it. Known values: * ``"panicked"`` — the task died instead of completing. * ``"stalled"`` — the task missed its declared heartbeat deadline. * ``"identity_ambiguous"`` — the local sync mirror held an identity other than the one the feed serves, and was wiped + cold-started (``sync_engine`` only). **Not a dead task**: the client recovered. Alerting that treats every row here as a failure must exclude it.
          - `task_name` string, required
          - `error_kind` string, required
          - `stalled_seconds` integer, nullable
      - RecordingStarted
        - `type` 'recording_started', required
        - `occurred_at` string, date-time, required
        - `payload` RecordingStartedPayload, required
          - `session_id` string, required
          - `trigger` string, required
      - RecordingStopped
        - `type` 'recording_stopped', required
        - `occurred_at` string, date-time, required
        - `payload` RecordingStoppedPayload, required
          - `session_id` string, required
          - `reason` string, required
      - RecordingStartBlocked
        - `type` 'recording_start_blocked', required
        - `occurred_at` string, date-time, required
        - `payload` RecordingStartBlockedPayload, required: One rejected user-initiated recording start. ``reason`` is the desktop client's ``PreconditionFailure`` discriminator (``not_authenticated`` / ``session_unverified`` / ``user_settings_missing`` / ``permissions_missing``).
          - `reason` string, required
      - RecordingError
        - `type` 'recording_error', required
        - `occurred_at` string, date-time, required
        - `payload` RecordingErrorPayload, required
          - `session_id` string, nullable
          - `error_kind` string, required
      - RecordingQuotaChanged
        - `type` 'recording_quota_changed', required
        - `occurred_at` string, date-time, required
        - `payload` RecordingQuotaChangedPayload, required: One screenshot-disk-quota edge on the desktop client. ``blocked`` true means screenshot capture paused at the local disk cap; false means eviction freed space and capture resumed.
          - `blocked` boolean, required
          - `bytes_used` integer, nullable
      - RecordingExtractionOutcome
        - `type` 'recording_extraction_outcome', required
        - `occurred_at` string, date-time, required
        - `payload` RecordingExtractionOutcomePayload, required: One direct-integration extraction attempt outcome. Emitted per dispatched extraction (identity or full) so each `MetadataExtracted` / `ExtractionFailed` result leaves a queryable audit trail. Silent-no-result failures (heavy AppleScript returns successfully with no document) are invisible without this event.
          - `span_id` string, required
          - `session_id` string, required
          - `strategy` string, required
          - `full_extraction` boolean, required
          - `had_metadata` boolean, required
          - `had_error` boolean, required
          - `error_kind` string, required
          - `extraction_ms` integer, required
          - `attempt_n` integer, required
          - `attempt_kind` string, nullable
          - `policy_timeout_ms` integer, nullable
      - RecordingForceClose
        - `type` 'recording_force_close', required
        - `occurred_at` string, date-time, required
        - `payload` RecordingForceClosePayload, required: One forced span close in the recording loop. Emitted next to each `span_tracker.force_close_current(...)` call in the desktop client's `recording/recording_service/detection_loop.rs` when the user is "not actually working" for a structural reason: - ``idle`` — keyboard / mouse idle past the client's `IDLE_THRESHOLD` (≈ 600s). `gap_duration_ms` carries the threshold-crossing idle window. - ``sleep`` — machine sleep / hibernate detected via the client's dual-clock divergence. `gap_duration_ms` carries the measured sleep duration. - ``lock`` — screen lock / screensaver transition detected. `gap_duration_ms` is always ``0`` — the client only sees the false→true transition edge at detection time, not how long the lock had been on.
          - `cause` 'idle' | 'sleep' | 'lock', required
          - `span_duration_ms` integer, required
          - `gap_duration_ms` integer, required
      - RecordingSpanSendGivenUp
        - `type` 'recording_span_send_given_up', required
        - `occurred_at` string, date-time, required
        - `payload` RecordingSpanSendGivenUpPayload, required: One recorded span the pusher gave up re-sending. Emitted by the desktop recording pusher when a span has been rejected by ``POST /api/v1/span-updates`` ``send_attempts`` times (``MAX_SPAN_SEND_ATTEMPTS``) AND the unsent backlog has grown past the batch window — the pusher force-marks it sent to keep the ``started_at ASC`` feed flowing, accepting the loss of that one span's billable activity. Rare and billing-relevant, so it is a server-visible telemetry event (not just a local dev-dashboard counter): a non-zero rate means recorded activity is being dropped and needs investigation (the per-item server log carries the actual DB failure reason, joined by ``span_id``).
          - `span_id` string, required
          - `attempts` integer, required
      - RecordingUndeliverableCapture
        - `type` 'recording_undeliverable_capture', required
        - `occurred_at` string, date-time, required
        - `payload` RecordingUndeliverableCapturePayload, required: One capture the app took, told us about, and then lost on the user's own machine before it could be uploaded. Emitted by the desktop recording pusher and the screenshot-quota eviction when a staged PNG will never reach ``PUT /api/v1/capture-images/{id}`` — the file gone from disk, the quota reclaiming it before the outbox drained, or a permanent server refusal. **This is the observability half, not the control path.** The capture's own ``SCREENSHOT`` span event carries the same reason in its ``data``, and that is what stops extraction retrying an upload that is never coming (see ``docs/decisions/0019-an-undeliverable-capture-is-declared-by-the-app.md``). This event exists so the rate is readable per device and per reason without joining span data — and because the batch-level ``recording_error`` it replaces named no image at all, which is precisely why a production occurrence could not be attributed to a client path. Should stay rare. A non-zero rate means capture bytes are being lost before upload, and ``reason`` says which of the three ways. ``reason`` is deliberately an open ``str``, matching ``LoginFailedPayload``: the app may learn a new way to lose a capture without needing a server release to report it.
          - `image_id` string, required
          - `reason` string, required
      - LoginSucceeded
        - `type` 'login_succeeded', required
        - `occurred_at` string, date-time, required
        - `payload` LoginSucceededPayload, required
          - `method` string, required
      - LoginFailed
        - `type` 'login_failed', required
        - `occurred_at` string, date-time, required
        - `payload` LoginFailedPayload, required
          - `error_kind` string, required
      - Logout
        - `type` 'logout', required
        - `occurred_at` string, date-time, required
        - `payload` LogoutPayload, required
          - `reason` string, required
      - PageView
        - `type` 'page_view', required
        - `occurred_at` string, date-time, required
        - `payload` PageViewPayload, required
          - `page` string, required
          - `app_session_id` string, required
          - `sequence` integer, required
      - FeatureUsed
        - `type` 'feature_used', required
        - `occurred_at` string, date-time, required
        - `payload` FeatureUsedPayload, required
          - `feature` string, required
          - `app_session_id` string, required
          - `sequence` integer, required
          - `context` object, nullable

## Response `200`

Successful Response

- ClientEventBatchResponse: Per-event accept/reject decision. `accepted` lists indices into `events[]` that were persisted; `rejected` carries per-event errors for the rest. The client marks accepted rows sent and bumps `send_attempts` on rejected ones.
  - `accepted` integer[], required
  - `rejected` RejectedEvent[], required
    - `index` integer, required
    - `error_kind` string, required
    - `message` string, nullable

## Other responses

- `400`: Validation error or domain rule violation.
- `401`: Missing, expired, or invalid authentication.
- `403`: Authenticated but not permitted.
- `404`: Resource not found.
- `409`: Resource state conflict (duplicate, illegal transition).
- `422`: Request body failed schema validation.
- `500`: Server-side failure.

---

[API](https://skmtc.dev/tokumalabs/apis/tokuma-user-api.md) - [All operations](https://skmtc.dev/tokumalabs/apis/tokuma-user-api/llms.txt) - [OpenAPI document](https://skmtc.dev/tokumalabs/apis/tokuma-user-api/revisions/1f8201af459b?raw)
