Why this exists
Privacy is the one thing on this app that I will never let creep. No ads, no third-party analytics, no behavior profiling, no sale of data. That is the line. The risk with any line is that it slowly moves without anyone announcing it — a script gets added, a service gets wired in, a screen gets a tracker, nobody notices because the privacy page still reads the same.
This is the first monthly transparency report for Detroit Meets. The plan is simple: every month I will publish a public log of what changed on the data and platform side — what we collect, what we removed, who we rely on, and any government or legal request we received (this month: none). If a number or a third party shows up that should not be there, you will see it here first.
April was a busy one — we shipped on the App Store, the iOS and Android native shells went in, push notifications went live, and we closed the month with app version 3.0 — so this first report is on the longer side. Future months should be shorter.
Headlines
- Detroit Meets launched on the App Store on April 22. Same product, no signup, no ads, no behavior tracking. The native iOS app is currently at marketing version 3.0, build 19.
- iOS and Android native shells were added via Capacitor. The native apps render the same web content you see at detmeets.com — the shell only adds platform features (haptics, push, offline polish, deep links).
- Push notifications went live (iOS this month). Tokens are stored against an opaque installation identifier — no email, no name, no contact info attached.
- Anonymous server-side backup of saved meets shipped in 3.0. Each install gets its own opaque identifier; saved meets and reminder choices are mirrored to the server against that identifier. It is not cross-device — there is no flow to link two installs — and the real reason the backup exists is so the server can send meet-change push notifications. Details in the data section below.
- A new third party joined the user-facing render path: Open-Meteo, which powers the weather card on meet detail. We proxy it server-side; the third party never sees your IP. Details below.
- Removed a third-party geocoding call (OpenStreetMap Nominatim) that used to run on every map page render. That code path no longer exists.
- Added per-IP rate limiting on the two public unauthenticated POST endpoints (
/api/push/register,/api/report-client-error) so log spam and abuse have an actual ceiling. - 0 government, law-enforcement, or civil legal requests received. 0 account or content disclosures made.
- 0 data breaches.
- 0 ad networks integrated. 0 analytics SDKs integrated. (Same as last month. Same as next month, hopefully forever.)
What we collect, in plain English
There is no anonymous "telemetry beacon" or background analytics call from this app or website. The list of things the server actually receives is short:
From everyone who visits
- Standard web request data that any web server sees: IP, user-agent, the page you asked for, the referrer if your browser sent one. Used for serving the page and for abuse protection. Vercel (our host) keeps these in their access logs for a short retention window. We do not aggregate them, we do not sell them, we do not feed them into a profile.
- Cache cookies, only when needed for things like CSRF protection on organizer login. No advertising, retargeting, or third-party cookies.
From people who save meets, set reminders, or use the calendar feed
- Saved meets and reminder choices are stored in your browser's local storage on the device. They are not sent to our server unless either (a) you have push notifications enabled and the meet is one we should alert you about (see below), or (b) you have anonymous cloud sync turned on (also below).
- The iCal/webcal feed URL is public and contains only published events. Subscribing it to your calendar app does not create an account or identify you to us.
From people who enable push notifications
- An opaque APNs device token (a long random string Apple gives the app).
- An installation identifier (
installationId) — also opaque, generated on first launch — so we can de-duplicate when iOS rotates the token. - The platform (currently
"ios"). - Your permission state (
grantedordenied).
There is no email, no username, no real name, no IP, and no device fingerprint in the push device record. If you uninstall the app, we delete the row the next time we get the chance (typically the next failed send).
The schema lives at src/server/db/schema/push-device.ts if you want to read it yourself.
From people whose install backs saved meets to the server (new in 3.0)
When this is enabled, the server stores — keyed against the same opaque installation identifier described above:
- Saved meet ids and the times you saved them.
- Reminder choices (which presets you picked for that meet).
- A small per-meet snapshot copied from the public meet listing (title, venue, address, coordinates, cover image URL, recommended/charity flags, operational status). This is what powers offline rendering of the saved list.
- Last-seen timestamp, platform string (
iosorweb), and an optional app version string.
A correction worth being explicit about, because the framing in the 3.0 announcement was sloppy: this backup is per-install, not cross-device. Each install (phone Safari, the iOS app, your laptop browser) generates an unrelated identifier on first launch, and there is no mechanism in the app to link them. Two devices = two backups, with the server unable to tell they belong to the same person.
The reason the backup exists at all is operational: the server needs to know which installs have saved a given meet so it can send "your saved meet was cancelled / postponed / updated" pushes. That use case requires per-install state, and we built nothing more than that.
There is no email, name, IP, or device fingerprint in these rows. The installation identifier is opaque to us — we cannot resolve it back to a person. If you reset the app or clear site data, the identifier is gone and the server-side rows orphan. We do not currently expire orphaned rows on a schedule; we will commit to a retention window in next month's report.
The relevant schema lives at src/server/db/schema/anon-sync.ts.
From people who tap a push notification (delivery telemetry)
When you open a push notification we sent, the native app makes one short server call (/api/push/opened) that records:
- The notification id (a random opaque value the server already issued).
- The category (e.g.
featured_blog,meet_change). - An "opened" timestamp.
That endpoint records a counter, not your identity. We use the aggregate to confirm pushes are actually getting through; we do not associate it with your installation identifier.
From the native app on cold start
When the iOS shell boots, it makes one short call to /api/config/native-shell to read the current minimum-build threshold. The endpoint returns two integers (iosMinNativeBuild, androidMinVersionCode) and Cache-Control: private, no-store. No identifiers are sent in the request.
From organizers
Organizer accounts (used by organizers who want to manage their own listings) are still username-only. No email required. No real name required. Authentication runs on Better Auth, self-hosted in the same database as the rest of the app. No third-party identity provider has any data about who an organizer is.
In April we added a staff session list and revoke screen so we can sign a compromised organizer account out of every device at once. That tooling never sees passwords; it operates on the same session rows your browser already holds.
What we removed in April
Runtime third-party geocoding (Nominatim)
This is the biggest privacy change of the month, and it is worth being explicit about because it was a thing we were doing that you might not have known about.
Until April 29, the home page and the map page included a small server-side helper called ensureMeetCoordinates. When a meet existed in our database without latitude/longitude columns filled in, that helper would call OpenStreetMap's Nominatim geocoding service — at most a handful of times per request, throttled to OSM's fair-use limit — to look the address up and write the resulting coordinates back to our database.
In practice this almost always meant a meet's venue address was sent to a third party (the OSM Foundation) on every map page render until the gap was filled in. Once filled, the loop stopped for that meet. But it was a third-party call we did not need.
It is now deleted. Geocoding for new meets happens once at publish time, with the result stored in our database. Map and home read directly from those columns. If a meet has no coordinates, it simply does not get a pin. We are willing to take that small loss in coverage to get rid of an external call from the user-facing render path.
Commit: Add car_meet index and use PublicMeetListing (April 29). The deleted file was src/server/meets/ensure-meet-coords.ts.
A redundant client-side fetch on password change
The password change form used to fire a second request to /api/account/clear-must-change-password after a successful change. That endpoint is gone; the same operation is now performed server-side by an authenticated database hook inside Better Auth. One request, one outcome, no orphan state. (Also: less unauthenticated surface area, even though the original endpoint was session-gated.)
What we added in April
App Store presence (April 22)
Apple approved the iOS build. The app is now listed at apps.apple.com/us/app/id6761500810. Apple receives the standard App Store metadata and crash reports — these are governed by Apple's privacy policy, not ours. We do not configure any third-party SDKs in the iOS build.
iOS native shell (Capacitor)
The iOS shell is a thin WKWebView wrapper around the same web product. It uses Capacitor plugins for:
- Browser opens (
@capacitor/browser) so external links and ICS files open in the system browser instead of the in-app web view. - App links (
@capacitor/app) for resume / state restoration. - Push notifications (Apple Push Notification service).
- Geolocation for the "Near me" filter — only when you tap the chip and the system permission prompts you.
The full **Info.plist usage descriptions** were updated this month: NSLocationWhenInUseUsageDescription, NSLocationAlwaysAndWhenInUseUsageDescription, and notification disclosures. A user-facing breakdown lives in the Privacy Policy.
Android native shell (Capacitor)
The Android shell entered the repo this month with the same architecture as iOS. It is not yet shipped; we are running through the same polish pass we did for iOS before submitting. When it is ready, we will announce it here.
Push notifications (APNs)
We can now notify you when:
- A featured blog post is published (used at most a few times per month — for example, this report).
- A meet you saved is updated by the organizer (start time, location, cancellation). This is in the changelog from 2.3 onwards.
Push goes only to devices that opted in through the system permission prompt. We never enroll devices automatically. The push payload includes the meet or post title and a deep link; it does not include device identifiers, advertising IDs, or any analytic identifiers.
report-client-error endpoint
When a page crashes inside the React error boundary, the client posts a small JSON payload to /api/report-client-error so the failure surfaces in our serverless logs instead of only in your browser console.
The payload is bounded in size and contains the error message, error name, optional digest, optional stack, and the URL the error happened on. It does not contain DOM contents, form data, cookies, headers, or your IP address. The endpoint logs the payload to the server log and returns. It is now rate-limited per IP to 24 per minute.
push/register rate limit
Same idea as above — 40 requests per minute per IP, returns 429 over the cap. Stops abuse cold without affecting normal use.
Better-auth database hook for "must change password"
Replaces the client → API → DB write that used to clear the must-change-password flag. Implementation lives at src/server/better-auth/config.ts under databaseHooks.account.update.
Server-side HTML sanitization
A subset of meet descriptions and blog content runs through **sanitize-html** server-side as of April 2. We had front-end sanitization before; this adds a second pass on the server so untrusted HTML cannot reach a renderer that might be more permissive than expected.
iCal / Outlook sharing on Capacitor
Add to Calendar is now wired up correctly on the native iOS shell and on Outlook on the web. ICS files open in the system browser via @capacitor/browser rather than the WKWebView. No tracking parameters are added to ICS URLs.
Catbox uploader policy
The image uploader (used for organizer cover images) explicitly declares its policy in the Privacy Policy as of April 20: uploads go to Catbox via an account userhash, which is read from CATBOX_USERHASH server-side and is never exposed to the client. The privacy policy page describes what is uploaded and what is retained.
Weather card and the Open-Meteo proxy
Version 3.0 added a weather forecast block to the meet detail page. Because we are deliberate about third parties on the render path, here is exactly what happens when you open a meet:
- Your browser or native app calls our own endpoint
/api/weather?lat=…&lon=…&at=…. - Our server makes the upstream request to Open-Meteo using our server IP, asking for the forecast at the meet's coordinates and start time.
- Our server returns three numbers to your client: temperature in °F, precipitation chance, and a WMO weather code.
- Your client renders a label ("Clear", "Partly cloudy", "Rain likely", etc.) from those three numbers.
Open-Meteo is an open-source, Germany-based weather aggregator with a stated non-commercial, no-tracking stance. Crucially, we proxy the request server-side, which means Open-Meteo only ever sees:
- Our origin server's IP address.
- The latitude/longitude of a Detroit-area meet.
- The hour the meet starts.
Open-Meteo does not see your IP, your user-agent, your installation identifier, your push token, your saved meets, or any other identifier from your device. It cannot correlate the request back to you because the request did not come from you.
The proxy is the only new render-path third party added in April. It is now in the third-party list below.
Anonymous cloud sync endpoints
We added a tRPC sync router with three callable mutations: registerInstall, pushSavedEntry, clearSavedEntry, plus a paged read for hydration. They all key on the opaque installation identifier described in the data section.
We also added per-meet meetChangeWatch rows so the server knows which devices to alert when an organizer marks a meet cancelled / postponed / updated. These rows reference the installation identifier, never an account.
Third parties we currently rely on
This is the full list, as of April 29, 2026. If a service is on the user-facing render path, it is in this list. If it is not in this list, it is not on the render path.
- Vercel — hosting and serverless functions for the web app and APIs. Sees standard request logs.
- Neon Postgres — primary database. Stores meets, organizer accounts, push device tokens, sessions, and (new in 3.0) anonymous cloud-sync rows keyed by installation identifier.
- Apple Push Notification service (APNs) — required to deliver iOS push.
- Apple App Store — distribution and crash reporting for iOS.
- Open-Meteo — (new this month) upstream weather forecast for the meet detail card. Proxied server-side, so Open-Meteo only ever sees our origin IP and the meet's coordinates.
- Catbox — image upload host for cover images. Server-to-server uploads only.
- Cloudflare — DNS and edge caching for
detmeets.com. - OpenStreetMap-compatible tile provider — map tiles for the embedded Leaflet map. (Specific provider to be named explicitly next month.)
- GitHub — source code hosting and the Actions workflow that dispatches the featured-push notification after a Vercel production deploy.
We do not use:
- Google Analytics or any analytics SDK.
- Meta / TikTok / Twitter / LinkedIn pixels.
- Sentry, LogRocket, FullStory, or any session replay product.
- Mixpanel, Amplitude, Posthog, Segment, or any event pipeline.
- Any ad network.
- Any data broker or enrichment service.
Government and legal requests in April
- Law-enforcement requests received: 0
- Subpoenas, search warrants, or court orders received: 0
- National security letters received: 0
- Civil discovery requests received: 0
- Account or content disclosures made: 0
- Takedown notices received: 0
- Takedowns honored: 0
If we receive any of the above in a future month, the next report will say so. If we are ever gagged from saying so, the canary line in this section will simply not be repeated. Plan accordingly.
Security in April
- Vulnerability disclosures received from researchers: 0
- Confirmed data breaches: 0
- Confirmed unauthorized access: 0
- Server-side rate limiting added to
push/registerandreport-client-error. - Server-side HTML sanitization added across the renderer using
sanitize-html. - Featured-push deploy workflow moved from Vercel Cron to a deploy-completion GitHub Actions workflow with a bearer secret. Reduces the standing surface of "endpoints anyone could hit."
If you find a security issue, message @alex.30mm on Instagram and I will get back to you and route disclosure properly.
Numbers (light)
- Posts published in April: 4 (this report, the 2.3 changelog, the 3.0 changelog, and the App Store launch announcement).
- App Store launch date: April 22, 2026.
- iOS app marketing version at end of April: 3.0 (build 19).
- Database migrations applied in April: 3 (anonymous sync schema; status reason length; the new
car_meet (published, endsAt, startsAt)index). - Endpoints removed: 1 (
/api/account/clear-must-change-password). - Endpoints added: 5 (
/api/weather,/api/push/opened,/api/meet/[id]/exists,/api/config/native-shell, plus thesynctRPC router withregisterInstall/pushSavedEntry/clearSavedEntry/ hydration mutations). - Endpoints with new rate limits: 2 (
/api/push/register,/api/report-client-error— both endpoints existed before April; rate limits are new). - Third-party render-path calls added: 1 (Open-Meteo, server-proxied).
- Third-party render-path calls removed: 1 (Nominatim runtime geocoding).
- Net new render-path third parties: 0 — Nominatim out, Open-Meteo in.
Closing
If you read this far, you care about the substance of how an app handles your data more than most. That is the right reflex — most apps you use deserve a lot less benefit of the doubt than they get. Keep that energy for the rest of the internet, too.
If something in this report is wrong, missing, or you want a category I should track and publish next month, message @alex.30mm on Instagram. Reports get better when readers push on them.
Effective: April 29, 2026 · Period covered: April 1 – April 29, 2026