Skip to Content
Working with the codebasePrivacy declarations

Privacy declarations

One file describes what your app collects, and it feeds Apple’s App Privacy label and Google’s Data safety form so the two cannot drift apart.

Run the submit skill's privacy scripts for both stores and show me the two tables. Do not set humanConfirmed and do not push anything: I will read them and decide.

privacy.config.json at the repo root is the authored source. It carries one entry per reason data is collected, each recording the code that causes it. The scripts prune it down to the features and integrations this build actually ships, then map what is left onto each store’s vocabulary.

This is a legal statement about your app, not a configuration value. The boilerplate’s defaults describe the boilerplate’s code. Anything you added, a location prompt, an ad SDK, a contacts import, is not in the file yet.

Prerequisites

  • Store credentials, including an Apple ID web session for the App Store half
  • A product brief that matches the code on disk, because the pruning reads both

Declare once, push to both stores

Read both declarations

node .claude/skills/submit/scripts/privacy.mjs node .claude/skills/submit/scripts/play-privacy.mjs

Each prints the categories that survived pruning, the reason for every line, and the source that causes it. Neither sends anything.

The Apple run also writes .asc/privacy.json, and the Play run writes .asc/data-safety.csv. Both are regenerated on every run.

Check the two tables against your app

The Apple table is one row per data type, with its purposes and whether it is linked to the user. The Play table adds the answers Apple never asks for.

Read both. One flag covers both stores, so confirming after reading only one leaves half the declaration unchecked.

Set the flag

privacy.config.json
"humanConfirmed": true

Neither script will push until this is true.

Push

node .claude/skills/submit/scripts/privacy.mjs --push node .claude/skills/submit/scripts/play-privacy.mjs --dry-run --push # see the request node .claude/skills/submit/scripts/play-privacy.mjs --push

The Apple push plans, applies and publishes over the web session, so it needs an Apple ID and a two-factor code rather than the API key. The Play push is a single POST authenticated with the same service account as everything else on that side.

Verify

App Store Connect → your app → App Privacy, and Play Console → App content → Data safety. asc validate --deep also checks that App Privacy is published, though it needs a cached web session to do it.

Neither declaration needs pushing again on a later release unless the feature or integration set changed.

What the boilerplate declares, and why

CategoryBecause
EMAIL_ADDRESS, USER_IDSupabase Auth. Always true, because there is no signed-out mode
NAMEThe profile drawer writes full_name, and Google and Apple sign-in return it too
PHOTOS_OR_VIDEOSThe profile avatar upload, which is not feature-gated, so it survives removing chat, create and scan
OTHER_USER_CONTENTChat messages, image prompts and scan results
PURCHASE_HISTORYRevenueCat
CRASH_DATA, DEVICE_IDSentry
PRODUCT_INTERACTION, DEVICE_IDPostHog
DEVICE_IDOneSignal’s push token

Each entry names what it depends on, so removing a feature or an integration removes its row. requires takes {"always": true}, a feature, an integration, anyFeature or anyIntegration, using the same names yarn remove:feature and yarn remove:integration take.

Everything is DATA_LINKED_TO_YOU, because everything is keyed to the account, and nothing is DATA_USED_TO_TRACK_YOU, because the boilerplate has no ad network and no cross-app identifier. Adding one changes that answer and also brings App Tracking Transparency into scope.

Because Apple models this as (category, purpose, protection) tuples, entries sharing a category are merged. Sentry, PostHog and OneSignal all collect DEVICE_ID, and that is one declared entry carrying both purposes rather than three competing ones.

The answers only Google asks for

Google asks three things Apple never does. None can be derived from an Apple declaration that does not contain them, so they are answered in privacy.config.json’s google block and in each entry’s own google block.

