A privacy notice is a specification, written in prose, by the same person who wrote the code, and nothing checks the two against each other. Nakodo's notice is at nakodo.app/privacy, the rendered page is 2,694 words including the header and footer every page shares, and the file it lives in carries this comment above its sections:
// Plain-language description of what the code does. Keep it in step with // src/db/schema.ts, src/lib/pipeline/maintenance.ts, src/lib/outreach and the // services we use. "Keep it in step" is doing a lot of work there. So I went through the notice, pulled out every sentence that makes a claim a grep can settle, and traced each one to the line that implements it. Twenty claims. Seventeen held exactly, one needs its wording changed, two are not checkable in the repo at all, and one is wrong in a way I am glad I found by doing this rather than by being asked.
The ones you can check from outside
Nakodo sets no tracking cookies. The only cookies keep you signed in.
$ for p in / /pricing /privacy "/login?mode=signup"; do > printf "%-22s set-cookie:%s\n" "$p" "$(curl -sI "https://nakodo.app$p" | grep -ci set-cookie)" > done / set-cookie:0 /pricing set-cookie:0 /privacy set-cookie:0 /login?mode=signup set-cookie:0 Nothing is set until you sign in, and in the code there are exactly two places that write a cookie, both of them the Supabase auth adapter: one in src/proxy.ts for the request path and one in src/lib/supabase/server.ts. No other file in the app calls cookies() to write anything. Holds.
There are no ad pixels, on this site or in the app.
$ curl -s "https://nakodo.app/login?mode=signup" | grep -oE 'https?://[a-z0-9.-]+' | sort | uniq -c 4 https://nakodo.app 1 https://challenges.cloudflare.com One external host in the whole document, and it is the bot check on the sign-up form. On the other 75 pages there are no external hosts at all. Holds, and hold that thought for the last section.
no open or click tracking in any email we send
The send function passes from, to, subject, text, html, replyTo and an optional idempotency key to the mail provider, and nothing else. No tracking options, no link rewriting anywhere in the mail code, no pixel in any template. Holds.
The analytics claims
Four claims, and all four are one posthog.init call:
posthog.init(POSTHOG_TOKEN, { api_host: POSTHOG_PATH, persistence: "memory", person_profiles: "identified_only", respect_dnt: true, session_recording: { maskAllInputs: true }, capture_exceptions: true, disable_surveys: true, }); Nothing is stored on your device: no cookies and no local storage.
persistence: "memory". Holds. There is a post on why that line costs us session replay if we use PostHog's own cookieless mode instead.
or at all if your browser sends Do Not Track or Global Privacy Control
respect_dnt: true. Holds, and the comment above it in the source says "The privacy notice says so", which is the closest thing in this repo to a test for a legal promise.
Anything typed into a field is hidden in recordings
maskAllInputs: true, set in code rather than in the project settings so the dashboard cannot loosen it. Holds.
and so are the messages from creators and businesses in your conversations
This one I expected to fail, because it is a promise about rendered text rather than a setting. There is exactly one ph-mask class in the app:
{conversation.length > 0 && ( // Creators' and businesses' own words: masked in session recordings. <ol className="ph-mask space-y-3"> So I checked whether anything else renders an incoming message, and direction === "in" appears in one component. The promise holds only because that data has a single renderer, which is luck as much as design. The note that belongs in that file: a second renderer of an incoming message is a privacy regression, not a styling task.
Nothing is recorded on the pages creators and businesses open from our emails
const UNTRACKED = ["/admin", "/o/", "/unsubscribe"]; Holds, and it includes the operator panel, which is not in the notice but is the more interesting of the three.
The data-handling claims
The log does not record IP addresses.
The audit table has seven columns: id, userId, actor, action, entityType, entityId, metadata, createdAt. No address column, and the one field that is deliberately dropped on the way into it has its own post. Holds.
Upstash, for short-lived rate limits, keyed on your account, your email address or your IP address. The keys expire within a couple of hours.
const hash = (key: string) => createHash("sha256").update(key).digest("base64url").slice(0, 32); The key is hashed before it leaves the process, and the longest window in the eighteen rules is one hour, so a sliding window holds at most two hours of it. Holds. "It never learns your email address" is a whole post about that limiter.
up to 5 public pages, such as the home, contact and about pages, read while respecting robots.txt
const MAX_PAGES = 5; with allowedByRobots(url) in the fetch path. Holds.
and up to 3 email addresses
const emails = checked.slice(0, 3); Holds, and it is pleasing how often a legal promise turns out to be a slice.
If you don't reply, we follow up at most three times
export const FOLLOW_UP_GAPS_DAYS = [4, 7, 10] as const; export const MAX_FOLLOW_UPS = FOLLOW_UP_GAPS_DAYS.length; and the per-campaign setting cannot exceed it:
followUps: Math.min(MAX_FOLLOW_UPS, Math.max(0, settings?.followUps ?? DEFAULT_FOLLOW_UPS)), Holds. The length of an array is the promise, which means adding a fourth gap silently makes the notice wrong. That is the kind of line that wants a comment pointing at the notice.
The address we email a creator or business at: removed from the conversation 30 days after it ends or, once they've said yes, 30 days after its last email.
export const FORGET_ADDRESS_DAYS = 30; .set({ toEmail: null }) .where( and( sql`${outreachThreads.toEmail} is not null`, or( and(eq(outreachThreads.status, "closed"), lt(outreachThreads.closedAt, before)), and( eq(outreachThreads.status, "handed_off"), sql`greatest(${outreachThreads.handedOffAt}, ${outreachThreads.lastSentAt}, ${outreachThreads.lastReplyAt}) < ${before.toISOString()}::timestamptz`, ), ), ), ) Two branches for the two ways a conversation can be over, which is exactly the two cases the sentence names. Holds, and it needed a second statement I had forgotten about: nulling from_address on the stored messages, because an inbound email carries the sender's address too.
YouTube channels marked as made for kids are never contacted.
if (ch.madeForKids) return "Made for kids"; A skip reason rather than a filter, which means the channel is visible to the brand with a reason attached rather than silently absent. Holds.
We never see or store your card number.
No card columns anywhere in the schema. The only card-derived value in the system is a fingerprint, and it is written into Stripe's own subscription metadata rather than into our database, so that a free trial can be one per card as well as one per account. Holds, and the notice should say that a card fingerprint exists, because "never store" and "ask Stripe to keep a fingerprint for us" are different sentences.
The one whose wording I am changing
such as no second try for 6 months after they say no
export const DECLINED_COOLDOWN_DAYS = 180; 180 days is not six months. It is six months minus two to five days depending where you start. Nobody will ever complain, and I still want the notice to say what the code does, so the sentence becomes "for 180 days". Prose that rounds a constant is how a true statement becomes a false one after a leap year.
The two that are not in the repo at all
PostHog keeps recordings for 30 days and everything else for a year.
We reply within one month.
Neither of these can be checked by reading this codebase. The first lives in a PostHog project setting, the second is a promise about me. They belong in the notice, and they are also the two claims most likely to quietly stop being true, because nothing in a deploy pipeline ever looks at them. The only real control for either is a recurring reminder to go and look.
The one that is wrong
These companies process data for us, only to run Nakodo: Supabase, Vercel, Trigger.dev, Upstash, PostHog, OpenAI, Google, Meta, Brave, Resend, Stripe.
Eleven named. Now the list of external hosts the code actually talks to, from a grep for https:// across src, with our own domains, test fixtures and example data removed:
*.supabase.co Supabase listed api.openai.com (SDK) OpenAI listed www.googleapis.com Google listed graph.facebook.com Meta listed api.search.brave.com Brave listed files.resend.com, resend.com (SDK) Resend listed api.stripe.com (SDK) Stripe listed nominatim.openstreetmap.org OpenStreetMap named in the businesses section overturemaps-us-west-2.s3... Overture Maps named in the businesses section api.indexnow.org Bing IndexNow not listed, and holds no personal data challenges.cloudflare.com Cloudflare NOT LISTED (a paid page-reading service) not named here NOT LISTED The IndexNow one is defensible: it receives our own public page URLs when they change and nothing about any person.
The other two are not.
Cloudflare Turnstile runs a script on the sign-up form, and the server-side half of the check sends Cloudflare the visitor's IP address:
const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ secret, response: token, ...(ip && { remoteip: ip }) }), signal: AbortSignal.timeout(10_000), }); That is a processor receiving personal data, on the one page where somebody is handing us their email address, and it is not in the list. It was added weeks after the notice was written, in the commit that put a bot check on sign-up, and updating the notice was simply not part of that change.
The second is a paid service that reads public profile pages for us as a fallback, which receives the handle or URL being read. I am not naming it here, for the same reason these posts have never named the providers behind discovery, but it is a processor and it is missing from the notice for the same reason Cloudflare is: it arrived in a later commit than the notice did.
Both belong in that list, and adding them is the first thing I am doing after this post. Neither is a scandal and both are exactly the failure mode a privacy notice has: it is correct on the day it is written, it is prose, and prose has no imports.
What I am doing so this does not happen again
The audit took about an hour and produced one real fix, one wording change, one reminder to set, and a short list of constants that the notice quotes and that therefore should say so in a comment:
FOLLOW_UP_GAPS_DAYS privacy notice, "we follow up at most three times" DECLINED_COOLDOWN_DAYS privacy notice, "no second try for 180 days" FORGET_ADDRESS_DAYS privacy notice, "removed 30 days after it ends" The thing I would recommend to anybody shipping a product with a privacy page: the gap is never in the hard claims. Nobody gets the retention periods wrong, because those are code. The gap is in the list of companies, because that list changes when you add a dependency and nothing makes you look at it. Grep for https:// in your own repo and compare it to your own notice. It takes five minutes and you will not enjoy it.
The notice itself is at nakodo.app/privacy, the terms it refers to are at nakodo.app/terms, and the page that explains what the app does with all of it is nakodo.app/how-it-works.
