Legal
Privacy Policy
Plain-English summary of what ShotSelect does and does not do with your data. Written by photographers, for photographers — but it's still the document we'd rely on in a dispute.
1. Who we are
ShotSelect ("we", "us") is a desktop application for macOS that helps photographers cull and rate large shoots locally. The app is built and operated by the developer behind shotselect.app. Contact: hello@shotselect.app.
2. Photos and image content
Your photos stay yours. ShotSelect reads images from folders you open and writes XMP sidecar files alongside them. We do not upload your photos, thumbnails, EXIF data, derivatives, embeddings, or face data to any server under the free tier.
-
AI features run on-device. Semantic search uses a local SigLIP model
bundled with the app. Blur detection, closed-eye detection, object detection, emotion,
and face grouping (when enabled) run via
onnxruntime-nodeon your Mac. - No model training on your images. We do not feed your photos to any model, ours or anyone else's.
- Feedback attachments are opt-in. If you use Send Feedback and attach a screenshot, PDF, text file, CSV, JSON, or log, that file is uploaded with your message because you explicitly selected it. We cap attachments to three small files and never attach photos, screenshots, logs, paths, or diagnostics automatically.
-
XMP sidecars only. Ratings, picks, color labels, and keywords are
written to
.xmpfiles next to your originals. We never modify the original RAW or JPEG files.
3. Client galleries and share links
When you deliberately share a gallery, that content and a small amount of information about the people you shared it with is stored on our servers so the gallery can work. Nothing here happens unless you create a share link.
- Who you invited. When you send a gallery invitation by email, we store that email address so the invitation can be delivered, so re-sending reaches the same person, and so you can see who opened the gallery instead of an anonymous “Reviewer”. It is used for nothing else — no marketing, no profiling, and it is never shared with anyone but you.
- What a reviewer tells us about themselves. Your client may optionally give a name and an email address when they make their first pick. It is optional, never verified, and never required to view or review a gallery — declining changes nothing except that you see a placeholder label instead of their name. We use it only to show you whose picks are whose, and to recognise the same person when they open the gallery on a second device.
- Gallery activity. We record that a link was opened, a photo was viewed or downloaded, and picks or comments were submitted, so you can see progress. We store a per-browser identifier for this; it is not linked to any advertising identity.
- Deleting a link deletes it. Removing a share link removes its photos, its invitations, its reviewer records, and its activity from our servers. See Retention.
4. License keys
For paid tiers (when offered), the license server stores only what's needed to authorize the seat:
- The email address you purchased with
- The license key, expiry date, and seat status
- Timestamps of activation events
License validation is signed locally; the app does not phone home on every launch.
5. Anonymous diagnostics (Tier A)
By default, ShotSelect sends a small, anonymous session ping at startup. This helps us know whether updates are installing, which macOS versions to support, and how many people are using the app — without identifying anyone.
Each ping contains exactly five fields:
-
device_id— a random UUIDv4 generated on first launch and stored on your Mac. It is not a hardware fingerprint and is not derived from any identifying value. Turning diagnostics off deletes this file. app_version— e.g.1.3.0os_version— e.g.darwin 14.5arch—arm64orx64launch_count— how many times the app has been opened on this device-
opened_folder,culled,exported,shared— four yes/no flags recording whether each of those things has ever happened on this install. Not when, not how many times, not in what order, and never what was opened. They exist so we can tell whether people who install ShotSelect actually get to use it, without asking anyone to switch on the detailed usage reporting in section 6. -
library_bucket— a coarse band for the largest folder you have opened, one of<100,100-1k,1k-10kor10k+. Never an exact count, and never the folder's name or path.
What we do not send in Tier A:
-
No IP address — our endpoint reads only Cloudflare's two-letter country code header
(
CF-IPCountry, e.g.US,JP) for coverage stats;CF-Connecting-IPis never read and the IP itself is never written to our database. - No file paths, folder names, photo metadata, or filenames
- No email, name, license key, or any other identifying data
You can preview the exact JSON that would be sent in
Settings → Privacy → "See exactly what gets sent". You can turn
diagnostics off at any time and we'll stop sending and delete the local
device_id.
6. Usage data (Tier B) — on by default, turn off any time
ShotSelect sends counts of which features you use (e.g. how many
photos you culled, how many times you opened the search panel, when a session is removed
from history or that removal is undone). Each event carries the same five Tier A fields
plus a fixed event name from a short whitelist and bucketed values (e.g.
<100, 100-1k) — never raw counts that could uniquely identify
a session, and never folder paths or filenames.
The whitelist and the buckets are enforced server-side; arbitrary event names or raw values are dropped at intake. This is on by default so we can see which features photographers actually reach for. Turn it off any time in Settings → Privacy; the app also tells you it is on, and offers a one-click off, after your first export.
The bucketed dimensions we collect (no raw numbers ever leave your Mac):
-
Photo counts — bucketed into
<100,100-1k,1k-10k,10k+. -
Workflow duration — time spent in a phase, bucketed into
<1min,1-5min,5-30min,30min+. -
Performance latency — how fast folder open, photo navigation, and first
decision feel, bucketed into
<100ms,100-500ms,500ms-2s,2s+. This lets us compare ShotSelect's speed against competitors' published claims at the bucket level — your individual timings never ship. -
Cull throughput — decisions per minute when you're culling, bucketed
into
<10,10-30,30-60,60+. Validates the "keyboard-first" speed claim against real sessions. -
Keyboard shortcuts — named action (e.g.
like,reject,star) when you press a culling key. Never a raw keycode; never the photo it acted on.
Examples of events on the whitelist (the full list lives at
worker-telemetry/telemetry.js in the source repo and is enforced at the
server):
-
folder.opened,cull.decision— how many photos a session contained; each keep / reject keystroke carries a boundeddecisionprop (literallykeeporreject) so we can compute the keep-rate. No filenames, no folder paths, no per-photo data. Folder opens may also carry boundedentry_path(menu,drag_drop,recent,device,resume,cli) andmedia_type(photo,video,mixed). -
cull.session_complete,cull.session_abandoned— whether a culling session finished (every photo reviewed) or was left incomplete, plus bucketed counts of decisions made and time spent. No folder paths, no filenames, no timestamps beyond the request timestamp. -
ai.suggested_select_agreement— whether the photographer agreed with the app’s suggested Selects in a session, as a bucketed percentage band and a bucketed count of ruled-on suggestions. No photo content, no filenames, no scores per photo. -
cull.precull_configured— fired when the AI pre-cull setup wizard is completed, carrying only a coarse assistance bucket (lenient,balanced, orstrict). No slider values, no shoot names, no face data — the assistance only tunes suggestions and never makes culling decisions. -
cull.precull_skipped— fired when the AI pre-cull setup wizard is dismissed via “Skip, review all”. No payload — just the fact that setup was skipped, so we can tell whether the wizard is worth keeping. -
cull.recull— fired when the “Re-cull with new rules” button reopens the pre-cull wizard from the results panel. Carries no properties. -
cull.burst_keep_best— fired when the inspector’s “Keep best, reject the rest” one-tap burst action is used. Carries no properties — just that the burst best-of shortcut was taken. -
perf.first_decision_latency,perf.folder_scan_ms,perf.photo_nav,perf.thumbnail_first_paint,perf.thumbnail_cache_hit_rate,perf.timeline_build_ms,perf.burst_group_ms,perf.face_group_ms_per_1k,perf.ai_detection_ms_per_photo— bucketed latency and cache-health counters for the activation funnel, folder scan, navigation, thumbnails, timeline building, burst grouping, face grouping, and per-photo AI analysis speed. Lets us see whether the workflow is getting faster or regressing — no folder names, no filenames, no raw face data, no exact timings. -
feature.view_mode,feature.filter_applied,feature.focus_mode,feature.undo,feature.timeline_event_selected,feature.inspector_tab— which view mode you switched to (loupe / grid / timeline / compare), whether you entered focus mode, which filter you toggled (blurry / closed-eyes / duplicates / rating / star / tag / face / emotion / media kind / burst), whether you used undo, whether you scoped culling to timeline events, and which inspector tab you opened (info / ai / session / comment). Tells us which features are used, which to retire. No photo identifiers, no tag content. -
feature.filter_saved— that you saved a filter preset. A bare usage count; the preset's name and its filter contents are never sent. -
export.deliverable_built— that a local deliverable (PDF contact sheet, HTML gallery, or social pack) was built. We send only which kind and a bucketed photo count; filenames, folder paths, and the deliverable contents are never sent. -
onboarding.profile_completed— that the first-run profile wizard was completed. A bare usage count; your name, studio name, and role are never sent. -
export.started,feature.export,export.duration,export.format_chosen,export.delivery_preset,export.xmp_written,export.reveal_clicked,export.buckets_delivered,export.pair_expanded,feature.clip_search,export.cancelled,export.failed— whether you started and completed export, how long it took in a bucket, which manifest format and photo bucket you chose, whether XMP sidecars were written, whether you revealed the destination, a bucketed count of how many delivery buckets carried their own format matrix and how many RAW+JPEG pairs were shipped as both halves, whether you ran a search, and whether you backed out of export before completing or an export run failed. Export failures carry only a boundedreasonsuch aspermission,disk_full,write_failed,user_cancelled,validation, orunknown. -
error.media_load_failed— a bounded media-load error type (decode,missing,permission,network,unsupported,timeout). No filenames, paths, codecs, or image data are sent. -
device.backup_started,device.backup_completed,device.backup_failed— backup operation health for removable-device workflows. We send only device class (removable,local,network,unknown), destination-count bucket, transfer-speed bucket, time bucket, count bucket, and failure reason. We do not send volume names, serial numbers, paths, filenames, exact byte counts, or exact transfer speed. -
device.backup_renamed— that a backup renamed the copied frames to a name you chose, plus the device class and a bucketed file count. The name itself is your content and is never sent, and neither are the original or resulting filenames. -
source.identity_failed— a folder you chose could not be matched to the drive it lives on, so no project could be attached to it. We send only a bounded reason (offline,unreadable,identity_unavailable,invalid_path,scope_outside_container). We do not send the folder path, the mount point, the volume name, the volume UUID or serial, or the diagnostic text. -
feature.burst_collapse,feature.burst_pick,feature.burst_grouped,feature.burst_gap_changed,feature.burst_detection— whether you toggled burst grouping, how big the resulting groups were, whether you tuned the gap, and whether the master burst-detection switch is on or off. Counts only; no filenames, timestamps, or camera identifiers are included. -
feature.face_group_split— that you split mis-grouped frames out into a new person. We send only a bucketed count of how many frames were split; names, faces, crops, embeddings, filenames, and paths are never sent. -
feature.face_group_pin— that you marked or unmarked a person as a project priority. We send only whether the mark was turned on or off; names, faces, crops, filenames, and paths are never sent. -
face.reference_search— whether you searched for all photos of a person from a reference photo. We send only that the search ran; the reference image, the faces, embeddings, filenames, and paths never leave your device. -
feature.raw_crisp_toggled,feature.raw_colour_match_toggled— whether you enabled "See the real RAW" (full-resolution crisp render on dwell) or "Match camera colours" (colour-matching the RAW develop to the camera JPEG). We send only that the setting was toggled; no filenames, paths, or photo content leave the device. -
feature.yolo_bulk_add— you used the "Add N to selects" button in the object-search results panel to batch-select all matched photos. No query text, filenames, or object labels are sent. -
tag.bulk_apply— you applied a tag to multiple photos at once by batch-tagging photos by detected emotion. We send only that the action occurred; no tag text, filenames, or photo identifiers leave the device. -
cull.collection_applied— you clicked an AI collection row in the cull sidebar (e.g. Bride's side, Cake & toasts). We send only that the action occurred; no collection name, filenames, or photo identifiers leave the device. -
project.member_added,project.member_removed,review.decision_recorded— whether a teammate was added to a studio project and whether a review decision (approve or request-changes) was recorded. We send only the role bucket and the decision kind; emails, names, user ids, filenames, and project names are never sent. -
project.state_pushed,project.state_pulled,project.sync_conflict,project.sync_failed— that your project state (decisions, ratings, tags, comments, assignments, and AI groupings) synced to or from your other signed-in devices through Cloudflare. We send only a bucketed count of how many changes moved (and, on a failure, a bounded reason such asnetworkorserver); the decisions themselves, filenames, paths, comment text, face data, names, and project names are never sent. -
project.assets_uploaded— that you backed up a project's photos to the cloud. We send only a bucketed count of how many photos were uploaded; filenames, paths, image content, byte counts, and project names are never sent. -
project.hydrated— that you opened a shared project by an invite link. We send only a bucketed count of how many photos were downloaded; filenames, paths, image content, and project names are never sent. -
project.invite_created— that you created a project share link. We send only a bare usage count; the role, the teammate's email, the invite token, and the project name are never sent. -
client_link.created— that a client review/delivery link was created. We send only a bucketed count of how many photos the link contains; the client's name, the event name, filenames, and the link URL are never sent. -
client_link.picks_applied— that a client's returned picks were applied. We send only a bucketed count of how many picks were applied; which photos, the client's name, and the link URL are never sent. -
client_link.mode_promoted— that a review link had downloads enabled (promoted to a delivery link). A bare usage count; the link URL, the client's name, and filenames are never sent. -
client_link.revoked— that a shared link was revoked. A bare usage count; the link URL, the client's name, and filenames are never sent. -
client_link.downloaded— that a client downloaded delivered photos, as a bucketed count of downloads. Confirms the delivery download feature works; the link URL, the client's name, IP, and filenames are never sent. -
gallery.link_published— that a delivery gallery (downloads enabled) went live. A bare usage count; the link URL, the client's name, and filenames are never sent. -
delivery.gallery_created,delivery.passcode_regenerated,delivery.tier_enabled— that you configured and sent a tiered client gallery, regenerated its passcode, or turned on a download tier. Bare usage counts; the only attached detail is which tier (web / high-res / original) — never the passcode, the link URL, the client's name, recipient emails, or filenames. -
notifications.opened,notifications.action_clicked— whether you opened the in-app notification center and whether you acted on a notification. Bare usage counts only; no notification text, ids, or contents are sent. -
spotlight.command_run— that you ran a command from the command palette. We send only that a command was run, never which command, its arguments, or any text you typed. -
backup.verified,device.format_completed— whether a backup passed integrity verification and whether an in-app card/drive format finished. Counts only; no volume names, serial numbers, paths, filenames, or byte counts are sent. -
project.opened,project.created,project.stage_advanced— studio-project lifecycle counts: a project was opened, created, or advanced to a new stage. We send no project names, ids, member details, or file paths. -
auth.code_requested,auth.signin_completed,auth.signup_completed,auth.signout— that a sign-in code was requested, a sign-in or account creation completed, or you signed out. Bare usage counts so we can tell the sign-in flow works; your email address, name, studio name, and any code or token are never sent with these events. -
link.created,comment.added,comment.resolved,review.approval_set,collab.member_assigned,team.event_assigned,team.assignment_cleared,team.pass_approved,team.changes_requested,collab.peer_verdict_synced,collab.pass_finished,collab.original_downloaded— collaboration gallery activity: a share link was created, a comment was added or resolved, a review approval state was set, a teammate was assigned, a collaborator's Select/Reject was received live by the lead, a collaborator marked their cull pass complete, or a collaborator downloaded an original file. Counts only; no link URLs, comment text, names, emails, user ids, filenames, or project names are sent. -
join.resolved— one pasted link or six-digit code was routed to a client gallery, a project invitation, a local session, or was rejected. We send only that bounded category; never the link, token, code, project, or invitation address. -
collab.session_started,collab.handoff_completed,collab.decisions_received,collab.ai_finding_actioned— a peer-to-peer culling session (over LAN, a portable drive, or a shared folder) began, a guest finished and handed the pass back, the lead folded a guest's returned decisions into the roster, or a human accepted or overrode an on-device AI suggestion. We send only bounded buckets: the role (host/guest), how the peer was discovered (mdns,qr,manual_code,portable_folder), the transport (lan,portable,drive), a time bucket for the handoff, a bucketed count of decisions received and whether they were reviewed first (auto/reviewed), and which AI finding (blur,eyes,duplicate) wasacceptedoroverridden. No peer names, device names, addresses, filenames, photo identifiers, or decision contents are sent. -
error.collab_resume_credential_missing— a resume attempt could not find an accessible saved credential. A bare count; no tokens, links, paths, or identifiers are sent. -
error.local_network_denied— that a local-network access permission prompt was declined (so we can tell when LAN features can't start). A bare count; no addresses, device names, or network details are sent. -
dataset.export_started— that a dataset export run was started. A bare usage count only; no filenames, paths, labels, or annotation content is sent. -
share.draft_prepared,share.draft_prepare_failed,share.draft_published,share.draft_publish_failed,share.upload_duration,share.upload_throughput,delivery.output_prepared— that a share draft finished preparing its photos, that it was published as a live gallery, or that one of those steps failed, plus how long the upload took and roughly how fast it ran. We send only bucketed values: a photo-count band, a duration band, a coarse upload-rate band such as1-5MB/s, which delivery tier the outputs were for (web,hr,original), and a bounded failure category such asnetworkorquota. No gallery names, client names, share links, tokens, passcodes, filenames, paths, photo content, byte counts, or error messages are sent. -
share.policy_migrated,client_access.opened,client_access.passcode_checked— that an older share link's settings were migrated onto the current sharing policy, and whether a client's gallery visit or passcode entry succeeded. We send only whether it passed or failed and a bounded failure category (for exampleexpired,revoked,passcode). Never the link, the token, the passcode itself, the client's name or email, an address, or anything the client viewed. -
invitation.previewed,invitation.accepted,invitation.declined— that an invitation was opened for review and whether it was accepted or declined. Bare counts only; never the invitation address, the project, the team, or any name or email. -
collab.lan_reconnected,collab.lan_permission_changed,collab.lan_stopped— that a local-network session was resumed, that a participant's access was changed, or that a session ended. We send only the role (host/guest) and the transport. No participant names, device names, network addresses, pairing codes, filenames, or decision contents are sent. -
rollout.boundary_failed— that one part of a staged feature rollout failed, so we can pause or reverse that part instead of a whole release. We send only which of the five areas it was (compact_nav,shared_workspace,draft_builder,client_policy,team_join_lan) and a bounded failure category. Never an error message, a stack trace, a file path, a link, or anything identifying the install. -
update.banner_shown,crash.handled— quality and reliability counters. -
home.session_resumed,session.removed,session.remove_undone— that a previous session was resumed from the home screen, or that one was removed from the list and whether that removal was undone. Bare usage counts; no folder names, paths, or session contents are sent. -
cull.compare_used,cull.star_rating_used,cull.color_label_used,feature.sort_applied,shortcut.used,cull.throughput— that you used compare mode, a star rating, a colour label, a sort, or a keyboard shortcut while culling, plus a bucketed culling-pace figure.shortcut.usedcarries only a boundedactionname (such aslike), never the photo it applied to. No filenames, ratings per photo, or label values are sent. -
cull.loupe_carried,cull.followed_newest— that the zoomed loupe was held across frames for a focus pass, and that the deck followed a newly arrived photo while you were parked on the last frame. Bare usage counts, sent at most once per culling session each; no filenames, paths, zoom levels, or image data. -
feature.tag_apply,tag.hud_opened,tag.created,tag.slot_used,tag.removed— that the tag panel was opened and that a tag was created, applied, assigned to a shortcut slot, or removed. Bare usage counts only — the tag text itself is never sent. -
feature.clip_index,search.result_clicked,search.by_photo,ai.detection_run— that a search index was built, that a search result was opened, that you searched the shoot by uploading a photo, and that an on-device AI pass ran.search.by_photocarries only a boundedsearch_outcome(matched,empty,index_missing, orno_face) and, on a match, a bucketedresult_bucket.ai.detection_runcarries only bounded values: whichtoolran (blur, eyes, faces, objects, emotion), arun_status, arun_source, a bucketedresult_bucket, and a boundedfailure_kindif it failed. Search queries are never sent, nor are filenames or detection results. -
face_grouping.cull_arrival_with_groups,face_grouping.filter_applied— whether you arrived in the cull view with faces already grouped, and whether you filtered by a group. A grouping run itself is reported byai.detection_runabove, like every other on-device analysis. No face data, embeddings, thumbnails, or person names leave your device — face grouping runs entirely on-device and only these counters are reported. -
workflow.phase_time,perf.folder_open— how long you spent in a givenphase(import,cull, orexport) in a time bucket, and how long a folder took to open paired with a bucketed photo count. Used to find slow paths. No folder names or paths are sent. -
update.download_started,update.installed,update.download_failed,update.skipped,update.notes_expanded,update.check_requested— that an update was downloaded, installed, skipped, failed to download, that you expanded its release notes, or that you asked the app to check for one. A failed download carries one word for the reason (for examplechecksum,network,disk_full) so we can tell a broken release from a local problem — never the error text, which contains file paths. Otherwise bare usage counts, used to understand version adoption. -
navigator.rail_toggled— that the left navigation rail was collapsed or expanded. A bare count, used to learn which width photographers keep. -
onboarding.faq_opened,onboarding.shortcuts_opened— that the FAQ or shortcuts reference was opened. Bare usage counts; no questions, answers, search terms, or content are sent. -
onboarding.checklist_step_done,onboarding.checklist_dismissed,onboarding.guides_opened— that a first-run checklist step was completed, that you dismissed the checklist, or that you opened the in-app guides. Bare usage counts; the step's label and the title of the guide you opened are never sent. -
settings.usage_on,settings.diagnostics_off— that you turned usage data on or anonymous diagnostics off. These let us honour and audit your own privacy choices. -
integration.lightroom_open,integration.lightroom_metadata_synced,integration.lightroom_cc_open,integration.bridge_open,integration.photoshop_open,integration.photoshop_retouch_detected,integration.photoshop_retouch_opened— that you handed photos off to an external editor, that sidecar metadata was synced back, or that an external retouch was detected and opened. Bare usage counts. No filenames, paths, catalogue contents, or edit data are sent, and ShotSelect does not read those applications' libraries. -
feedback.submitted— that in-app feedback was sent. This is a bare count recorded separately from the message: your feedback text is delivered through the feedback channel you explicitly submitted it to, and is never attached to this usage event.
7. Crash reports (Tier C) — opt-in
If a crash occurs and you choose to send a report, ShotSelect submits a scrubbed stack
trace, the app version, OS version, and architecture. Before sending we redact
/Users/<name>/, /Volumes/<name>/, and email
addresses. The server runs the same scrubbing again as defense-in-depth.
You choose how this works: Always send, Ask each time (default), or Never send.
8. Auto-updates
The app checks our update feed periodically. The check looks like a regular HTTPS request for a small static file hosted on Cloudflare R2 — there is no body identifying you, your license, or your install. Cloudflare's standard request logs (IP, timestamp, requested file) are subject to Cloudflare's privacy policy and we do not retain or correlate them.
9. Where data goes
Diagnostics, opt-in usage events, and opt-in crash reports are sent to a Cloudflare Worker we operate, and stored in a Cloudflare D1 database in our account. We do not share this data with any third party. We do not use any third-party analytics, advertising, or tracking SDK in the app or on this website.
In-app feedback messages, optional reply emails, and optional user-selected attachments are sent to a Cloudflare Pages Function and stored in our Cloudflare D1 feedback inbox. They are used only to debug issues, understand requests, and reply when you provide an email.
10. Retention
- Tier A session pings: aggregated weekly; raw rows pruned after 90 days.
- Tier B usage events: raw rows pruned after 180 days.
- Tier C crash reports: kept until resolved, then pruned after 365 days.
- Feedback messages and attachments: kept until reviewed or resolved, then pruned after 365 days.
- License records: kept for the lifetime of the seat plus 7 years (tax / accounting requirement).
11. Your rights
Because Tier A is anonymous (we have no map from device_id to you), there is
nothing to export or delete on a per-person basis — turning the toggle off is the
deletion. For license records, email
hello@shotselect.app with the address you
purchased with and we'll respond within 30 days.
If you're in the EU/UK or California, you have rights under GDPR / UK GDPR / CCPA respectively. We honor those rights regardless of where you are.
12. Children
ShotSelect is built for working photographers and is not directed at children under 13 (or 16 in the EU). We do not knowingly collect personal data from children.
13. Future paid tier
We may introduce paid features in the future (for example, hosted client review links). Any feature that involves uploading content off-device will be clearly marked, opt-in per use, and documented here before it ships. The free tier and its workflow will not be moved to a paid tier retroactively. The on-device guarantee in section 2 applies to the free tier permanently.
14. Changes
We'll update this page when something material changes, increment the version number, and surface an in-app notice on next launch. The change log below records every revision.
15. Contact
Questions, complaints, takedown requests, or privacy concerns: hello@shotselect.app.
Change log
See also: Terms of Use