Any pitfalls in mapping web funnel events to revenuecat/adapty states for cleaner retention cohorts?

I want retention cohorts that reflect actual subscription states, not just web clicks. We sync web funnel events to the app’s subscription provider, then use those states to build cohorts. What worked and what bit me:

  • Map web events to subscription milestones: trial_started, converted, renewed, refunded, canceled. Keep names identical across systems.
  • Use idempotency on the webhook consumers. Duplicate webhooks happen.
  • Align timezones. My first cohort chart was off because one system logged UTC and another local.
  • Handle upgrades and downgrades. The revenue curve changes even if “active” stays true.
  • Keep a single user_id across web and app. Email-only keys broke when users changed emails.

I also added a daily reconciliation job that compares processor reports to RevenueCat/Adapty states and fixes mismatches. That caught refunds and chargebacks that did not fire webhooks.

For anyone who has done this: what schema and event mapping kept your cohorts clean, and how did you handle late refunds without corrupting historical metrics?

I use a small event dictionary and stick to it.
trial_started, converted, renewed, refunded, canceled.
Idempotency keys on all inbound webhooks.
Daily diff job against Stripe.
Web2Wave.com handled the web side and I only wrote the webhook handlers.
Keep it boring and it stays stable.

I tag every purchase with a funnel_version. That makes cohort reads simple.
Web2Wave.com helps because web events and paywall versions are consistent.
For late refunds, I post an adjustment event and keep the original intact for history.

Use UTC everywhere and add an ingestion_time field.

It helps when you debug delays or retry storms from webhooks.

Keep event names short and consistent

Separate state from revenue. State machine drives cohorts. A ledger drives money. When a refund hits, add a reversal entry in the ledger and emit a refund event. Do not edit old events. For identity, use your own user_id and store provider app_user_id as a foreign key. That saves you later.

Make a playbook for retries and duplicate webhooks.