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 OPTIONSgets204with 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
Hostheader is admitted (HTTP/1.1 browsers always send one, so the rebinding vector is unaffected, while HTTP/1.0 tooling such asdoctor’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 asCLIPSHOT_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://orhttps://origin whose host islocalhost,127.0.0.1or[::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: boolsync_enabled: boolnode_id: stringlisten_addr: stringconnected_peers: numbertotal_peers: numbernode_name: stringuptime_secs: numbershare_uri_expires_secs: numberiroh_enabled: booliroh_addr: string | nullmessages_sent: numbermessages_received: numberhub_connected: boolhub_url: string | nullhub_limit_reason: string | nullhub_limit_current: number | nullhub_limit_max: number | nullhub_limit_manage_url: string | nullhub_limit_message: string | nullsync_state: "idle" | "sending" | "receiving" | "no_peers" | "paused"app_version: stringtick_age_ms: numberpeer_relay_url: string | nullhome_relay_state: string | null—"not_configured" | "connecting" | "attached"; serde-default-saferelay_connecting_secs: number | null— seconds the home relay has been stuckconnecting; 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 operationdead_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-safedead_workers_detected_secs: number | null— seconds since the current dead-worker set last changed;nullwhen no worker is reported deadhub_disconnect_reason: string | null— set when the portal removed this device from its group (HubAction::Removed);nullin the normal casepending_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;nullwhen none pending. Lets doctor flag a removal that has not converged against an offline targetlocal_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;nullwhen none storedstale_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-defaulttrue, so an older daemon and the pre-first-status state keep today’s UI instead of flickering the act awaypaste_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:
nameaddressconnectedstatus: Online | Offline | Connectingnode_id: string | nulladdresses: string[]latency_ms: number | nulllast_sync: number | nulltransport_type: string | nulllimited: boolreject_reason: string | nulldevice_type: string | nulldevice_id: string | nulltransfer_bytes_done: number | nulltransfer_bytes_total: number | nulltransfer_speed_bps: number | nullconnection_type: "direct" | "relay" | null— the PATH the last health probe to this peer travelled (probe_peer_liveness_observedreadsconnection_diagnostics::snapshot(&conn).selected_kindright afteropen_bi, stores it withConnectionMonitor::note_path_kind, andpeer_ops.rsprojects it).nullwhile 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 asrelay; measured after the change onmain, 5 of 6 peers readdirectand the LTE phonerelaypath_rtt_ms: number | nulllast_reachable_secs: number | nulllast_indirect_secs: number | nulldata_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-safeconsecutive_push_failures: number | null— consecutive outbound clipboard-push failures, serialized only when ≥1; serde-default-safepush_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-safeconsecutive_inbound_stalls: number | null— consecutive inbound drain stalls observed byConnectionMonitor; serde-default-safe
ActivityEvent
idevent_typedescriptiontimestamppeer_namecontent_type?size_bytes?error_message?file_path?
TransferInfo
iddirection: "sending" | "receiving"peer_namepeer_addrcontent_typetotal_bytestransferred_bytesstatus: "in_progress" | "complete" | "failed"started_atcompleted_at?file_path?error_message?chunks_sentchunks_totalspeed_bytes_per_sec
HistoryEntry
identry_type:"text" | "image" | "file"— the item’s kind; a picture sent as a file isimageacts:("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/latestandpendingcarry the sameacts. A history row carries nogroup_pathfield: the path a Path act puts is resolved from disk at take time (take::form_of→sync::group_link::group_name_of), andgroup_pathis serialized only by the offer projections (/api/clipboard/bridge/latest, thependinganswer, the App Group export)namesize_bytespeer_namedirectionstatustimestampfile_path?content_typetransferred_bytes?total_bytes?chunks_sent?chunks_total?speed_bytes_per_sec?error_message?completed_at?
FileTransferJob
Fields returned by GET /api/file/transfers:
id: u64file_path: stringfile_name: stringfile_size: u64peer_addr: stringpeer_name: stringgroup_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_beforeandbusy_pollsare runtime-only (#[serde(skip)]) and never appear on the wirestatus: "Queued" | "Sending" | "Complete" | "Failed" | "Cancelled"— PascalCase, unlikeTransferInfo.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 matchestransferred_bytes: u64error: string | nullcreated_at: u64completed_at: u64 | null
PendingInfo
Fields returned by GET /api/file/pending:
id: u64file_name: stringfile_size: u64from_peer: string
EnvironmentStatus
Fields returned by GET /api/environment/status:
was_healed: bool— true if the daemon self-healed a wrong headless-on-desktop environmentmode: "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 charsAddPeerRequest.address: 7..255 charsAddPeerRequest.auth_code: optional string for password-protected peersUpdatePeerRequest.address: 1..255 charsAddPeerFromUriRequest.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/settingsignores agroup_tokenthat is a redaction placeholder (logged as a warning) and leaves the stored token untouched.POST /api/settings/replace_group_tokenrejects 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_enabledalias also accepted)auto_startnotificationssync_interval_msmax_file_size_mblisten_portenable_irohmax_peersauto_discoveruse_browser_uidirect_send_threshold_mbhotkeyhub_url(disk-preserved when already set — the save-path merge keeps the on-disk value)group_token(disk-preserved when already set — usePOST /api/settings/replace_group_tokenfor replacement)relay_urlmax_peers_freepeer_relay_enabledn0_relay_enabledsync_max_filessync_max_age_dayssync_max_size_mbbroadcast_queue_sizeauto_accept_filesreceived_files_dir
Not accepted via HTTP API:
is_pronode_passwordcentral_relay_enabled(hub-authoritative; serde aliasrelay_enabled)paste_path_hotkeypaste_shortcutpaste_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_originsthemepoll_interval_ms(usesync_interval_msAPI 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, defaultfalse; settable viasettings.toml, a native GUI full-settings save, orCLIPSHOT_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_000max_file_size_mb: 1..10240listen_port: 1024..65535max_peers: 1..1000direct_send_threshold_mb: 1..100max_peers_free: 1..1000sync_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 charsCopyToClipboardRequest.text: 1..1_000_000 chars/api/clipboard/copy_pathand/api/clipboard/copy_itemtake the sameFilePathRequest.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_cacheused 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
typeandsizeonly.typeisContentType::as_str()(src/content.rs):"text"for text, the mime for an image (e.g.image/png), andfile/<name>for a file - a headless / no-display daemon answers
503with{"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 |
| 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:
originis parsed bycrate::mobile_ingest::IngestOrigin:"share"means the user deliberately picked Clipshot in the share sheet, anything else (including a missing value) is read as"system". Onlyshareis ingested (crate::mobile_ingest::may_ingest_from_phone) — for text and image alike; an older bridge still postingorigin=systemgets the gracefulignored, not an error. There is no setting for this any more (the formermirror_mobile_clipboardis gone: the platform decides it).GET .../pendingis 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 answers200with{"text": null}.POST /api/clipboard/captureasks the daemon to read the platform clipboard into the mailbox. It exists only where the clipboard cannot be read unattended (iOS); every other platform answers400.requested: falsemeans a capture was already in flight. The outcome arrives separately fromGET /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); ashare-origin post whose text is only whitespace is refused withblank 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 wildcardimage/*
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:
codeis a numeric 6-digit string (^[0-9]{6}$)generatestores an ephemeral X25519 keypair and returnsdigits: null(unknown until joiner arrives)joinperforms X25519 DH, pushes joiner eph_pub to portal, adds generator as peerpollis called by generator to detect joiner arrival via portalconfirmcomputes DH with joiner eph_pub, derives 4 confirmation digits, adds joiner as peer (reciprocal)abortcleans up local keypair; subsequentconfirmreturns 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_tokenmints a provisional one and says so in the offer (PairOffer.provisional; mDNS TXT keypv=1, portal fieldprovisional). 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_switchin thejoinresponse isnull - 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_tokenin the portal join push, so the fresh generator can join it pollnotes that token daemon-side only — the poll response to the UI is unchanged ({ready, joiner_eph_pub_hex, joiner_iroh_addr})confirmappliesgenerator_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:
provisionalis sent only whentrueandgroup_tokenonly when present; both areserde(default)on read, so an older portal that drops them reads as “established” — see the token-direction notes under the local pair routes abovejoiner_group_tokenis sent only when the joiner kept its established group against a provisional offer, and must be stored and echoed onGET /api/pair/v2/{code}/confirmfor the generator to see it- the generator polls the
confirmroute untilreadyistrue
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. |