user_id from T4 (see §6). That's what makes them attributable. Set identity before any event fires.Every event carries its source in its name: rc_* = RevenueCat (e.g. rc_initial_purchase) · af_* = AppsFlyer (e.g. af_purchase) · unprefixed = web/GTM (e.g. purchase). Amplitude's AI-merge recommendation then unifies same-meaning events across sources (e.g. af_purchase + purchase + rc_initial_purchase → merged Purchase) — analysis uses the merged event; the raw prefixed events stay unmerged for audit. Status at the 9 Jul API check: af_* confirmed flowing (4 events); rc_* not yet visible in the project taxonomy — the RC→Amplitude wire counts as done when rc_* events appear and reconcile within 5% of the RevenueCat dashboard.
Some events (begin_checkout, add_to_cart, generate_lead, quiz_*, subscription_checkout_*) belong to the web checkout/quiz surface and are the internal team's job — they're marked WEB and listed for context only. Your focus is the native rows: auth, onboarding/assessment, workout, today-screen, profile, and the AppsFlyer conversion events.
We do not have a normal trial→paid funnel. A lead becomes a paying customer at the paywall, on one of two paths, and the meter must count both as "a new paying customer":
Build ONE event for both paths — new_paying_customer (or reuse purchase) fired on BOTH the carded trial-start AND the direct discounted purchase, with:
| Property | Values / note |
|---|---|
path | trial_start | direct_purchase |
source / UTM | channel that drove them — makes it attributable |
plan | monthly | quarterly | annual |
value | amount charged |
transaction_id | Stripe / RevenueCat id — dedupe key (kills the 1.52× double-fire for the GA4→Ads import, Task 3) |
Why it matters for paid (Task 3): import THIS event (trial-start OR direct purchase), deduped on transaction_id. If we import only trial_started, the ad algorithm optimises toward trials and goes blind to the direct-purchase buyers — a big share of real revenue. Order: behind Task 19 (can't fire a clean purchase event through a checkout that isn't working yet). Source: MESSAGE-TO-NIC-FUNNEL-CORRECTION-2026-07-16.md · §⑮/§⑰ of the $15K doc.
These make paid acquisition measurable. Drive them off the RevenueCat purchase callback so they can't drift from the source of truth. Include revenue + currency on every one.
| Event | 30d | Status | Fire when | Key props |
|---|---|---|---|---|
af_start_trial | 0 | DORMANT | RevenueCat reports a trial started | currency, product_id |
af_purchase | 0 | DORMANT | First paid conversion (trial→paid or direct) | revenue, currency, product_id |
af_subscribe | 0 | DORMANT | Subscription renewal / active sub | revenue, currency, product_id |
payment_initiated | 4 | PARTIAL | User taps buy / enters payment | product_id, price |
purchase | 10 | PARTIAL | Generic purchase confirmed | revenue, currency |
| Web checkout (internal team — context only) | ||||
subscription_checkout_screen_opened | 0 | WEB | Paywall/checkout screen shown | — |
subscription_checkout_confirmed | 0 | WEB | Checkout confirmed | — |
subscription_billing_portal_failed | 0 | WEB | Billing portal error | — |
// On the RevenueCat purchase/trial callback, fan out to all trackers.
// This guarantees AppsFlyer + Amplitude never disagree with RevenueCat.
function onRcEntitlementChange(info) {
if (info.isTrial) { appsFlyer.logEvent('af_start_trial', { af_currency }); }
if (info.isNewPaid) { appsFlyer.logEvent('af_purchase', { af_revenue, af_currency });
amplitude.track('purchase', { revenue, currency, product_id }); }
if (info.isRenewal) { appsFlyer.logEvent('af_subscribe', { af_revenue, af_currency }); }
}
These are where you set the canonical user_id (§6). auth_register_succeeded and auth_login_succeeded are the moments identity gets established — wire identity here first, then everything downstream attributes.
| Event | 30d | Status | Fire when |
|---|---|---|---|
auth_register_succeeded | 0 | DORMANT | New account created → SET user_id on all SDKs here |
auth_login_succeeded | 3 | PARTIAL | Login success → SET user_id on all SDKs here |
auth_session_started | 3 | PARTIAL | App session begins for a logged-in user |
auth_register_failed | 0 | DORMANT | Registration error (capture reason prop) |
auth_login_failed | 0 | DORMANT | Login error (capture reason prop) |
auth_logout | 0 | DORMANT | Logout → clear identity on all SDKs |
auth_forgot_password_requested | 0 | DORMANT | Password reset requested |
auth_mode_switched | 0 | DORMANT | Toggled login ↔ register |
This is the behaviour that drives retention (the #1 revenue leak). The full workout journey is defined but silent — wiring it unlocks D1/D7/D30 retention and activation analysis.
| Event | 30d | Status | Fire when | Key props |
|---|---|---|---|---|
workout_started | 1 | PARTIAL | User starts a workout | workout_id, level |
workout_session_started | 1 | PARTIAL | Session (day) begins | session_id |
workout_completed | 0 | DORMANT | Workout finished — key activation event | workout_id, duration |
workout_session_completed | 0 | DORMANT | Full session completed | session_id, exercises_done |
rpe_submitted | 1 | PARTIAL | User rates exertion (RPE) | value, exercise_id |
workout_session_feedback | 1 | PARTIAL | Post-session feedback given | rating |
workout_swap_confirmed | 0 | DORMANT | User swaps an exercise | from_id, to_id |
workout_prep_opened / _closed | 0 | DORMANT | Pre-workout review screen open/close | — |
workout_player_leave_to_plan | 0 | DORMANT | Left the workout player (drop-off signal) | at_exercise |
workout_recap_share | 0 | DORMANT | Shared a workout recap | — |
workout_cantdo_pain_chain | 0 | DORMANT | "Can't do / pain" flow triggered | exercise_id |
workout_start_l2_gated | 0 | DORMANT | Workout start blocked by L2 gate | — |
| Event | 30d | Status | Fire when |
|---|---|---|---|
mini_onboard_step | 2 | PARTIAL | Each mini-onboarding step viewed |
mini_onboard_pick | 0 | DORMANT | User makes an onboarding choice |
mini_onboard_assessment | 0 | DORMANT | Onboarding assessment step |
mini_onboard_completed | 1 | PARTIAL | Onboarding finished — key activation event |
advanced_assessment_started | 0 | DORMANT | Advanced (L2) assessment begins |
advanced_assessment_completed | 1 | PARTIAL | Advanced assessment finished |
advanced_assessment_exited | 0 | DORMANT | Abandoned mid-assessment (drop-off) |
advanced_assessment_saved | 1 | PARTIAL | Assessment results saved |
advanced_assessment_goal_recommended | 1 | PARTIAL | Engine recommends a goal |
placement_feedback_submitted | 0 | DORMANT | User confirms/adjusts placement |
profile_leverage_submitted | 0 | DORMANT | Leverage/profile data submitted |
engine_decision | 1 | PARTIAL | Personalisation engine makes a decision |
training_disclaimer_acknowledged | 0 | DORMANT | Safety disclaimer accepted |
Engagement depth on the home screen and profile. Wire after the P0/P1 sets are proven. The today_* family is a rich home-screen interaction map — useful for engagement analysis but not blocking the core funnel.
| Event group | Events (all currently DORMANT unless noted) | Fire when |
|---|---|---|
| Today — start | today_start_workout_clicked, today_choose_workout_clicked, today_quick_ten_clicked, today_lets_go_clicked, today_open_workout_tab, today_start_workout_l2_gated | User taps a start-workout entry point on the home screen |
| Today — explore | today_skill_row_clicked, today_build_goal_clicked, today_plan_section_toggled, today_see_all_achievements, today_continue_assessment_in_app_onboarding 2 | User explores plan/skills/achievements |
| Today — share | today_share_levels_open, today_share_trophy_open, today_share_start_point_open, today_l2_nudge_dismissed | User opens a share sheet or dismisses a nudge |
| Profile / body | body_status_saved, body_metrics_unit_toggled, injury_profile_saved, profile_save_succeeded 1 | User edits body metrics / injury profile |
| Settings | settings_save_succeeded, settings_save_failed, settings_achievements_view, settings_subpage_opened 2 | Settings interactions |
| Nav / misc | tab_switched 9, tab_viewed 22, notifications_bell_clicked, referral_attribution_touchpoint_captured | Tab navigation; referral touchpoint captured |
user_id — the keystone (Task T4)Everything above is only attributable if it carries one consistent identity. This is the single most important decision in the project — agree it with TMA before wiring, so the web side threads the same value.
Pick ONE canonical id and set it on all three native SDKs at every auth point (register + login), before any purchase or event fires. Recommended: the authenticated app user id; pre-auth, fall back to a lowercased-email hash so a quiz lead can later resolve to the subscriber.
// Set at auth_register_succeeded AND auth_login_succeeded:
await Purchases.logIn(APP_USER_ID); // RevenueCat appUserID
amplitude.setUserId(APP_USER_ID); // Amplitude
appsFlyer.setCustomerUserId(APP_USER_ID); // AppsFlyer
// On auth_logout, reset all three:
await Purchases.logOut();
amplitude.reset();
appsFlyer.setCustomerUserId(null);
// Decisions to confirm with TMA before you start:
// 1. What IS the canonical id? (app user id recommended)
// 2. Same id used by the WEB checkout so web lead == app subscriber?
// 3. Pre-auth fallback (email hash) — needed to attribute quiz leads?
| Group | Done when Claude confirms via API… |
|---|---|
| Conversion (P0) | af_start_trial + af_purchase + af_subscribe fire at volumes matching RevenueCat's real trials/purchases |
| Auth/identity (P0) | a test user resolves to ONE profile across RevenueCat + Amplitude + AppsFlyer with purchase attached |
| Workout (P1) | workout_started → workout_completed fire from real devices with props; a retention curve can be read |
| Onboarding (P1) | mini_onboard_completed + advanced_assessment_completed fire; activation funnel is visible |
| Today/profile (P2) | the today_* and profile events fire on interaction (engagement depth available) |