Browse docsOverview
Compliance

Privacy & consent

GDPR, CCPA, ATT, advertising consent. What the SDK does, what you have to do, and the APIs that make it easy.

Release status (27 July 2026): the strict 90-day server controls and privacy-hardened SDK behavior described below are locally verified release candidates, not the current published SDK/production-server contract. Production remains at migration 138 of the 145-file release head: raw click IP truncation at 24 hours is live, but referenced clicks can remain until the ordered migrations and backend rollout complete.

What Reflect collects, by default

  • Install UUID (random GUID, in PlayerPrefs); Android ID (SSAID)
  • Device taxonomy: model, manufacturer/brand, type (phone/tablet/tv), OS version + build, CPU, screen size/format/density, RAM, UI mode
  • App bundle id, version (+ version code), install source / store, first install + last update time, system-app flag
  • Locale, timezone; network type, carrier name + MCC/MNC
  • Engagement: session count & length, app foreground state; environment (production/sandbox)
  • Fraud signals: emulator, root/jailbreak, VPN, mock-location detection
  • Public IP + country (resolved server-side at the edge gateway). Production nulls raw click IP after 24 hours. After the pending strict-retention release, the click’s coarsened subnet and remaining per-click context will reach their strict boundary at 90 days and be removed by the next successful hourly purge; event free-form bags will follow the same source-age boundary.

Pending strict click-retention contract

Once activated, click context will expire on the strict 90-day policy clock and its row will be removed by the next successful hourly purge. At expiry, Reflect will remove the unique click_id, subnet, user agent, referrer, external click id, sub parameters, device signals, and dynamic callback URLs. The retained attribution keeps only the partner, attribution type, attribution time, non-unique source-link configuration, and coarse acquisition country needed for historical reporting. Click-to-install time is removed with the source click.

Under that pending contract, dynamic per-click callbacks cannot fire after the boundary. Static postbacks that require click-id, external-click-id, or sub1sub5 macros fail closed; campaign-only templates can still resolve from the retained source link. Queue retries and dead-letter replay enforce the same expiry; dead-letter storage removes click/callback context, and attempt logs do not retain a rendered URL, request body, or captured response for click-marked postbacks. Reflect also requires at least 30 seconds of retention headroom immediately before starting the initial partner request or its one allowed redirect.

The pending attribution endpoint sends a click_id: null revision once the strict boundary passes. The release-candidate Android and iOS cores never persist a unique click ID or its exact expiry. A current response can include the ID only in its live attribution callback while the positive expiry is still valid; the cache, getter, and restart path keep only partner/type/campaign. Upgrade cleanup immediately removes legacy persisted ID and exact expiry keys, including offline, then forces a full signed refresh when connectivity returns. After rollout, attribution warehouse exports omit click_id, and every warehouse table uploads directly to your configured destination instead of a new Reflect staging object. Once you copy data into your own warehouse, you are responsible for that downstream copy’s retention and deletion.

Encrypted email-webhook staging has a seven-day cold storage lifecycle, but lifecycle expiry is asynchronous and is not proof of a strict source-age limit. The pending backend release adds an explicit hourly sweep keyed to the oldest normalized source event, durable exact-key tenant-prefixed cleanup markers for failed request rollback, and no second global archive when email delivery exhausts retries. The single encrypted stage and its tenant-scoped operator index remain replayable only until the same source deadline, then both age out; tenant deletion removes the stage, marker, and index. Those controls and their remote staging E2E remain deployment gates.

Deep links and install referrals

Your app can receive the full deep-link URL in the immediate live callback. Reflect does not keep that query-bearing URL for replay: buffered and persisted SDK state keeps a query/fragment-free URL and path. The durable deep_link_opened event keeps only that clean route, a fixed direct/deferred source, and the boolean reattribution flag; query-derived campaign, partner, click IDs, and arbitrary parameters are excluded.

The release-candidate Android and iOS cores never write raw install-referrer or Apple AdServices input to disk. The durable queue is written immediately with only a reviewed provider-source value, the Google Play Instant boolean when present, and click/install client/server timestamps rounded down to the UTC day. The raw payload can remain in process memory for at most 30 seconds for an immediate online install send, then is discarded on acknowledgement, timeout, privacy reset, or process death. The pending server release independently enforces the same durable allowlist on every event. Current ingestion event bags are recursively stripped of click-token and query-bearing link context; long-lived audit event copies omit every free-form bag, and the retained database event loses all such bags at its 90-day source boundary while keeping fixed analytical fields.

This is a privacy/attribution tradeoff: if the app remains offline for more than 30 seconds or restarts before sending, deterministic matching that requires the raw referrer or token may be lost. Attribution then falls back to the coarse signal, fingerprint matching when enabled, or organic.

Unpublished Web SDK local retention

The unpublished Web SDK candidate removes query strings and fragments from durable page/referrer URLs and retains only sanitized source, medium, and campaign UTM values. Click IDs, raw query syntax, UUID-like values, and opaque tokens do not enter its IndexedDB or localStorage-fallback outbox. Every current, migrated, and legacy row expires at the strict 90-day boundary measured from the earlier of its event time and enqueue time; exact-boundary, malformed, and older rows cannot consume capacity or transmit.

