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
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)
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: string | nullpath_rtt_ms: number | nulllast_reachable_secs: number | nulllast_indirect_secs: number | nulldata_plane_state: string | null—"ok"when connected and outbound push liveness is healthy;"degraded"when quarantined by push failures; 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_typenamesize_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: stringstatus: "queued" | "sending" | "complete" | "failed" | "cancelled"transferred_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 |
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 |
| 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 |
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_shortcutpeer_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 |
| POST | /api/copy_to_clipboard | {"text":"..."} | queues local clipboard write + broadcast |
| 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 |
Validation:
RetryTransferRequest.file_path: 1..4096 charsCopyToClipboardRequest.text: 1..1_000_000 chars
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 <50MB use single push, ≥50MB use chunked streaming with resume. 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
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.
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) |
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/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 |
| 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.
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. |