Incident brief · Qaf billing · 28 August 2026

RevenueCat → Autumn identity gap

Six iOS subscribers paid (or started a paid trial) and are still on the Free plan in our system. Their App Store purchases were reported to Autumn under a RevenueCat anonymous app-user ID, so Autumn attached the plan to a throwaway customer instead of the real one. The server is behaving correctly given what Autumn tells it — the defect is in identity handoff between RevenueCat and Autumn.

Affected users
6
5 annual trials · 1 paying monthly
Incidence
≈5%
of native purchases, 25–28 Aug · iOS only
Root cause
Verified
outcome chain, 3 independent re-derivations
Trigger
Likely
RC Apple S2S race · one dashboard check to confirm
First renewal at risk
1 Sep
trials convert 1–4 Sep and will not sync

The report

A French user wrote in (via the in-app feedback, twice, minutes after purchasing) saying they had taken the annual subscription at “8 € per month” and still had no access to voice. They asked us to unblock it. The annual launch offer is 99.99/yr ≈ 8.33/month, so the plan matches.

Voice dictation is gated server-side on Autumn feature flags. Their Autumn customer has none. So from the app’s point of view they are a free user who is trying to use a Pro feature — and the paywall they land on tells them their subscription is “still syncing”, forever.

What happened

Every step below was checked against live Autumn and RevenueCat records and our source, then re-derived independently by three verification passes. Chips mark what is verified versus inferred.

  1. The purchase is real verified
    RevenueCat holds one App Store subscription for the reporter’s user ID: annual launch offer, 7-day trial, gives_access: true, entitlement pro active. Started 2026‑08‑28 03:47:08Z, country FR.
    RC v2 GET /customers/{id}/subscriptions
  2. …but it was recorded under an anonymous ID verified
    The subscription’s original_customer_id is $RCAnonymousID:524aac…; its customer_id is the real user, because RevenueCat merged the two afterwards. Five control purchasers (iOS and Android) have original_customer_id == customer_id.
    Same RC record queried via the anonymous ID returns the same subscription.
  3. Autumn keyed on the anonymous ID and created an orphan customer verified
    13 seconds after the purchase, Autumn live gained a customer whose only identity is processors.revenuecat.id = $RCAnonymousID:524aac… (Autumn id: null, no email) holding pro_annual_launch_offer active plus all three Pro flags and 500 messages. Autumn’s own API refuses to address it: customer_id: cannot contain '$'.
    Autumn customers.list, created 03:47:21.959Z. Autumn’s open-source resolver keys on the webhook’s app_user_id, then original_app_user_id, then auto-creates; it never reads aliases and has no TRANSFER handler.
  4. The real customer stayed on Free verified
    Autumn customer LjByEn2uCYLZ3ntfTfuXMAdQnniwkMBj: plan free only, processors null, flags {}, no invoices, no trials used.
  5. So the server denies voice — correctly verified
    Feature access is customer.flags[featureId] !== undefined (packages/billing/src/server/billing.ts:89‑97); findDeniedFeature returns voice_transcription; the transcription route throws PAYMENT_REQUIRED (packages/api/src/routers/transcription/transcription.router.ts:29‑36).
    Ruled out: monetization gating (can only fail open), TestFlight/sandbox routing (no such customer in sandbox), stale native cache (denial is server-side), a second account (only one Autumn customer for this person).

Affected users

All six orphans in Autumn live follow the same shape. All are App Store purchases; none are Android or web. Emails are masked here; IDs are the Better Auth / Autumn / RevenueCat user IDs and are enough to act on.

Anonymous RC ID (orphan)Real user IDEmailCountryPlanStarted (UTC)Trial ends / renewsAutumn state
$RCAnonymousID:8acc3d03…Au0Fozjr8TEqykwT1l2tlUHCczq1tXGtg•••@privaterelay.appleid.comSAAnnual (trial)25 Aug 22:291 Sepfree
$RCAnonymousID:eefeaa85…IE8lnmlJoNMEDzAOG4sgQB4WyIiruK7Ky•••@gmail.comNGAnnual (trial)27 Aug 09:293 Sepfree
$RCAnonymousID:0cf9d45b…vVvNvi0yAEuzjveOOOK3di7zACFRu4jas•••@gmail.comSAAnnual (trial)27 Aug 09:593 Sepfree
$RCAnonymousID:b0e28f82…lI5DMzt1vzCogi4CWjuVvPeyH9HoRpACs•••@icloud.comSAMonthly — paying27 Aug 10:4727 Sepfree
$RCAnonymousID:83a39195…e1cQZ1hUal1xE3jtDOK3lkzc2p7kcLKYy•••@privaterelay.appleid.comAEAnnual (trial)27 Aug 13:043 Sepfree
$RCAnonymousID:524aac16…LjByEn2uCYLZ3ntfTfuXMAdQnniwkMBjr•••@gmail.com reporterFRAnnual (trial)28 Aug 03:474 Sepfree

