Clipshot exposes a local HTTP API in browser mode and in daemon setups that enable the HTTP server.

Base behavior

  • Default bind: 127.0.0.1:<port>
  • Override bind: CLIPSHOT_HTTP_BIND=0.0.0.0|127.0.0.1|::1|localhost
  • JSON envelope for JSON endpoints:
{"success": true|false, "data": ..., "error": null|"message"}
  • Unknown GET routes fall back to the embedded SPA index.html
  • OPTIONS gets 204 with CORS headers

Host header gate (DNS-rebinding protection)

The API is unauthenticated because it is localhost-only, so src/http/server.rs validates the Host header before every route, including /health and OPTIONS. A request whose Host is not a loopback host is rejected with 403 and this fixed body:

{"success":false,"error":"Forbidden: Host header is not a loopback address","data":null}

Rules (HostGuard, src/http/server.rs):

  • accepted hosts: localhost, 127.0.0.1, [::1] — optionally carrying the configured port (localhost:15282); a host with a different port is rejected
  • a request with no Host header is admitted (HTTP/1.1 browsers always send one, so the rebinding vector is unaffected, while HTTP/1.0 tooling such as doctor’s own probe can reach the socket directly anyway)
  • the gate is enforced only for loopback binds (CLIPSHOT_HTTP_BIND = 127.0.0.1, ::1, localhost). A non-loopback bind such as CLIPSHOT_HTTP_BIND=0.0.0.0 (used by the repo’s own docker/CI compose files) turns the gate off and logs that once
  • the same gate is applied by the Tauri test-hook server on port 18181 — one predicate, not a per-server copy

So a caller reaching the API by LAN name or container hostname on a loopback bind gets a 403, not a route.

CORS default allowlist

With no custom origins configured, is_origin_allowed (src/http/server.rs) admits:

  • any http:// or https:// origin whose host is localhost, 127.0.0.1 or [::1], on any port (a vite dev server and the daemon are different ports on the same trusted host)
  • exactly three Tauri webview origins: tauri://localhost, http://tauri.localhost, https://tauri.localhost

