Skip to content

Commit a0ae399

Browse files
committed
backend: fix Superwall webhook handler to match actual payload schema
Symptom: real Superwall webhooks land with no app_user_id and get rejected with 'missing app_user_id' even though Superwall.shared.identify() ran client-side and the user shows up correctly in Superwall's dashboard. Root cause: the handler was written against an imagined/normalized payload shape — snake_case top-level fields — that doesn't match what Superwall actually sends. The real shape (per https://superwall.com/docs/integrations/webhooks) is: { "object": "event", "type": "initial_purchase", "projectId": ..., "applicationId": ..., "timestamp": ..., "data": { "originalAppUserId": "<uid OR $SuperwallAlias:UUID>", "originalTransactionId": "<durable sub id>", "transactionId": "<per-charge id>", "productId": "com.omi.app.lite_monthly", "expirationAt": <ms since epoch>, "store": "APP_STORE" | "PLAY_STORE" | "STRIPE", ... } } Key differences: - All purchase data lives under `data`, not at top level - Field names are camelCase, not snake_case - `originalAppUserId` is the durable user id (was looking for `app_user_id`) - `originalTransactionId` is the across-renewals sub id (was `subscription_id`) - `expirationAt` is in milliseconds (we store seconds — convert with //1000) - `store` values are UPPERCASE (APP_STORE/PLAY_STORE/STRIPE) not lowercase Handler updates: - dispatch_event extracts `data` from outer envelope, passes to handlers - Each handler reads from camelCase keys via two helpers: _extract_sub_id reads originalTransactionId (falls back to transactionId) _extract_period_end_seconds reads expirationAt and converts ms to s - _detect_source matches uppercase `store` values - dispatch_event rejects `$SuperwallAlias:<UUID>` uids — that prefix means the SDK was configured but identify() wasn't called before the purchase, so we have no omi user to reconcile to This is the same root cause we saw in the user-facing log message 'event initial_purchase missing app_user_id' — handler was reading the wrong key.
1 parent a7f7177 commit a0ae399

1 file changed

Lines changed: 75 additions & 28 deletions

File tree

backend/routers/superwall.py

Lines changed: 75 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -156,13 +156,40 @@ def resolve_plan(product_id: str) -> Optional[PlanType]:
156156

157157

158158
def _detect_source(event: dict) -> SubscriptionSource:
159-
"""Infer ``superwall_ios`` vs ``superwall_android`` from event metadata."""
160-
store = (event.get('store') or event.get('app_store') or '').lower()
161-
if 'play' in store or 'google' in store or 'android' in store:
159+
"""Infer ``superwall_ios`` vs ``superwall_android`` from Superwall's
160+
``store`` field. Per the documented payload, valid values are uppercase:
161+
``APP_STORE``, ``PLAY_STORE``, ``STRIPE``.
162+
"""
163+
store = (event.get('store') or '').upper()
164+
if store == 'PLAY_STORE':
162165
return SubscriptionSource.superwall_android
166+
# APP_STORE, STRIPE, or missing → default to iOS.
163167
return SubscriptionSource.superwall_ios
164168

165169

170+
def _extract_sub_id(event: dict) -> Optional[str]:
171+
"""Pull the durable subscription id from Superwall's payload.
172+
173+
``originalTransactionId`` is the across-renewals identifier; ``transactionId``
174+
changes per charge. We store the original so renewals/cancellations dedupe
175+
against the same sub record.
176+
"""
177+
return event.get('originalTransactionId') or event.get('transactionId')
178+
179+
180+
def _extract_period_end_seconds(event: dict) -> Optional[int]:
181+
"""Convert Superwall's ``expirationAt`` (milliseconds since epoch) to the
182+
unix seconds we store in ``Subscription.current_period_end``.
183+
"""
184+
expires_at_ms = event.get('expirationAt')
185+
if not expires_at_ms:
186+
return None
187+
try:
188+
return int(expires_at_ms) // 1000
189+
except (TypeError, ValueError):
190+
return None
191+
192+
166193
# ── Per-event handlers ──────────────────────────────────────────────────────
167194

168195

@@ -205,8 +232,8 @@ def handle_initial_purchase(uid: str, plan: PlanType, source: SubscriptionSource
205232
_build_subscription(
206233
plan=plan,
207234
source=source,
208-
superwall_sub_id=event.get('subscription_id') or event.get('id'),
209-
current_period_end=event.get('expires_at'),
235+
superwall_sub_id=_extract_sub_id(event),
236+
current_period_end=_extract_period_end_seconds(event),
210237
),
211238
)
212239

@@ -220,8 +247,8 @@ def handle_renewal(uid: str, plan: PlanType, source: SubscriptionSource, event:
220247
_build_subscription(
221248
plan=plan,
222249
source=source,
223-
superwall_sub_id=event.get('subscription_id') or event.get('id'),
224-
current_period_end=event.get('expires_at'),
250+
superwall_sub_id=_extract_sub_id(event),
251+
current_period_end=_extract_period_end_seconds(event),
225252
cancel_at_period_end=False,
226253
),
227254
)
@@ -234,8 +261,8 @@ def handle_cancellation(uid: str, plan: PlanType, source: SubscriptionSource, ev
234261
_build_subscription(
235262
plan=plan,
236263
source=source,
237-
superwall_sub_id=event.get('subscription_id') or event.get('id'),
238-
current_period_end=event.get('expires_at'),
264+
superwall_sub_id=_extract_sub_id(event),
265+
current_period_end=_extract_period_end_seconds(event),
239266
cancel_at_period_end=True,
240267
),
241268
)
@@ -254,8 +281,8 @@ def handle_expiration(uid: str, _plan: PlanType, source: SubscriptionSource, eve
254281
plan=PlanType.basic,
255282
status=SubscriptionStatus.inactive,
256283
source=source,
257-
superwall_subscription_id=event.get('subscription_id') or event.get('id'),
258-
current_period_end=event.get('expires_at'),
284+
superwall_subscription_id=_extract_sub_id(event),
285+
current_period_end=_extract_period_end_seconds(event),
259286
cancel_at_period_end=False,
260287
)
261288
users_db.update_user_subscription(uid, sub.dict())
@@ -269,8 +296,8 @@ def handle_billing_issue(uid: str, plan: PlanType, source: SubscriptionSource, e
269296
plan=plan,
270297
status=SubscriptionStatus.inactive, # we have no `past_due` enum value yet
271298
source=source,
272-
superwall_subscription_id=event.get('subscription_id') or event.get('id'),
273-
current_period_end=event.get('expires_at'),
299+
superwall_subscription_id=_extract_sub_id(event),
300+
current_period_end=_extract_period_end_seconds(event),
274301
cancel_at_period_end=False,
275302
)
276303
users_db.update_user_subscription(uid, sub.dict())
@@ -302,38 +329,58 @@ def handle_subscription_paused(uid: str, plan: PlanType, source: SubscriptionSou
302329

303330

304331
def dispatch_event(event_type: str, payload: dict) -> str:
305-
"""Route a parsed webhook payload to its handler. Returns a status string
306-
for the response body / log line.
332+
"""Route a parsed Superwall webhook payload to its handler.
307333
308-
Payload shape (per Superwall normalized webhook):
334+
Real Superwall webhook shape (per docs):
309335
{
336+
"object": "event",
310337
"type": "initial_purchase",
311-
"app_user_id": "<omi uid>",
312-
"product_id": "com.omi.app.lite_monthly",
313-
"subscription_id": "<superwall sub id>",
314-
"expires_at": <unix seconds>,
315-
"store": "app_store" | "play_store" | ...
338+
"projectId": <int>, "applicationId": <int>, "timestamp": <ms>,
339+
"data": {
340+
"originalAppUserId": "<uid set via identify(), OR $SuperwallAlias:UUID>",
341+
"originalTransactionId": "<durable sub id, persists across renewals>",
342+
"transactionId": "<per-charge id, changes each renewal>",
343+
"productId": "com.omi.app.lite_monthly",
344+
"expirationAt": <ms since epoch>,
345+
"store": "APP_STORE" | "PLAY_STORE" | "STRIPE",
346+
... + many other normalized fields
347+
}
316348
}
349+
350+
Everything we read about the purchase lives under ``data``; the top-level
351+
only carries the routing fields (type, ids, timestamp). Handlers receive
352+
the inner ``data`` dict.
317353
"""
318354
handler = _HANDLERS.get(event_type)
319355
if handler is None:
320356
return 'ignored'
321357

322-
uid = payload.get('app_user_id')
323-
if not uid:
324-
logger.error(f"[superwall] event {event_type} missing app_user_id")
358+
data = payload.get('data') or {}
359+
raw_uid = data.get('originalAppUserId') or ''
360+
361+
# Anonymous alias means the SDK was configured but identify() was not
362+
# called before the purchase — we have no omi user to reconcile to.
363+
# Log and reject so svix's 2xx-or-retry policy doesn't keep replaying.
364+
if raw_uid.startswith('$SuperwallAlias:'):
365+
logger.error(
366+
f"[superwall] event {event_type} arrived with anonymous alias "
367+
f"{sanitize(raw_uid)} — identify() was not called before purchase"
368+
)
369+
return 'missing_uid'
370+
if not raw_uid:
371+
logger.error(f"[superwall] event {event_type} missing originalAppUserId")
325372
return 'missing_uid'
326373

327-
product_id = payload.get('product_id') or ''
374+
product_id = data.get('productId') or ''
328375
plan = resolve_plan(product_id)
329376
if plan is None and event_type != 'expiration':
330377
# Expiration doesn't need a current plan (we revert to basic regardless),
331378
# but every other handler does.
332-
logger.error(f"[superwall] unknown product_id {sanitize(product_id)} for event {event_type}")
379+
logger.error(f"[superwall] unknown productId {sanitize(product_id)} for event {event_type}")
333380
return 'unknown_product'
334381

335-
source = _detect_source(payload)
336-
handler(uid, plan or PlanType.basic, source, payload)
382+
source = _detect_source(data)
383+
handler(raw_uid, plan or PlanType.basic, source, data)
337384
return 'processed'
338385

339386

0 commit comments

Comments
 (0)