Population: 125 RevenueCat-backed Autumn customers; ~131 native paid subscriptions 24–28 Aug. Detection query: Autumn customers.list with processors: ["revenuecat"] and look for ids starting $RCAnonymousID.

Why the ID was anonymous

The outcome chain above is settled. The trigger — why RevenueCat had an anonymous identity at purchase time — is where the evidence is indirect, and where the investigation changed its mind. Two candidates, ranked.

Most likely · unconfirmed

RevenueCat’s Apple server-to-server notification race with non-UUID app-user IDs

RevenueCat’s documentation states that new purchases tracked from Apple’s server notifications are matched by the transaction’s appAccountToken, and that this token is not set when the App User ID is not a valid UUID, or when Apple’s notification reaches RevenueCat before the SDK’s receipt post. In that case:

RevenueCat will generate an anonymous App User ID to associate that purchase with. We will then proceed with your transfer behavior for the new App User ID set by the SDK.RevenueCat docs — Apple App Store Server Notifications → Tracking new purchases → User identity

Our app-user IDs are Better Auth’s 32-character IDs, not UUIDs, so the token is never set. Whenever Apple’s notification wins the race (slower mobile networks — the six are FR, SA ×3, NG, AE), RevenueCat creates an anonymous customer, fires INITIAL_PURCHASE for it, and later transfers the subscription to the real ID without any alias webhook. This explains everything that is otherwise odd: iOS only (Google’s RTDN path differs), a random ~5%, no auth churn, purchases logged under the real user in analytics, the 13-second gap.

Decisive check (needs RevenueCat dashboard): Apps → iOS → “Track new purchases from server-to-server notifications”, and the anonymous customer’s event history. Our API key lacks project_configuration:apps:read, so this could not be read programmatically.

Possible, weak · refuted by 2 of 3 verifiers as the trigger

The app anonymises the SDK on transient session loss

NativeBillingProvider receives userId = undefined whenever the Better Auth session is null or anonymous (apps/native/src/app/_layout.tsx:164); the adapter then calls Purchases.logOut() (revenuecat-adapter.ts:483‑497), which mints a new $RCAnonymousID. Better Auth nulls the session on a 401 during revalidation. This path is real and is the only anonymising call in the app — but purchases are serialised behind logIn/configure, the billing route requires a real user, and analytics show zero auth events for any of the six on their purchase day. Only an out-of-band StoreKit post (a pending/SCA transaction delivered later) could land in that window. Worth removing as hygiene; unlikely to be the cause.

Ruled out: an explicit sign-out or anonymous flow before purchase (no such events for any of the six); an older build configuring RevenueCat without an app-user ID (every RC-enabled build passes appUserID).

Latent second defect

Inferred from Autumn’s source · high likelihood

After RevenueCat merges the anonymous ID into the real one, later events carry app_user_id = real and original_app_user_id = anonymous. Autumn’s resolver then matches both the orphan (via the processor ID) and the real customer (via customer.id) and throws 409 MultipleCustomersFound. Renewal, cancellation and expiration for these six will fail to sync to either customer: the orphan stays Pro forever, the real user stays Free forever. The first trial converts on 1 September.

This is why the orphans must be cleaned up, not merely worked around.

What users see

Tapping the microphone opens the billing screen in its subscription‑syncing state (“Your subscription details are still syncing” with a “Check again” button), because the RevenueCat SDK says entitled while summary.activePlanId is null. Settings show “Free Plan”. Restore cannot help — there is nothing to restore. That matches the reporter opening feedback twice within the hour.

Fixing the 6 accounts

1 · Autumn support safest

The RevenueCat integration is beta and the orphans are unaddressable by API. Ask Autumn, per user, to move the RevenueCat-provisioned product from the $RCAnonymousID orphan to the real customer, set the real customer’s processors.revenuecat to the real ID with the anonymous ID as an alias, and delete the orphan. This is the only path that also prevents the 409s on renewal.

Side effects: none on Stripe; possibly one billing.updated → welcome email from our webhook.

2 · Self-serve bridge sandbox-test first

Grant the real customer directly with no invoice — the same call Autumn’s own RevenueCat handler makes:

autumn.billing.attach({
  customerId: "LjByEn2uCYLZ3ntfTfuXMAdQnniwkMBj",
  planId: "pro_annual_launch_offer", // "pro" for lI5DMzt1…
  noBillingChanges: true,            // no Stripe writes
  enablePlanImmediately: true,
  planSchedule: "immediate",
  redirectMode: "never",
  endsAt: 1788493628000,             // RC current_period_ends_at
});

Side effects: one welcome email. Nothing auto-renews or expires this — a re-check at trial end is required. Open risk: the public route may reject a priced plan for a customer with no Stripe customer.