The Tauri origins are matched exactly, not by scheme prefix: the host part is spoofable (tauri://x.evil.com, http://tauri.localhost.evil.com), and the matched origin is reflected into Access-Control-Allow-Origin, so a prefix match would let an attacker-registrable domain read every API response. Loopback hosts are matched exactly for the same reason (http://localhost.evil.com is rejected).

If cors_origins is configured, it replaces the defaults entirely and each origin must match exactly. Requests with no allowed Origin get http://localhost reflected back.

Canonical response types

DaemonStatusFull

Fields returned by GET /api/status and Tauri get_daemon_status:

  • running: bool
  • sync_enabled: bool
  • node_id: string
  • listen_addr: string
  • connected_peers: number
  • total_peers: number
  • node_name: string
  • uptime_secs: number
  • share_uri_expires_secs: number
  • iroh_enabled: bool
  • iroh_addr: string | null
  • messages_sent: number
  • messages_received: number
  • hub_connected: bool
  • hub_url: string | null
  • hub_limit_reason: string | null
  • hub_limit_current: number | null
  • hub_limit_max: number | null
  • hub_limit_manage_url: string | null
  • hub_limit_message: string | null
  • sync_state: "idle" | "sending" | "receiving" | "no_peers" | "paused"
  • app_version: string
  • tick_age_ms: number
  • peer_relay_url: string | null
  • home_relay_state: string | null — "not_configured" | "connecting" | "attached"; serde-default-safe
  • relay_connecting_secs: number | null — seconds the home relay has been stuck connecting; absent when not connecting (doctor runs in a separate process, so the elapsed time is projected through status)
  • home_relay_fallback: string | null — reason a configured custom relay was stripped before endpoint creation; currently only "n0-self-relay-stripped". Absent for unconfigured nodes and for normal custom-relay operation
  • dead_workers: string[] — endpoint worker(s) that exited unexpectedly and have not yet been cleared by a deliberate respawn; empty in the normal case, serde-default-safe
  • dead_workers_detected_secs: number | null — seconds since the current dead-worker set last changed; null when no worker is reported dead
  • hub_disconnect_reason: string | null — set when the portal removed this device from its group (HubAction::Removed); null in the normal case
  • pending_remove_intents: number — pending peer-removal retry intents awaiting delivery; serde-default-safe (absent → 0)
  • remove_intent_oldest_secs: number | null — age in seconds of the oldest pending remove-intent; null when none pending. Lets doctor flag a removal that has not converged against an offline target
  • local_tombstones: number — local peer-removal tombstones currently stored; serde-default-safe (absent → 0)
  • tombstone_oldest_secs: number | null — age in seconds of the oldest local tombstone; null when none stored
  • stale_tombstoned_peers: number — peers present in the registry and still tombstoned locally (the actually-divergent, holder-side node behind “dead node in half the fleet”); serde-default-safe (absent → 0)
  • group_has_host: bool — some member of the group (this node included) is a computer (device_kind::is_host). false = a group of phones only: a group path pastes nowhere, so the UI drops the path acts and a phone’s take counts as delivery. serde-default true, so an older daemon and the pre-first-status state keep today’s UI instead of flickering the act away
  • paste_hotkey_note: string | null — macOS GUI only: the one-line banner shown while another app holds Secure Event Input (e.g. “Secure keyboard entry is on (Google Chrome) — Clipshot holds Cmd+B until it ends”). Absent otherwise and always absent off macOS; serde-default-safe

FullPeerInfo

Fields returned by GET /api/peers and Tauri get_peers:

  • name
  • address
  • connected
  • status: Online | Offline | Connecting
  • node_id: string | null
  • addresses: string[]
  • latency_ms: number | null
  • last_sync: number | null
  • transport_type: string | null
  • limited: bool
  • reject_reason: string | null
  • device_type: string | null
  • device_id: string | null
  • transfer_bytes_done: number | null
  • transfer_bytes_total: number | null
  • transfer_speed_bps: number | null
  • connection_type: "direct" | "relay" | null — the PATH the last health probe to this peer travelled (probe_peer_liveness_observed reads connection_diagnostics::snapshot(&conn).selected_kind right after open_bi, stores it with ConnectionMonitor::note_path_kind, and peer_ops.rs projects it). null while the peer is connected but no probe has reported a path yet, and whenever the peer is disconnected — the projection says nothing rather than guessing. Changed in v0.8.84: the field used to be inferred from the address card (relay= present → "relay"), and every hub-known card carries a relay, so a node with 189 direct QUIC connections listed every peer as relay; measured after the change on main, 5 of 6 peers read direct and the LTE phone relay
  • path_rtt_ms: number | null
  • last_reachable_secs: number | null
  • last_indirect_secs: number | null
  • data_plane_state: "ok" | "degraded" | "backpressured" | "asleep" | null — "ok" when connected and outbound push liveness is healthy; "degraded" when a host is quarantined by transport failures; "asleep" when the quarantined peer is a phone or tablet (a suspended phone is not a broken host; doctor and the UI do not prescribe transport repair for it); "backpressured" while the latest spoken Busy response is no older than five minutes. Fresh backpressure may remain visible while disconnected because it proves application responsiveness, not connection health; without another Busy response it expires after five minutes. serde-default-safe
  • consecutive_push_failures: number | null — consecutive outbound clipboard-push failures, serialized only when ≥1; serde-default-safe
  • push_attempts_since_quarantine: number | null — real outbound push attempts observed after this peer entered quarantine; serialized only when the peer is quarantined and attempts ≥1, so it distinguishes actual failed attempts from no-attempt starvation; serde-default-safe
  • consecutive_inbound_stalls: number | null — consecutive inbound drain stalls observed by ConnectionMonitor; serde-default-safe

ActivityEvent

  • id
  • event_type
  • description
  • timestamp
  • peer_name
  • content_type?
  • size_bytes?
  • error_message?
  • file_path?

TransferInfo

  • id
  • direction: "sending" | "receiving"
  • peer_name
  • peer_addr
  • content_type
  • total_bytes
  • transferred_bytes
  • status: "in_progress" | "complete" | "failed"
  • started_at
  • completed_at?
  • file_path?
  • error_message?
  • chunks_sent
  • chunks_total
  • speed_bytes_per_sec

HistoryEntry

  • id
  • entry_type: "text" | "image" | "file" — the item’s kind; a picture sent as a file is image
  • acts: ("item" | "path")[] — what Copy / Path can put on THIS device’s clipboard for the item, decided by the daemon from the same table the take uses (take::acts); the UI only words them. Offers in /api/clipboard/bridge/latest and pending carry the same acts. A history row carries no group_path field: the path a Path act puts is resolved from disk at take time (take::form_of → sync::group_link::group_name_of), and group_path is serialized only by the offer projections (/api/clipboard/bridge/latest, the pending answer, the App Group export)
  • name
  • size_bytes
  • peer_name
  • direction
  • status
  • timestamp
  • file_path?
  • content_type
  • transferred_bytes?
  • total_bytes?
  • chunks_sent?
  • chunks_total?
  • speed_bytes_per_sec?
  • error_message?
  • completed_at?

FileTransferJob

Fields returned by GET /api/file/transfers:

  • id: u64
  • file_path: string
  • file_name: string
  • file_size: u64
  • peer_addr: string
  • peer_name: string
  • group_name: string — the item’s group name, computed ONCE at enqueue and carried on the wire, so ~/.clipshot/sync/<group name> names the item on every node that has it. Empty string on a job persisted before this field existed (#[serde(default)])
  • attempts: u32 — transport attempts this job has already spent (#[serde(default)], 0 on an older persisted job). not_before and busy_polls are runtime-only (#[serde(skip)]) and never appear on the wire
  • status: "Queued" | "Sending" | "Complete" | "Failed" | "Cancelled" — PascalCase, unlike TransferInfo.status, which is lowercase. FileTransferStatus (src/p2p/v2/file_transfer/queue.rs) is serialized with no serde rename, so the enum variant names go on the wire verbatim; the TypeScript binding (web/src/lib/tauri.ts) matches this. A client that compares against lowercase values never matches
  • transferred_bytes: u64
  • error: string | null
  • created_at: u64
  • completed_at: u64 | null

PendingInfo

Fields returned by GET /api/file/pending:

  • id: u64
  • file_name: string
  • file_size: u64
  • from_peer: string

EnvironmentStatus

Fields returned by GET /api/environment/status:

  • was_healed: bool — true if the daemon self-healed a wrong headless-on-desktop environment
  • mode: "daemon" | "gui" — the serving process UI mode

Core / SPA / static routes

Method Path Body Response
GET / – embedded index.html
GET /index.html – embedded index.html
GET /assets/* – embedded static asset or 404 text/plain
GET /health – {"status":"ok"}
GET /api/icon.png – PNG chosen from current global sync state
GET /api/permissions – ApiResponse<PermissionInfo[]> (macOS Input Monitoring etc.)
OPTIONS any API path – 204 + CORS headers

Status / activity / share routes

Method Path Body Response
GET /api/status – ApiResponse<DaemonStatusFull>
GET /api/activity – ApiResponse<ActivityEvent[]>
GET /api/share_uri – ApiResponse<{ uri, node_id, expires_in_secs }> or error if no routable addresses
GET /api/gossip_peers – same payload as /api/peers
GET /api/transfers – ApiResponse<TransferInfo[]>
GET /api/history?filter=<opt>&limit=<opt> – ApiResponse<HistoryEntry[]>, default limit=100
GET /api/history/:id/content – raw file bytes; image/* if history entry is image, else text/plain; charset=utf-8
GET /api/last_sync_path – last sync file path
GET /api/push/status – push-wake diagnostics (src/http/handlers_sync.rs::push_status): {token_registered, platform, sandbox, wakes, settled, background_refresh, notices, delivered}. The device token itself is never returned — only whether one is registered, its platform and sandbox flag. background_refresh is the OS verdict on background wakes (iOS Background App Refresh; null where the platform does not report one), notices is the notification authorization state, delivered lists the bodies of notices the OS is showing right now (metadata text only)

Peer routes

Method Path Body Response
GET /api/peers – ApiResponse<FullPeerInfo[]>
POST /api/peers {"name":"...","address":"...","auth_code"?:"..."} queues PeerCommand::Add
PUT /api/peers {"address":"...","name"?:string,"addresses"?:string[],"auth_code"?:string|null} queues PeerCommand::Update
DELETE /api/peers/:name – queues remove by peer name or stable hex node ID
POST /api/peers/:name/connect – queues ConnectByName
POST /api/peers/:name/activate – queues ActivateByName
POST /api/peers/:name/deactivate – queues DeactivateByName
POST /api/peers/:name/reconnect – queues Reconnect
POST /api/peers/reconnect_all – queues a reconnect sweep for all peers
POST /api/add_peer_from_uri {"uri":"clipshot://..."} queues AddFromUri

Validation details:

  • AddPeerRequest.name: 1..64 chars
  • AddPeerRequest.address: 7..255 chars
  • AddPeerRequest.auth_code: optional string for password-protected peers
  • UpdatePeerRequest.address: 1..255 chars
  • AddPeerFromUriRequest.uri: 1..2048 chars, plus non-blank after trim

Settings routes

Method Path Body Response
GET /api/settings – ApiResponse<Settings> from daemon-authoritative projection; group_token is redacted (see below). A settings load failure answers 500 settings unavailable: …, never a default with group_token: null — a default would read as “this device is not paired” (the web UI shows the Welcome screen only after a successful fetch says there is no token)
POST /api/settings partial settings update body queues UpdateSettings
POST /api/settings/replace_group_token {"group_token":"clip_..."} intentional token replacement: store-level write bypassing the disk-preserve merge, then queues PeerCommand::ReconnectHub

group_token is never served in clear text

GET /api/settings (and the Tauri get_settings command) go through load_effective_settings_for_api() (src/p2p/control/settings.rs), which replaces a configured group_token with the placeholder:

[redacted:<first 8 hex chars of SHA-256(token)>]

Presence is preserved (null vs a placeholder still answers “is a token configured?”) and so is token identity — a client can recompute SHA-256 over a candidate token and compare fingerprints — but the credential itself is never returned. The redaction lives in the API projection only; the real value is what gets written to disk.

Both write paths refuse the placeholder, so a client that reads the settings and posts them back cannot destroy the stored credential:

  • POST /api/settings ignores a group_token that is a redaction placeholder (logged as a warning) and leaves the stored token untouched.
  • POST /api/settings/replace_group_token rejects it with an error: group_token must not be the redacted placeholder returned by GET /api/settings.

See Settings for how the same redaction appears in the UI.

Accepted update fields in POST /api/settings:

  • auto_sync (sync_enabled alias also accepted)
  • auto_start
  • notifications
  • sync_interval_ms
  • max_file_size_mb
  • listen_port
  • enable_iroh
  • max_peers
  • auto_discover
  • use_browser_ui
  • direct_send_threshold_mb
  • hotkey
  • hub_url (disk-preserved when already set — the save-path merge keeps the on-disk value)
  • group_token (disk-preserved when already set — use POST /api/settings/replace_group_token for replacement)
  • relay_url
  • max_peers_free
  • peer_relay_enabled
  • n0_relay_enabled
  • sync_max_files
  • sync_max_age_days
  • sync_max_size_mb
  • broadcast_queue_size
  • auto_accept_files
  • received_files_dir

Not accepted via HTTP API:

  • is_pro
  • node_password
  • central_relay_enabled (hub-authoritative; serde alias relay_enabled)
  • paste_path_hotkey
  • paste_shortcut
  • paste_path_mode (how Ctrl+B delivers the path: "type" or "clipboard"; default "type" on Linux, "clipboard" elsewhere — settings.toml only)
  • peer_removal_enforce (trusted peer-removal gates; settings.toml only)
  • cors_origins
  • theme
  • poll_interval_ms (use sync_interval_ms API alias instead)
  • external_dns_fallback (kill-switch for the DoH fallback layer; settings.toml only)
  • allow_self_relay (self-relay guard bypass for relay-host operators; settings.toml only)
  • diagnostic_telemetry (single reusable opt-in for Clipshot-owned diagnosis logs, default false; settable via settings.toml, a native GUI full-settings save, or CLIPSHOT_DEBUG_TELEMETRY=1 — not over HTTP, because remote enablement would amplify log volume)

Validation ranges enforced by UpdateSettingsRequest (src/http/types.rs):

  • sync_interval_ms: 100..3_600_000
  • max_file_size_mb: 1..10240
  • listen_port: 1024..65535
  • max_peers: 1..1000
  • direct_send_threshold_mb: 1..100
  • max_peers_free: 1..1000
  • sync_max_files: 0..10000 (0 = unlimited)
  • sync_max_age_days: 0..3650 (0 = unlimited)
  • sync_max_size_mb: 0..100000 (0 = unlimited)
  • broadcast_queue_size: 1..10

API ranges are wider than the Settings page inputs

The number inputs on the Settings page (web/src/pages/SettingsPage.tsx) use narrower min/max attributes than the HTTP validator. Values between the UI cap and the API ceiling are accepted by POST /api/settings but cannot be typed into the GUI:

Field Settings page input HTTP API validation Default
max_file_size_mb 1..500 1..10240 10
sync_max_files 0..10000 0..10000 100
sync_max_age_days 0..365 0..3650 30
sync_max_size_mb 0..10000 0..100000 200
max_peers 1..100 1..1000 10
poll_interval_ms / sync_interval_ms 100..10000 (step 100) 100..3_600_000 500
listen_port 1024..65535 1024..65535 19231
direct_send_threshold_mb 1..100 1..100 1
broadcast_queue_size 1..10 1..10 1

The Settings page field labels use poll_interval_ms; the HTTP API alias for the same field is sync_interval_ms. See Settings for the UI-side view of these ranges.

Sync / transfer / clipboard routes

Method Path Body Response
POST /api/retry_transfer {"file_path":"..."} queues retry by path; the item goes out through the same broadcast queue as a fresh copy (a retry while sync is paused answers «Sync is paused»)
POST /api/copy_to_clipboard {"text":"..."} queues local clipboard write + broadcast
POST /api/clipboard/copy_path {"file_path":"..."} History’s Copy path over HTTP (browser UI and the phones): LOCAL clipboard only, never broadcast. On a desktop: the local path, tilde-expanded. On a phone the request goes through take::take_saved(.., TakeAs::Path) and puts the item’s group path ~/.clipshot/sync/<group name> — which FAILS (TakeError::NotPasteable, nothing at ~/.clipshot/sync/ names <path> on this device — it has no group path) when no group path names the item here, so nothing is written to the pasteboard. The route queues PeerCommand::CopyPathLocal, so that failure is reported by the command, not by the HTTP status. Bridge-secret gated
POST /api/clipboard/copy_item {"file_path":"..."} History’s Copy: the SAVED item itself onto this device’s clipboard, synchronously (crate::clipboard::copy_item), never broadcast. A desktop gets a file reference (pictures included) noted as our own write, so the poll does not re-send it; a phone gets the picture. 200 {"copied_as": "picture"\|"file_reference"}. Errors: 400 outside the sync dir / received_files_dir — the same answer whether or not the path exists (no existence oracle), and a .. component is refused before any filesystem call; 404 a missing item inside them; 422 this clipboard cannot hold the item; 500 the clipboard write was refused. Bridge-secret gated
POST /api/app/notes/take – read-and-clear the notes an app extension left for the app (one mailbox, <AppGroup>/app-notes/<kind>.json): [{"kind":"share_handoff",…}]; a wait_for_sender note becomes the waiting OFFER (expected in the offers projection) and is not answered. [] on a host with no App Group. Bridge-secret gated
POST /api/sync/pause – queues PauseSync
POST /api/sync/resume – queues ResumeSync
POST /api/sync/toggle – queues ToggleSync
POST /api/paste_path – saves clipboard to sync dir, writes path, simulates paste
GET /api/paste – reads the LOCAL OS clipboard; {"type":"text","text":"...","size":N} for text, {"type":"<mime>"\|"file/<name>","size":N} for image/file; 503 when the clipboard is unavailable

Validation:

  • RetryTransferRequest.file_path: 1..4096 chars
  • CopyToClipboardRequest.text: 1..1_000_000 chars
  • /api/clipboard/copy_path and /api/clipboard/copy_item take the same FilePathRequest.file_path: 1..4096 chars

About GET /api/paste (src/http/handlers_sync.rs, response shape owned by crate::cli_support::paste_response):

  • it answers “what would Ctrl+V paste on this machine” — the local OS clipboard, read through crate::clipboard::create_clipboard()
  • it is deliberately not the clipboard_cache used by the Ctrl+B path/content swap (that cache holds swap content with a 5-minute TTL, a different question)
  • data bytes never cross this endpoint: only text is returned in full; image and file clipboards report type and size only. type is ContentType::as_str() (src/content.rs): "text" for text, the mime for an image (e.g. image/png), and file/<name> for a file
  • a headless / no-display daemon answers 503 with {"success": false, "error": "clipboard unavailable: <reason>"}

(The desktop’s Home page no longer shows this clipboard — see Home.)

Mobile clipboard bridge routes

These routes are the mailbox between a mobile front end and the daemon (src/http/handlers_sync.rs, backend crate::clipboard::bridge). The /android spellings are kept because the shipped Android APK (ClipboardBridge.kt) calls them; iOS uses the platform-neutral /bridge spellings. Both spellings hit the same handler.

The model behind them: a phone’s own clipboard is never mirrored (the platform forbids reading it in the background); the only way in from a phone is the share sheet (origin=share), and an arrival on a phone is an offer the person takes, never an automatic pasteboard write. See Phones and tablets.

Method Path (neutral) Shipped alias Body Response
POST /api/clipboard/bridge /api/clipboard/android {"origin"?:"system"/"share","text":"..."} {"success":true}; an origin=system post is answered 200 with {"success":true,"ignored":"a phone's own clipboard is not mirrored; use Share → Clipshot"} and nothing travels
POST /api/clipboard/bridge/image?origin=share /api/clipboard/android/image raw image bytes, mime from Content-Type (default image/png); origin rides in the query {"success":true}; same ignored answer without origin=share
POST /api/clipboard/bridge/file?name=<file name> /api/clipboard/android/file raw file bytes, 1..100_000_000; the name (URL-encoded) is judged once for both front ends — it decides what every receiver writes to disk {"success":true}, queues the file as a FILE clipboard item for the group (no origin gate: picking Clipshot in the share sheet is the consent)
GET /api/clipboard/bridge/pending /api/clipboard/android/pending – {"text": string or null} or {"image": {"path","mime"}}, plus the offer slots latest / latest_image (see /latest). Fetch-and-CLEAR for the text/image mailbox: on Android only the Kotlin shell polls it (it applies the item with setPrimaryClip)
GET /api/clipboard/bridge/latest – (neutral only) – The offer WITHOUT the mailbox: {latest, latest_image, expected} — the latest_inbound slots exactly as the pending answer carries them (one projection, latest_inbound::offers_json), each {seq, kind, size, from, path, arrived_at_ms, sent_at_ms, group_path, requested, taken} or null (sent_at_ms = the origin’s stamp used for the exact match; group_path = ~/.clipshot/sync/<group name> when that path names the item here, else null — the one fact a «Copy path» act is offered on; requested = the person already asked for THIS item before it landed; taken = the person took it from any surface, so the bar and the shade stop offering it); expected is an item announced but not landed yet ({kind, size, sent_at_ms, from, waiting: true}, shown «Waiting for », never takeable). Read-only: never touches the fetch-and-clear mailbox (pinned: N reads leave a parked take in place). The in-app offer bar's source on Android (iOS reads the same slots from its own `pending` poll). Answers on every platform (a desktop's slots are empty). Bridge-secret gated
POST /api/clipboard/bridge/take_latest – (neutral only; callers: the in-app offer bar on both phones — tap and foreground pass — and the Android shade notice’s «Copy» / «Copy path» action) {"slot":"latest"/"latest_image","only_if_newer"?:bool,"take_as"?:"path"} (defaults: false, the item) Phones only: the person’s TAKE of an offered arrival (clipboard::take). only_if_newer=false = tap (apply whatever was written before); true = foreground pass (apply only an offer newer than the last own pasteboard write; a tie keeps the own write). take_as is the person’s ACT: "path" puts the item’s GROUP path ~/.clipshot/sync/<group name> (the secondary «Copy path», the Android shade’s included) and answers not_pasteable when nothing there names the item; absent (or any other value) puts the ITEM itself — «Copy image» / «Copy file». Ignored for text. {applied, kind, name, reason} with reason ∈ applied/kept_own/no_offer/not_pasteable — all 200, applied=false for the last three (facts about the offer, not failed requests; not_pasteable = a hub-relayed text with no saved file, or a non-image FILE on iOS which stays in Files → Received). 400 only for an unknown slot and for a DESKTOP build (“not a phone”, decided before the offer is looked at). 500 = the pasteboard refused the write. Every applied take stamps last_local_write and pre-registers its hash so the daemon’s poll does not re-broadcast it
POST /api/clipboard/capture – – {"requested": bool} on iOS; 400 on every other platform
GET /api/clipboard/capture/status – – {"state": "idle"/"pending"/"captured"/"refused"/"empty"}

Notes:

  • origin is parsed by crate::mobile_ingest::IngestOrigin: "share" means the user deliberately picked Clipshot in the share sheet, anything else (including a missing value) is read as "system". Only share is ingested (crate::mobile_ingest::may_ingest_from_phone) — for text and image alike; an older bridge still posting origin=system gets the graceful ignored, not an error. There is no setting for this any more (the former mirror_mobile_clipboard is gone: the platform decides it).
  • GET .../pending is fetch-and-clear. A pending image is reported before pending text, because the text setter already clears the image when both were set. On non-mobile platforms it answers 200 with {"text": null}.
  • POST /api/clipboard/capture asks the daemon to read the platform clipboard into the mailbox. It exists only where the clipboard cannot be read unattended (iOS); every other platform answers 400. requested: false means a capture was already in flight. The outcome arrives separately from GET /api/clipboard/capture/status, because the POST cannot say what was read — the read may still be behind a system prompt.

Validation:

  • bridge text: 1..1_000_000 bytes (text size 1..1_000_000); a share-origin post whose text is only whitespace is refused with blank share
  • bridge image: 1..25_000_000 bytes (image size 1..25_000_000)
  • image mime is lowercased, trimmed and stripped of parameters; it must start with image/ and must not be the wildcard image/*

File transfer routes (Pro)

Method Path Body Response
POST /api/file/send {file_path, peer_addrs: [[addr, name]]} {job_ids}
GET /api/file/transfers – FileTransferJob[]
POST /api/file/reorder {job_id, direction} {ok}
POST /api/file/cancel {job_id} {ok}
POST /api/file/accept {request_id} {path}
POST /api/file/decline {request_id} {ok}
GET /api/file/pending – PendingInfo[]

No file size limits. Files < 8 MiB use single push, ≥ 8 MiB use chunked streaming with resume (the same big-item threshold as clipboard items). Pro gate: sending requires Pro subscription (bypass with CLIPSHOT_TEST_HOOKS=1).

Pair routes (pair-v2, src/http/handlers_pair_v2.rs)

Unified pairing API for GUI, browser UI, and CLI. Code is a 6-digit numeric string.

Method Path Body Response
POST /api/pair/generate – {code, digits: null, expires_in_secs}
POST /api/pair/join {code} {digits, peer_addr, joiner_eph_pub_hex}
POST /api/pair/poll {code} {ready, joiner_eph_pub_hex, joiner_iroh_addr}
POST /api/pair/confirm {code, joiner_eph_pub_hex, joiner_iroh_addr?} {digits}
POST /api/pair/abort {code} {aborted, code}

Notes:

  • code is a numeric 6-digit string (^[0-9]{6}$)
  • generate stores an ephemeral X25519 keypair and returns digits: null (unknown until joiner arrives)
  • join performs X25519 DH, pushes joiner eph_pub to portal, adds generator as peer
  • poll is called by generator to detect joiner arrival via portal
  • confirm computes DH with joiner eph_pub, derives 4 confirmation digits, adds joiner as peer (reciprocal)
  • abort cleans up local keypair; subsequent confirm returns 404

Which group the devices end up in (token direction). Pairing also decides group membership, and the direction follows who has a group, not who pressed Generate (src/pair_v2/flows.rs):

  • a generator with no group_token mints a provisional one and says so in the offer (PairOffer.provisional; mDNS TXT key pv=1, portal field provisional). An offer from an older generator has no flag and therefore reads as established
  • the joiner’s adoption_decision(current, offered, provisional) returns:
    • Adopt — the offered token is established, or we have no group (two fresh devices still meet in one group). Re-pairing into a different real group is intentional
    • Keep — we already have a group and the offer is provisional; the local token stays and group_switch in the join response is null
    • EnsureLocal — the offer carries no token at all; just make sure a local one exists
  • a joiner that kept its group sends that token as joiner_group_token in the portal join push, so the fresh generator can join it
  • poll notes that token daemon-side only — the poll response to the UI is unchanged ({ready, joiner_eph_pub_hex, joiner_iroh_addr})
  • confirm applies generator_token_to_persist(provisional, joiner_token): a provisional generator persists the joiner’s token when one arrived, otherwise its own provisional one; an established generator persists nothing and ignores any token the joiner sent

This is the rule that prevents the 2026-09-17 incident, where a desktop joined a code generated by a freshly installed phone, adopted its provisional token and left its own (Pro) group. It is complete only when BOTH devices run this build and the portal stores and echoes the two new fields — a portal that drops them makes every offer read as established.

Environment routes

Method Path Body Response
GET /api/environment/status – ApiResponse<{ was_healed: bool, mode: "daemon" \| "gui" }>
POST /api/environment/relaunch-gui – { spawned: true } or 500 error

Tauri command reference (src/gui/commands.rs)

The desktop GUI talks to the daemon over Tauri IPC instead of HTTP. src/gui/commands.rs defines 47 #[tauri::command] functions; 46 of them are registered in the invoke_handler list in src/gui/tauri_app.rs. toggle_clipboard_mode is defined but not registered — it is called internally from the Rust hotkey path and is not reachable from frontend IPC.

One more command lives outside that file: copy_item (src/gui/commands_copy.rs, split out because commands.rs is at its size budget) and it is registered. So repo-wide there are 48 #[tauri::command] definitions and 47 registered commands.

The browser UI reaches the same functionality through the HTTP routes above; see web/src/lib/api/endpoints.ts for the fallback mapping.

Status / peers

Command Params Returns
get_daemon_status – DaemonStatusFull
get_peers – FullPeerInfo[]
add_peer name: string, address: string, password?: string ()
remove_peer peer_id: string ()
reconnect_peer name: string ()
reconnect_all_peers – ()
update_peer address: string, name?: string, addresses?: string[], auth_code?: string \| null ()
connect_to_peer peer_id: string boolean
activate_peer address: string ()
deactivate_peer address: string ()

Discovery / share

Command Params Returns
get_network_interfaces – NetworkInterface[]
export_node_url node_name: string, node_id: string, interfaces: NetworkInterface[] string (signed clipshot:// URI)
add_peer_from_uri uri: string ()
discover_peers – DiscoveredPeer[] (mDNS, empty when the feature is off)

Activity / settings / pairing

Command Params Returns
get_activity limit: number ActivityEvent[]
get_settings – Settings
save_settings settings: Settings () (full struct required; also updates the hotkey listener)
replace_group_token token: string () (intentional token replacement + hub reconnect)
pair_generate – {code, digits: null, expires_in_secs}
pair_join code: string {digits, peer_addr, joiner_eph_pub_hex}
pair_poll code: string {ready, joiner_eph_pub_hex, joiner_iroh_addr}
pair_confirm code: string, joiner_eph_pub_hex: string, joiner_iroh_addr?: string {digits}
pair_abort code: string {aborted, code}

On pair_confirm argument naming: the Rust signature is snake_case (joiner_eph_pub_hex, joiner_iroh_addr — src/gui/commands.rs), and that is the canonical spelling. The browser HTTP fallback additionally accepts the camelCase forms as aliases — web/src/lib/api/endpoints.ts reads args?.joiner_eph_pub_hex ?? args?.joinerEphPubHex (and likewise joiner_iroh_addr ?? joinerIrohAddr) before posting to /api/pair/confirm. So callers may pass either spelling from the frontend, while the wire body and the Tauri command always use snake_case. CLAUDE.md documents the camelCase form for this command; both refer to the same parameter.

Sync / transfers / history

Command Params Returns
toggle_clipboard_mode – "image" \| "path" — not registered in invoke_handler, internal Rust hotkey path only
paste_file_path – ()
pause_sync – ()
resume_sync – ()
get_transfers – TransferInfo[]
get_history filter?: string, limit: number HistoryEntry[]
retry_transfer file_path: string string
copy_to_clipboard text: string () (local copy + broadcast)
copy_file_path path: string () (local clipboard only)
copy_item path: string "picture" \| "file_reference" — History’s Copy (src/gui/commands_copy.rs, a thin wrapper over clipboard::copy_item run on spawn_blocking); local clipboard only, never broadcast. On error the returned string is the toast text (This item is no longer on this device, Only items Clipshot saved can be copied, …)

File transfer (Pro)

Command Params Returns
send_file file_path: string, peer_addrs: [string, string][] SendFileResponse { job_ids }
get_file_transfers – FileTransferJob[]
reorder_file_transfer job_id: number, direction: string () ("up"/"down", queued jobs only)
cancel_file_transfer job_id: number ()
accept_incoming_file request_id: number AcceptIncomingFileResponse { path }
decline_incoming_file request_id: number ()
get_pending_incoming_files – PendingInfo[]

OS / shell / update / permissions

Command Params Returns
open_containing_folder path: string ()
open_external_url url: string () (only http:// and https://)
restart_app – ()
check_for_update – string \| null
install_update – ()
check_permissions – PermissionInfo[]
open_permission_settings permission: string ()
restart_app_for_permissions – ()

Test-only daemon HTTP routes (src/http/handlers_test.rs)

These require CLIPSHOT_TEST_HOOKS=1 or return 403.

Method Path Body Response
GET /api/test/clipboard – clipboard snapshot: text returns type/data/size, binary returns type/size
POST /api/test/clipboard/text raw UTF-8 text bytes writes the body onto the platform clipboard as text; returns the byte count, 400 when the body is empty or not valid UTF-8, 500 when the clipboard write fails. Raw body, not JSON: the hook’s whole job is to hand bytes to the platform clipboard
POST /api/test/clipboard/image raw PNG bytes writes the body to the local clipboard as image/png; returns the byte count, 400 on empty body
POST /api/test/reset optional {"restore_peer_name"?,"restore_peer_addr"?,"max_peers_free"?} queues reset-state command
POST /api/test/kill_iroh_listener – kills the active iroh listener (forces iroh restart on next tick)
POST /api/test/set_pro {"is_pro": bool} sets Pro state for testing
POST /api/test/notice_seal {"text": "...", "sent_at_ms": u64} seals text exactly as a publish of it would (notice_content::extra_for_publish under the daemon’s current group_token, AAD identity text\|<len>\|<sent_at_ms>) → {ct, kind, size, sent_at_ms}, the payload the iOS Simulator lane hands to simctl push. 400 when no group token is configured (no group token: nothing to derive a key from) or the text is empty / longer than NOTICE_CONTENT_MAX (2600 bytes)
GET /api/test/peer_registry_dump – full peer registry diagnostic snapshot
GET /api/test/reconnect_state – per-peer reconnect state snapshot (failures, cooldown deadlines)
GET /api/test/removal_state – peer-removal gate state snapshot
GET /api/test/transport_health – transport health metrics (supervisor view)
GET /api/test/iroh_metrics – full EndpointMetrics from iroh registry
GET /api/test/iroh_metrics_openmetrics – OpenMetrics text format of iroh metrics
GET /api/test/connection_paths?peer=<iroh-url> – per-peer connection path snapshot
GET /api/test/connection_paths_all – bulk connection paths for all peers

Tauri hotkey test-hook HTTP server

This is a separate tiny_http server started from src/gui/tauri_app.rs only when CLIPSHOT_TEST_HOOKS=1.

Defaults:

  • bind: 127.0.0.1:${CLIPSHOT_TEST_HOOKS_PORT:-18181}

Routes:

Method Path Body Response
GET / POST /api/test/health – daemon readiness (200 ready or 503 starting)
GET / POST /api/test/sync_enabled – current sync_enabled bool
GET / POST /api/test/hotkey_metrics – queue/drain latency metrics
GET /api/test/native_listener_health – native hotkey listener health
GET /api/test/icon_state – { state, state_raw } from global sync atomic
GET /api/test/native_key_events – captured native key event snapshot
POST /api/test/native_key_events – clears captured native key events
POST /api/test/hotkey_toggle optional X-Clipshot-Source header emits hotkey-toggle-sync into app event loop
GET /api/test/status – daemon status snapshot
GET /api/test/peers – peer list snapshot
GET /api/test/transport_health – transport health metrics
POST /api/test/file/send {file_path, peer_addrs} enqueue file transfer (Pro gate)
GET /api/test/file/transfers – file transfer queue snapshot
GET /api/test/file/pending – pending incoming files
POST /api/test/file/cancel {job_id} cancel queued file transfer
POST /api/test/file/reorder {job_id, direction} reorder queued file transfer
POST /api/test/file/accept {request_id} accept incoming file
POST /api/test/file/decline {request_id} decline incoming file
GET / POST /api/test/settings GET: read settings; POST: partial update settings read/write for E2E
POST /api/test/set_pro {is_pro: bool} set Pro state for testing

Portal-side endpoints (not local daemon)

The following routes are served by the Clipshot Portal (clipshot.cc), not by the local daemon HTTP API. They are listed here for completeness.

Pair v2 broker (/api/pair/v2)

The portal is one of the transports PairBroker races (the other is mDNS). Every local /api/pair/* route above depends on these: join pushes to them, poll reads from them (src/pair_v2/discovery/portal.rs, 12 s timeout per call).

Method Path Body Response
POST /api/pair/v2 {code, eph_pub_hex, iroh_addr, ttl_secs, group_token?, provisional?} 200 / 201 / 409 (already exists — treated as idempotent)
GET /api/pair/v2/{code} – {code, eph_pub_hex, iroh_addr, expires_at, group_token?, provisional?}; 404 when unknown/expired
POST /api/pair/v2/{code}/join {joiner_eph_pub_hex, joiner_iroh_addr?, joiner_group_token?} 200; 404 when the code is unknown
GET /api/pair/v2/{code}/confirm – {ready, joiner_eph_pub_hex?, joiner_iroh_addr?, joiner_group_token?}

Notes:

  • provisional is sent only when true and group_token only when present; both are serde(default) on read, so an older portal that drops them reads as “established” — see the token-direction notes under the local pair routes above
  • joiner_group_token is sent only when the joiner kept its established group against a provisional offer, and must be stored and echoed on GET /api/pair/v2/{code}/confirm for the generator to see it
  • the generator polls the confirm route until ready is true

Setup sessions (device auth flow)

Method Path Body Response
POST /api/setup/sessions – { session_id, url, expires_in }
GET /api/setup/sessions/:id – { status: "pending"\|"completed"\|"expired", group_token? }
POST /api/setup/sessions/:id/complete { group_token } (requires JWT) 200

Used by curl | bash install script and GUI “Create Account” button. Session expires in 10 minutes.

OAuth

Method Path Notes
GET /api/auth/google Redirect to Google OAuth. Optional ?state=SESSION_ID for device auth.
GET /api/auth/google/callback Google callback — creates/finds user, issues JWT.
GET /api/auth/github Redirect to GitHub OAuth.
GET /api/auth/github/callback GitHub callback.

Back to top

Clipshot — P2P clipboard sync across your devices.

This site uses Just the Docs, a documentation theme for Jekyll.