Skip to main content

Stripe + Clerk Subscription Integration

How reflex-stripe and reflex-clerk-api work together to handle payment checkout, subscription lifecycle, and user entitlement in Syntropy-Journals.

Architecture

Subscription data lives in three systems kept in sync:
SystemRoleAccess
StripeBilling engine, subscription lifecycleAPI + webhooks
PostgreSQLAudit trail, backend queries (Subscription model)db_utils/subscription.py
Clerk public_metadataFast frontend entitlement check via JWTClerkUser.public_metadata["stripe"]

Metadata Schema

Stored under public_metadata.stripe on the Clerk user:
{
  "stripe": {
    "subscription_status": "active",
    "plan": "Pro",
    "customer_id": "cus_abc123",
    "current_period_end": 1735689600
  }
}
Clerk deep-merges updates — keys you provide are merged, keys set to None are removed, unprovided keys are preserved. 8 KB limit per metadata type.

Data Flow

1. Checkout (frontend → Stripe → DB + Clerk)

File: syntropy_journals/app/states/user/subscription.py
# SubscriptionState.create_checkout_session()
clerk_state = await self.get_state(clerk.ClerkState)
session = stripe.checkout.Session.create(
    mode="subscription",
    client_reference_id=clerk_state.user_id,   # links Stripe ↔ Clerk
    customer_email=auth_state.email,
    metadata={"plan_name": plan_name, "clerk_user_id": clerk_state.user_id},
    ...
)
return rx.redirect(session.url)
After Stripe redirects back to /subscription/success?session_id=..., the success handler runs:
# SubscriptionState.handle_payment_success()

# 1. Persist to PostgreSQL
upsert_subscription_sync(user_id=user_id, stripe_subscription_id=sub_id, ...)

# 2. Update Clerk metadata (best-effort, doesn't block success page)
await clerk.update_user_metadata(
    self,
    public_metadata={"stripe": {
        "subscription_status": "active",
        "plan": plan_name,
        "customer_id": customer_id,
    }},
)

2. Webhook (Stripe → DB + Clerk)

File: syntropy_journals/app/api/routes/stripe_webhook.py Handles four lifecycle events:
EventAction
checkout.session.completedProvision: upsert DB row + set Clerk metadata
customer.subscription.updatedSync: update DB status + update Clerk metadata
customer.subscription.deletedCancel: mark DB canceled + set Clerk status=canceled
invoice.payment_failedDegrade: mark DB past_due + set Clerk status=past_due
The webhook uses clerk_backend_api.Clerk directly (not the Reflex state wrapper) since it runs in a FastAPI endpoint without a Reflex event context:
async def _update_clerk_subscription_metadata(clerk_user_id, status, ...):
    from clerk_backend_api import Clerk
    def _sync_update():
        with Clerk(bearer_auth=secret_key) as c:
            c.users.update_metadata(
                user_id=clerk_user_id,
                public_metadata={"stripe": stripe_metadata},
            )
    await asyncio.to_thread(_sync_update)
Identity resolution: The webhook finds the Clerk user ID via:
  • checkout.session.completedsession.client_reference_id (set during checkout)
  • Other events → reverse lookup: stripe_customer_idUser.clerk_id via DB

3. Reading subscription status (Clerk → AuthState)

File: syntropy_journals/app/states/shared/clerk_auth.py On every authenticated page load, AuthState reads the Clerk metadata:
# Inside sync_auth_state()
clerk_user = await self.get_state(clerk.ClerkUser)
stripe_meta = (clerk_user.public_metadata or {}).get("stripe", {})
if stripe_meta:
    self.subscription_status = stripe_meta.get("subscription_status", "none")
    self.subscription_plan = stripe_meta.get("plan", "Free")
Computed var for feature gating:
@rx.var(cache=True)
def is_subscribed(self) -> bool:
    return self.subscription_status in ("active", "trialing")

Key Libraries

reflex-clerk-api (v1.2.4)

Provides two ways to update Clerk user metadata:
APIContextUsage
clerk.update_user_metadata(self, public_metadata=...)Reflex event handlersSubscriptionState.handle_payment_success()
clerk_backend_api.Clerk().users.update_metadata(...)FastAPI endpoints, webhooksstripe_webhook.py
The ClerkUser state exposes public_metadata, private_metadata, and unsafe_metadata as dict[str, Any] fields, populated automatically during load_user().

reflex-stripe (v0.1.1)

Provides the Embedded Checkout UI (embedded_checkout_session()) and Express Checkout (stripe_provider() + ExpressCheckoutBridge). The actual subscription management (creating sessions, handling webhooks) is done by the main app using the stripe Python SDK directly — reflex-stripe handles the frontend checkout form only.

Environment Variables

VariableUsed byRequired for
STRIPE_SECRET_KEYsubscription.py, stripe_webhook.pyStripe API calls
STRIPE_WEBHOOK_SECRETstripe_webhook.pyWebhook signature verification
CLERK_SECRET_KEYstripe_webhook.pyUpdating Clerk metadata from webhooks
STRIPE_PUBLISHABLE_KEYFrontend (reflex-stripe)Checkout UI initialization

Files

FilePurpose
syntropy_journals/app/states/user/subscription.pySubscriptionState — checkout + payment success
syntropy_journals/app/api/routes/stripe_webhook.pyWebhook endpoint — lifecycle sync
syntropy_journals/app/states/shared/clerk_auth.pyAuthState — reads public_metadata.stripe
syntropy_journals/app/functions/db_utils/subscription.pyDB helpers (upsert, status update, reverse lookup)
syntropy_journals/app/models/admin/subscription.pySubscription SQLModel
syntropy_journals/app/models/admin/user.pyUser model with clerk_id field

Testing

# Unit tests (no DB/API keys needed)
APP_ENV=test uv run python -m pytest tests/unit/test_stripe_webhook.py -v
APP_ENV=test uv run python -m pytest tests/unit/test_subscription_state.py -v
APP_ENV=test uv run python -m pytest tests/unit/test_subscription_model.py -v
Stripe CLI for local webhook testing:
# Forward Stripe events to local webhook endpoint
stripe listen --forward-to localhost:8000/api/stripe/webhook

# Trigger test events
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed