Loading postβ¦
Loading Detroit Meetsβ¦
Loading postβ¦
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.
/api/push/register, /api/report-client-error) so log spam and abuse have an actual ceiling.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:
installationId) β also opaque, generated on first launch β so we can de-duplicate when iOS rotates the token."ios").granted or denied).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.
When this is enabled, the server stores β keyed against the same opaque installation identifier described above:
ios or web), 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.
When you open a push notification we sent, the native app makes one short server call (/api/push/opened) that records:
featured_blog, meet_change).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.
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.
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.
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.
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.)
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.
The iOS shell is a thin WKWebView wrapper around the same web product. It uses Capacitor plugins for:
@capacitor/browser) so external links and ICS files open in the system browser instead of the in-app web view.@capacitor/app) for resume / state restoration.The full **Info.plist usage descriptions** were updated this month: NSLocationWhenInUseUsageDescription, NSLocationAlwaysAndWhenInUseUsageDescription, and notification disclosures. A user-facing breakdown lives in the Privacy Policy.
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.
We can now notify you when:
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 endpointWhen 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 limitSame idea as above β 40 requests per minute per IP, returns 429 over the cap. Stops abuse cold without affecting normal use.
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.
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.
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.
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.
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:
/api/weather?lat=β¦&lon=β¦&at=β¦.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:
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.
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.
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.
detmeets.com.*.basemaps.cartocdn.com. The render path does not use Mapbox, Google Maps tiles, or any other provider. CARTO sees the standard request fields (your IP and user agent) plus which tiles you fetched. Detroit Meets does not pass any installation, push, or sync identifier to the tile request β CARTO and Detroit Meets cannot correlate tile fetches with anything else we know about your device. (Named explicitly per the placeholder above.)We do not use:
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.
push/register and report-client-error.sanitize-html.If you find a security issue, message @alex.30mm on Instagram and I will get back to you and route disclosure properly.
car_meet (published, endsAt, startsAt) index)./api/account/clear-must-change-password)./api/weather, /api/push/opened, /api/meet/[id]/exists, /api/config/native-shell, plus the sync tRPC router with registerInstall / pushSavedEntry / clearSavedEntry / hydration mutations)./api/push/register, /api/report-client-error β both endpoints existed before April; rate limits are new).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