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.
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.mjsEach 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
"humanConfirmed": trueNeither 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 --pushThe 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
| Category | Because |
|---|---|
EMAIL_ADDRESS, USER_ID | Supabase Auth. Always true, because there is no signed-out mode |
NAME | The profile drawer writes full_name, and Google and Apple sign-in return it too |
PHOTOS_OR_VIDEOS | The profile avatar upload, which is not feature-gated, so it survives removing chat, create and scan |
OTHER_USER_CONTENT | Chat messages, image prompts and scan results |
PURCHASE_HISTORY | RevenueCat |
CRASH_DATA, DEVICE_ID | Sentry |
PRODUCT_INTERACTION, DEVICE_ID | PostHog |
DEVICE_ID | OneSignal’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.
| Answer | Where | What ships, and why |
|---|---|---|
| Encrypted in transit | google.encryptedInTransit | true. Every backend this app talks to is HTTPS |
| Users can request deletion | google.userRequestDelete | true. The app ships a delete-account edge function, reachable from the profile screen |
| Shared with other companies | per entry, google.shared | false 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 collection | per entry, google.userControl | required where the app cannot work without it, optional where the user chooses: display name, avatar, chat attachments, purchases |
| Processed ephemerally | per entry, google.ephemeral | false 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 statusIf 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.csvwhether 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
- Age rating and content rights, the other two declarations
- Submit to the stores