What requires explicit consent

All advertising/device identifiers are gated by RequireAdvertisingConsent and scrubbed server-side if the user later denies consent.

  • GAID (Android advertising id) + its source/retry — gated by RequireAdvertisingConsent + the user’s system "Limit Ad Tracking" setting.
  • IDFA / IDFV (iOS) — gated by Apple’s ATT prompt; only after the user taps Allow.
  • Google App Set ID (Android 12+) — privacy-friendly per-developer id used for matching as GAID availability declines.
  • Amazon Fire ID (Fire OS devices only).
  • Push token (FCM/APNS) — set via SetPushToken for re-engagement; treated as a quasi-identifier and dropped on consent denial.
  • External device id — your own cross-system id set via SetExternalDeviceId; dropped on consent denial.
  • China only, OFF by default: IMEI/MEID and OAID — collected only when you explicitly enable CollectImei / CollectOaid for China distribution (require extra permissions / the China OAID SDK). Leave off unless you have a lawful basis.

Advertising consent (GDPR / EEA / UK)

If you’re distributing in regions requiring opt-in for cross-app tracking, set:

new ReflectConfig {
    RequireAdvertisingConsent = true,
    ...
}

The SDK will NOT collect GAID / IDFA until you call:

ReflectSDK.SetAdvertisingConsent(granted: true);
// or
ReflectSDK.SetAdvertisingConsent(granted: false);   // re-collects without ad ids

Wire this to your CMP (consent management platform) callback. Reflect doesn’t ship a CMP UI — every studio has different brand requirements and we don’t want to lock you into ours.

iOS App Tracking Transparency

Apple requires the ATT prompt before any cross-app tracking on iOS 14.5+. Reflect supports two modes:

// 1) Auto-prompt on first launch
new ReflectConfig {
    AutoRequestIosTracking = true,
}

// 2) Manual — call when you have a contextual moment in your UX
ReflectSDK.RequestIosTracking(status => {
    Debug.Log("ATT result: " + status);
    // status is one of: NotDetermined, Restricted, Denied, Authorized, Unavailable
});

The build post-processor injects NSUserTrackingUsageDescription into Info.plist automatically. Edit the wording in Editor/ReflectBuildPostProcessor.cs.

Android — AD_ID permission & Play Console Data Safety

The com.google.android.gms.permission.AD_ID permission (required on Android 13+ to read the GAID) is declared in the SDK’s Plugins/Android/AndroidManifest.xml and merges into your app automatically — you don’t add it. For COPPA / kids apps, remove that line and set RequireAdvertisingConsent = true.

You must still complete two Google Play Console declarations:

  • App content → Advertising ID: declare that the app uses an advertising ID (purpose: analytics / attribution). Declaring “no” while the AD_ID permission is present triggers a Play review warning.
  • Data safety: declare collection of Device or other IDs — GAID, Android ID, App Set ID, and (if enabled) Fire ID / IMEI / OAID, plus the push token; App activity (events, sessions); and Purchase history if you call TrackPurchase. Declare the collection purpose as analytics / attribution and whether data is shared with third parties (mirror your SetThirdPartySharing setting).

GDPR / CCPA right-to-be-forgotten

Reflect supports three operations per install_uuid: delete (irreversible wipe), opt-out (drop future events, keep history), and export (downloadable JSON snapshot). Tenant operators submit them from the admin console at Settings → Privacy, or programmatically via the Admin API:

# Tenant operator triggers from their backend on user request:
POST https://api.reflect.cloud/admin/gdpr/delete
Cookie: reflect_session=<operator session>
{ "install_uuid": "8c1f2a4d-..." }

What "delete" actually does:

  1. Cascade DELETE across every install-keyed database table:
    • Keyed directly on install_uuid: events, attributions, postbacks_log, ad_revenue_events, fraud_anomaly_scores, reengagement_attributions, touch_log, identity_graph, owned_media_attributions, pltv_scores, audience_members, capi_dispatch_log, ctv_attributions, iap_subscriptions, incrementality_assignments, install_audiences, push_tokens, san_claims, shadow_comparisons, uninstall_events, user_activity_state, user_aliases, skan_cv_reports, deferred_deep_links, email_credits.
    • Reached via joins or alternate key columns: attribution_credits and adservices_responses (via the install's attribution ids), clicks (via the attributed click ids), webhook_deliveries (via the install's event ids) — the join deletes run before the parent rows are cleared so the sub-selects still resolve — plus referral_codes (via its referrer_install_uuid column).
    A schema-coverage test fails if a migration adds an install-keyed table that is not triaged into the cascade (or the explicit retention list below), so this list cannot silently drift.
  2. Sweep all audit ledger records tagged with this install_uuid (via the per-install index written at ingest time).
  3. Insert the install into data_opt_outs so future events drop at the queue boundary — no database write, no attribution, no postback. In-memory cached, sub-ms hot-path check.
  4. Log a row in gdpr_requests with rows_affected + the operator email + timestamps. Idempotent: re-submitting a completed delete is a no-op.

