Review

Selkie Routes: real-time paddle-wallet top-up via Stripe webhook

Selkie Routes members buy Glow Packs (paddle-shuttle seats) with Stripe Checkout; until now a 30s poller credited wallets after PI status=succeeded and lagged during fog-delay rush hour. This PR replaces the poller with a signed Stripe webhook: on payment_intent.succeeded, credit the member's paddle wallet from PI metadata and return 200 so Stripe stops retrying.

FastAPITier 4stripewebhooksidempotencywallet

Click a line to flag it, pick one or more labels, then submit. If the change looks correct, approve it.

app/routers/stripe_webhooks.py+23-2
11import stripe
22from fastapi import APIRouter, HTTPException, Request
33
44from app.config import settings
55from app.wallets import credit_paddle_wallet
66
77stripe.api_key = settings.STRIPE_SECRET_KEY
88router = APIRouter(prefix="/webhooks", tags=["webhooks"])
99
10-# Poller path (cron every 30s) — replaced by webhook below
11-# async def poll_succeeded_intents(): ...
10+@router.post("/stripe")
11+async def stripe_webhook(request: Request):
12+ payload = await request.body()
13+ sig_header = request.headers.get("stripe-signature")
14+ try:
15+ event = stripe.Webhook.construct_event(
16+ payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
17+ )
18+ except (ValueError, stripe.error.SignatureVerificationError) as exc:
19+ raise HTTPException(status_code=400, detail=str(exc)) from exc
20+
21+ # event["id"] is unique per delivery — safe to act without a dedupe table
22+ if event["type"] == "payment_intent.succeeded":
23+ pi = event["data"]["object"]
24+ member_id = pi["metadata"]["member_id"]
25+ seats = int(pi["metadata"]["glow_pack_seats"])
26+ await credit_paddle_wallet(
27+ member_id=member_id,
28+ seats=seats,
29+ source_event_id=event["id"],
30+ payment_intent_id=pi["id"],
31+ )
32+ return {"received": True, "event_id": event["id"]}