AI consent
NativeExpress asks each user for permission before chat, create or scan sends anything to a third-party AI, which the App Store requires of every app that does this.
A user who has not allowed it sees an inline card where they are about to send: in place of the chat composer, in place of the Generate button, and at the top of the scan screen before the camera opens. The card says what that feature sends and who receives it, for example:
To answer, Native Express sends the text you type, any photos you attach and the earlier messages in that chat to OpenRouter, which passes it to OpenAI, Anthropic or Google, depending on the model.
Allow records the permission on the account and the request goes ahead. What’s shared opens a sheet listing every AI feature, what it sends and to whom. One permission covers all AI features. The user withdraws it under Profile → AI data sharing, and the next AI request asks again.
Until the user allows it, Scan disables its capture button and hides its other capture actions, and Create hides Regenerate on a result. If the server refuses a request anyway, for example because the permission was withdrawn on another device, the card comes back, and Allow resends the message, prompt or photo that was refused.
Why the App Store requires it
App Review Guideline 5.1.2(i) has applied since 13 November 2025. An app must clearly disclose where personal data is shared with a third-party AI and get the user’s explicit permission before the first transmission. Explicit means an affirmative tap such as Allow. Accepting your terms at sign-up is not enough, and apps are rejected for relying on it.
Chat sends message text and attached photos, create sends the image description, and scan sends the photo. All three go to OpenRouter, which forwards them to the company whose model answers. So every app that keeps one of these features needs the consent, and the boilerplate ships it in place.
Change the wording
| To change | Edit |
|---|---|
| The card and the sheet | The ai_consent keys in src/i18n/en.json and src/i18n/de.json |
| What a feature says it sends | chat.ai_consent_sent, create.ai_consent_sent, scan.ai_consent_sent in both files |
| The Profile row and its withdraw dialog | The profile.ai_consent* keys |
| How a provider is named | PROVIDER_NAMES in src/lib/aiConsent.ts, keyed by the model id’s prefix |
The recipients are not written into the copy. The card reads the model ids each feature
can send to and names OpenRouter plus the maker of each model, so adding a model to
supabase/functions/chat/config.json from a new provider adds that provider to the card.
The app name comes from general.appName in config.js.
Keep the text concrete. Apple wants the data named (“the photo you take”, not “your content”) and the recipient named (“OpenRouter, which passes it to Google”, not “our AI partners”).
Ask everyone again
Bump consentVersion in supabase/functions/_utils/ai.config.json when the data a
feature sends or the companies receiving it change: a model from a new provider, a
feature that starts sending location or contacts, a new AI feature. A user’s permission
counts only for the version they allowed, so everyone sees the card again.
Bump the version
{
"freeMessagesPerMonth": 10,
"consentVersion": 2
}The app reads the same file through config.js, so the build and the functions
agree on the number.
Ship the app first
Release the build containing the new version. Until it is live, deployed functions on the old version keep accepting users who allowed either one.
Deploy the functions
supabase functions deploy chat generate identifyUsers still on an older build get a card asking them to update the app the next time they send, rather than an Allow the server would refuse.
Verify
Open chat with an account that allowed the previous version. The card appears again, and after Allow the message sends.
Add an AI feature
A new feature that sends user content to a model needs all three parts, or it either skips the question or fails without explaining why:
- Export an
aiDisclosurefrom the feature’sconfig.ts, withsentKey, the i18n key describing what it sends, andmodelIds, the models it sends to. Set it on the feature infeature.ts. The sheet and the Profile row read it throughaiFeatures, exported from@/features. - Render
AiConsentCardfrom@/components/ai-consent/AiConsentCardwhere the user sends, and hold back the send controls whileuseAiConsent().isRequiredis true. When a request fails withai_consent_required, pass the error tohandleRefusal(error). It reads the version the server wants from the error. - Call
assertAiConsent(user)fromsupabase/functions/_utils/consent.tsin the edge function, straight afterauthenticateUserand before any quota or model call.
Gotchas
- The App Review demo account must not have allowed AI. The reviewer needs to see the card. If you tested with that account, withdraw it under Profile first.
- The functions refuse even when a screen skips the card. A rewritten screen that drops the card still cannot reach a model, because each function refuses without consent. The user then sees an error instead of the card, which is a rejection of its own.
- Google’s “shared” answer depends on your OpenRouter settings. The boilerplate declares the AI providers as processors, not sharing. That holds only while your OpenRouter account refuses providers that may train on inputs. See Privacy declarations.
- Maestro flows grant the permission. The chat, create, scan and screenshot flows tap Allow when the card shows, so run them with a test account. See Testing.
- Removing features keeps consent. It is shared infrastructure, like the quota. Removing chat leaves the card on create and scan. With every AI feature removed there is no card and no Profile row.
Error codes
| Code | HTTP | Meaning |
|---|---|---|
ai_consent_required | 403 | The user has not allowed AI processing, withdrew it, or allowed an older consentVersion. The body carries the version the server wants. |
The app answers this code with the consent card, not an error. When the version in the
body is newer than this build knows, Allow could never satisfy the server, so the card
asks the user to update the app instead.
Files
- AiConsentCard.tsx - The inline card with Allow
- AiConsentSheet.tsx - What's shared
- AiConsentRow.tsx - Profile state and withdrawal
- useAiConsent.ts - Gate, grant, withdraw, server refusals
- aiConsent.ts - Version, account record, recipient names
- */config.ts - Each feature's aiDisclosure
- ai.config.json - consentVersion
- consent.ts - assertAiConsent
How it works
The permission is stored on the account as user_metadata.ai_consent, so it survives a
reinstall and follows the user to another device. The functions read it through
auth.getUser, which asks Supabase Auth rather than trusting the claims in the access
token, so a withdrawal counts on the next request. user_metadata is writable by the
user it belongs to, which is appropriate here: the record is that user’s own answer.
The check runs before quota, storage or provider work, so a refused request costs the user nothing from their monthly allowance.
ai_consent_granted and ai_consent_withdrawn go to analytics with
the version as their only property.