Do not
  • Retry/resend the RevenueCat webhook — it replays the same anonymous payload into the same orphan.
  • Rename the orphan with customers.update — the target ID already exists, and the $ ID is rejected anyway.
  • Delete any orphan before the real customer has access.
  • Attach via web checkout — that creates a Stripe subscription and invoice.

Code changes

Native — one PR, pure JS, no native rebuild

  1. Declare the canonical identity to Autumn. After configure/logIn succeed (revenuecat-adapter.ts ~L465–470), call Purchases.setAttributes({ autumn_customer_id: userId, autumn_customer_email: email }). Autumn resolves this subscriber attribute before app_user_id (getRevenueCatOverrideCustomerId.ts, on main since 4 July). This single change neutralises every trigger candidate, including the Apple race.
  2. Never anonymise the SDK. Remove the Purchases.logOut() branch in disconnect(); keep the generation bump that already invalidates stale work. The next connect(otherUser) does logIn.
  3. Bind before money moves. Immediately before purchasePackage and restorePurchases, assert getAppUserID() === userId; re-logIn once if drifted, otherwise refuse.
  4. Tests in apps/native/test/billing/revenuecat-adapter.test.ts: no logOut on disconnect; purchase re-logs-in when the SDK ID drifted; purchase refuses when the SDK stays anonymous; setAttributes called after connect.

Server — detection first, self-heal behind a flag

  1. Detect divergence. Extend the RevenueCat evidence schema (adapters/revenuecat.ts) with customer_id, original_customer_id, ownership; in loadSubscriptionContext (billing.ts:139‑161) flag “RC says active, purchased, for this user ID — Autumn says free, no RC processor”. Log to Sentry (deduped via Redis), and expose it in the summary so the paywall can say something honest instead of “syncing” forever.
  2. Self-heal (flagged). When divergence is detected from server-fetched RevenueCat evidence only, perform the attach above with endsAt from RevenueCat, idempotent via a Redis lock and a metadata marker; extend or cancel on later checks.
  3. Monitor. Daily job listing Autumn customers whose RevenueCat processor ID starts with $RCAnonymousID or differs from the customer ID; alert on RevenueCat webhook delivery failures (409s).
  4. Get a RevenueCat key with customer_information:customers:read for audit tooling; ours cannot read aliases or history.

Vendors

Decisions needed

  1. Who checks the RevenueCat dashboard for the S2S toggle and the anonymous customer’s history (confirms or kills the leading trigger).
  2. Remediation path for the six: Autumn support (slower, complete) vs. the self-serve attach (fast, needs a follow-up at trial end). Recommendation: open the support ticket now and sandbox-test the attach so we can unblock users if support is slow.
  3. Approve the native PR (attribute + no-logOut + identity assertion) for the next OTA/store build.
  4. Whether to ship the server self-heal or stop at detection + honest UI copy.

Reply to the reporter

Draft, French. Tells them the subscription is real, the fault is ours, nothing to re-buy, no timing promise.

Bonjour Rochdi,

Merci pour votre message et désolé pour la gêne. Nous avons vérifié : votre abonnement Qaf Pro est bien actif côté App Store et vous n'avez rien fait de travers. Le problème vient de chez nous : l'abonnement a été rattaché à un identifiant technique différent de votre compte, donc l'application ne vous reconnaît pas encore comme abonné (d'où le blocage de la dictée vocale).

Nous corrigeons cela sur votre compte ; vous n'avez rien à faire ni à racheter. Nous vous préviendrons par e‑mail dès que c'est en place — il suffira alors de fermer et rouvrir l'application.

Merci pour votre patience et pour nous l'avoir signalé.

Method & sources

Everything was read-only: no records were modified in Autumn, RevenueCat, Stripe, or the database.

Key raw fields
RC subscription subAapc1e2e02aec111563ef5aa55a74a25f72
  customer_id           LjByEn2uCYLZ3ntfTfuXMAdQnniwkMBj
  original_customer_id  $RCAnonymousID:524aac169e10489b91cad72fc2c5ce4f
  store app_store · country FR · status trialing · gives_access true
  starts_at 2026-08-28T03:47:08Z · current_period_ends_at 2026-09-04T03:47:08Z
  entitlement pro (active) · product prodae88158eaf · ownership purchased

Autumn live orphan (customers.list)
  id null · email "$RCAnonymousID:524aac…" · processors.revenuecat.id "$RCAnonymousID:524aac…"
  created_at 2026-08-28T03:47:21.959Z
  subscriptions: cus_prod_3IWmkaKGMzdfmHL89RrLDoHEJVV pro_annual_launch_offer active (trial ends 2026-09-04)
  flags: voice_transcription, search_filters, image_uploads

Autumn live real customer LjByEn2uCYLZ3ntfTfuXMAdQnniwkMBj
  created_at 2026-08-23T00:21:33Z · plan free · processors null · flags {} · invoices []