AnswerWhereWhat ships, and why
Encrypted in transitgoogle.encryptedInTransittrue. Every backend this app talks to is HTTPS
Users can request deletiongoogle.userRequestDeletetrue. The app ships a delete-account edge function, reachable from the profile screen
Shared with other companiesper entry, google.sharedfalse everywhere. Google counts a transfer as sharing only when the other company uses the data for its own purposes; a vendor acting on your instructions is still collection
Users can decline the collectionper entry, google.userControlrequired where the app cannot work without it, optional where the user chooses: display name, avatar, chat attachments, purchases
Processed ephemerallyper entry, google.ephemeralfalse everywhere. This app stores what it collects

Sentry, PostHog, OneSignal and RevenueCat are all processors in this stack, which is what makes shared: false the honest answer. That is a claim about your contracts rather than about the code, so check it before you sign it.

What the scripts refuse to do

The failure these guards exist for is a declaration that understates what the app collects. Three refusals stop the run rather than guessing.

They will not publish “no data collected” for an app with accounts. If pruning empties the declaration, both scripts fail. This app signs users in through Supabase, so it collects an email address and a user id before any feature is considered, and a no-data-collected label would be false. The fix is the requires rules, not the declaration.

They will not invent a Google type for a category Google models differently. SENSITIVE_INFO, ADVERTISING_DATA and OTHER_DATA_TYPES have no honest counterpart: Google splits sensitive data into race, beliefs and orientation, and has neither an advertising-data type nor a catch-all. Which one applies is a judgement about your app. An entry using one stops the Play run with instructions to name the real type in google.types.

They will not carry Apple’s catch-all purpose across. OTHER_PURPOSES says nothing about why data is collected, and Google requires a reason.

Where the two vocabularies simply differ in granularity, the file says so explicitly. Apple’s one PHOTOS_OR_VIDEOS is two Google types, and the shipped entry narrows it to photos only, because the picker requests images and the app cannot collect video. Google is the coarser one for identifiers, where Apple distinguishes advertising identifiers and Google has a single “Device or other identifiers”. Google also has purposes Apple lacks, so the auth entries add account management explicitly: Apple’s app-functionality alone understates why an email address is collected.

How it works

Apple has no API for this

Apple’s App Store Connect API spec, version 4.4, is 929 paths and 6.9 MB of JSON, and it contains the string dataUsage zero times. There is no data-usage resource, no schema and no publish endpoint. The same search finds age-rating declarations and version localizations exactly where you would expect them, so the absence is real rather than a broken search. asc classifies App Privacy as a web-session capability for the same reason.

So this is not a documented endpoint behind a permission, and not something asc has yet to wrap. There is nothing to call. What exists is App Store Connect’s own internal web API, the one the website uses, and asc web privacy drives it over a logged-in Apple ID session: pull the current declaration, diff a local file against what is live, apply the tuples, publish them.

An agent cannot type a two-factor code. Either you run the push, or you log the session in once yourself and the agent reuses it while Apple keeps it alive:

asc web auth login --apple-id you@example.com asc web auth status

If the session is unavailable, the printed table doubles as the answer sheet for App Store Connect’s own App Privacy form: one row per data type, with its purposes and whether it is linked to the user.

Google’s endpoint takes a CSV

applications.dataSafety has exactly one field, and its documented content is the contents of the CSV that Play Console’s own Data safety page exports. That file is a full question and response matrix, so every possible answer gets a row and a column carries TRUE, FALSE or blank. It is not a list of what is true.

The machine-readable question and response ids appear in no API reference. They come from the sample CSV that Google’s Data safety help page  hands out, transcribed into the repo as three top-level questions, 38 data types and five questions per type. The generated file matches Google’s template row for row.

That is also the weak point, because the vocabulary is in this repo rather than at Google, so a data type added later would simply be missing from what the script generates. Two things follow:

  • The CSV is written to .asc/data-safety.csv whether or not you push, and Play Console → App content → Data safety → Import takes that exact file. Reviewing it in the Console instead of trusting the push is a first-class option.
  • If an import ever complains about unknown or missing questions, export your own app’s CSV from the Console, which is always current, and diff it against the generated one.

Next

Last updated on