SDK-side wipe

The SDK exposes ReflectSDK.DeleteUserData(callback) which:

  1. Locally: wipes PlayerPrefs / AsyncStorage / shared_preferences (install_uuid + first-launch flag), drops the offline event queue, clears global properties + user_id.
  2. Server-side: POSTs /privacy/delete (same HMAC-signed envelope as /event — no separate credential), which enqueues the erasure as a deletion_requests row. The hourly privacy workflow then runs the same cascade described above; if the request could not be confirmed, the SDK retries it on next launch.

The local wipe completes synchronously — even if the server call fails, the user's device is clean. Server-side, an operator-initiated /admin/gdpr/delete runs the cascade synchronously in the request (typically <500ms because the install is already indexed); SDK-initiated deletes are picked up by the hourly privacy workflow.

What's NOT deleted by the cascade (legitimate-interest retention):

  • audit_log entries about the deletion itself (kept for legal record of the operation).
  • The operation's own records — gdpr_requests / deletion_requests (the Art 5(2) accountability trail) and the data_opt_outs row, which must survive so the erased install cannot silently re-ingest.
  • global_blocklist entries — fraud prevention, retained under the Art 17(3) legitimate-interest exception.
  • Daily database backup archives in disaster-recovery storage and point-in-time restore history (available for up to 30 days under a separate restricted policy). A restore cannot serve traffic or operator reads until retention and completed erasures are reapplied.
  • Aggregated analytics (cohort retention rollups, hourly counters) — derived from deleted source rows but not directly identifying after aggregation.

Privacy Manifest (iOS) — required by App Store

Apple requires every SDK on the App Store to ship a PrivacyInfo.xcprivacy declaring the data it collects and the required-reason-codes for any restricted API it uses. All three Reflect SDKs (Unity, React Native, Flutter) ship one; you don't need to author it manually.

Where it lives in each SDK

SDKManifest pathPackaging
UnityPlugins/iOS/PrivacyInfo.xcprivacyBuild post-processor copies it into the Xcode project automatically
React Nativeios/PrivacyInfo.xcprivacyresource_bundles entry in reflect-react-native.podspec — picked up by pod install
Flutterios/Classes/PrivacyInfo.xcprivacyresource_bundles entry in reflect_sdk.podspec — picked up by Flutter's iOS build

What it declares

The manifest tells Apple that the SDK collects four kinds of data, two of which are linked to user identity:

Data typeLinkedTrackingPurposes
DeviceID (IDFA / IDFV / install_uuid)yesyesAnalytics, Third-Party Advertising
ProductInteraction (events)yesnoAnalytics
PurchaseHistory (revenue, subscriptions)yesnoAnalytics
CoarseLocation (server-inferred from IP)yesnoAnalytics

Plus two required-reason-code declarations for restricted Apple APIs the SDK uses internally:

API categoryReasonWhy we use it
UserDefaultsCA92.1Persist install_uuid + event-queue cursor
FileTimestampC617.1Read / write timestamps on the on-disk event buffer

What YOU still need to do

  • Add an app-level PrivacyInfo.xcprivacy in your iOS project root declaring categories you collect outside of Reflect (email, name, photos, etc.). Apple validates the app manifest in addition to every SDK's manifest.
  • Leave NSPrivacyTrackingDomains empty unless your compliance posture requires the declaration. Declaring the ingest host makes iOS 17+ refuse every SDK request until ATT is authorized — users who have not answered, or who deny, deliver nothing at all. iOS already returns an all-zero IDFA without ATT authorization, so identifier collection needs no domain fence. The shipped manifest deliberately carries an empty array, and Unity's build post-processor no longer injects the host by default (opt in with ReflectBuildPostProcessor.DeclareTrackingDomain or REFLECT_DECLARE_TRACKING_DOMAIN=1). If you do opt in, expect first-session events from ATT-undecided users to arrive only after the prompt is answered:
    <key>NSPrivacyTrackingDomains</key>
    <array>
        <string>api.reflect.cloud</string>      <!-- OPT-IN ONLY: blocks all SDK traffic until ATT is authorized -->
    </array>
  • If your app does NOT do server-side IP geo-lookup, you can remove the CoarseLocation block from the SDK manifest before shipping.
App Store enforcement is live since 1 May 2024
Submissions without a complete privacy manifest are rejected. The SDK ships one; failing to declare your app-level manifest is the most common rejection cause.

Anonymized mode

Not implemented as a single config flag (it’d be a leaky abstraction). Compose:

  • RequireAdvertisingConsent = true + don’t call SetAdvertisingConsent(true) — drops GAID/IDFA.
  • Don’t call SetUserId — keeps user anonymous.
  • Don’t pass PII as event properties (email, name) — server-side validation truncates strings to 1024 chars but doesn’t scrub semantic PII.
Tracking domains
Reflect doesn’t track users across apps. The SDK only sends events from the app it’s embedded in to the server you configured (BaseUrl). No fingerprinting beyond what’s needed for install attribution.