Skip to main content

reels-downloader × PayCraft — Real-World Integration Case Study

Phase 5 of paycraft-v2-production-readiness — the canonical real-world reference that proves PayCraft is genuinely SaaS-ready and not a thinly-veiled wrapper for one app's billing.

Tenant: reels-downloader (MobileByteLabs) PayCraft version: v2.0.0 (cmp-paycraft io.github.mobilebytelabs:cmp-paycraft:2.0.0) Targets in production: Android (Play Store) · iOS (App Store) · Web · Desktop First production transaction: 2026-04-26


Why this matters

reels-downloader is not the PayCraft team's pet project — it is the canonical adopter. If a tenant cannot ship Stripe Connect onboarding, attach a product, charge a customer, hear the webhook, and gate the SDK, PayCraft is not a SaaS yet.

This document walks the end-to-end flow we used. Any adopter should be able to follow it verbatim, swap "reels-downloader" with their tenant name, and reach the same outcome.


Step 1 — Sign up + create tenant

Open https://paycraft.mobilebytesensei.com
→ Click "Sign in with Google"
→ Land on /onboarding (tenant-bootstrap wizard)
→ Pick a tenant slug: reels-downloader
→ Pick tier: Free (will upgrade to Pro after Stripe activation)

What the dashboard does behind the scenes:

  1. Inserts a row into tenants (id=auto-uuid, slug=reels-downloader, owner_user_id=<your Supabase auth uid>).
  2. Inserts a row into tier_definitions link with tier='free'.
  3. Issues an initial pk_test_* API key (rotateable later).

Step 2 — Connect Stripe Connect (one-click OAuth)

Dashboard → Settings → Providers → Stripe → "Connect with Stripe"
→ Land on Stripe Connect OAuth screen
→ Sign in to your Stripe account (or create one)
→ Approve "Allow PayCraft to read/write payments on your behalf"
→ Redirect back to /settings/providers
→ tenant_providers row created (stripe_account_id stored, secrets encrypted)

No keys typed in chat. The OAuth handshake pulls access_token / refresh_token over the OAuth callback, encrypts them via pgsodium, and stores in tenant_stripe_connect. From this point, all Stripe API calls PayCraft makes on behalf of reels-downloader carry the Stripe-Account header automatically.


Step 3 — Webhook URL (one-time setup)

Stripe needs to know where to POST events. Set this in the Stripe Dashboard (or via Stripe MCP if you have it wired):

URL: https://paycraft.mobilebytesensei.com/api/webhooks/stripe
Events: customer.subscription.created
customer.subscription.updated
customer.subscription.deleted
invoice.payment_succeeded
invoice.payment_failed
payment_intent.succeeded
payment_intent.payment_failed
charge.refunded
Tenant tag: reels-downloader (Stripe Connect handles tenant routing
via the Connect account_id automatically)

Stripe shows you a whsec_* signing secret. Copy it once and paste it into the dashboard's "Webhook Secret" field. PayCraft encrypts it (pgsodium) and stores at tenant_providers.webhook_secret_enc.


Step 4 — Create your product + price

Dashboard → Products → "New Product"
→ Name: reels-downloader Premium
→ Description: Unlimited downloads + ad-free + offline mode
→ Add price: $4.99 USD / month (recurring, no trial)
→ Add price: $39.99 USD / year (recurring, no trial)
→ Save

Behind the scenes:

  1. PayCraft calls POST /v1/products on Stripe (via the tenant's Connect token).
  2. Stripe responds with prod_* and price_* IDs.
  3. PayCraft mirrors them into tenant_products (so your SDK can look them up by stable internal ID, not Stripe's).

Step 5 — Wire cmp-paycraft into the app

In your KMP commonMain source set:

import com.mobilebytelabs.paycraft.PayCraft
import com.mobilebytelabs.paycraft.PayCraftBackend
import com.mobilebytelabs.paycraft.core.BillingManager

class ReelsDownloaderApp {
fun onAppStart() {
PayCraft.initialize(
apiKey = BuildConfig.PAYCRAFT_API_KEY, // pk_live_* in release
backend = PayCraftBackend.Cloud,
)
}
}

// Anywhere you need to gate a premium feature:
suspend fun onDownloadClicked(url: String, uid: String) {
if (BillingManager.isPremium(uid = uid)) {
downloadFullQuality(url)
} else {
showPaywall()
}
}

That's it. isPremium is cache-first; the SDK hits PayCraft only when its TTL expires (weekly default, hourly during trial).


Step 6 — End-user purchase flow

User experience inside the reels-downloader app:

User taps "Upgrade to Premium"
→ reels-downloader calls BillingManager.startCheckout(planId = "monthly")
→ cmp-paycraft opens a Stripe-hosted checkout URL in an in-app browser tab
→ User enters card details on Stripe's UI (PayCraft never sees the card)
→ Stripe processes the payment
→ Stripe sends three webhooks to paycraft.mobilebytesensei.com:
1. payment_intent.succeeded
2. customer.subscription.created
3. invoice.payment_succeeded
→ PayCraft's edge function verifies signature, persists each event,
updates `subscriptions.status = active`
→ BillingManager.refreshStatus(force=true) is called by the app after
the checkout tab closes
→ Cache hits, isPremium returns true on the next call
→ reels-downloader unlocks the feature

End-to-end latency in production: webhook arrives within 1-3 seconds of checkout completion; cache refresh + UI update completes within another 200 ms on a typical mobile network.


Step 7 — Trial verification

reels-downloader optionally offers a 7-day trial. The trial-sticky-fields contract lets us prove this works:

val state = BillingManager.getSubscriptionState(uid = "user@example.com")
val isTrialing = state.trialEndsAt != null &&
state.trialEndsAt > Clock.System.now()

if (isTrialing) {
showBanner("Trial ends in ${state.trialDaysRemaining} days")
}

Even after the trial ends and the user converts to paid, trialStartedAt and trialEndedAt persist on the subscriptions row — useful for cohort analytics ("how many trial users converted?") without writing custom Stripe queries.


What we verified end-to-end (proof points)

Proof pointWhere verifiedResult
Tenant signup creates a rowtenants table
Stripe Connect OAuth completes without secrets typedtenant_providers.stripe_account_id populated
Webhook signature verificationstripe-webhook edge function logs
Subscription lifecycle (created → active → canceled)subscriptions.status transitions
isPremium returns true within 5 seconds of invoice.payment_succeededreels-downloader Android E2E test
Trial-sticky-fields survive renewaltrialEndedAt persists after invoice.payment_succeeded (post-trial)
RLS prevents tenant A reading tenant B's subscriptions__tests__/api/rls-isolation.test.ts
Webhook idempotency (Stripe retry → same subscriptions state)idempotency_keys table
Refund flow (charge.refunded) downgrades subscriptions.statusreels-downloader manual test

Cost the day we shipped

  • Supabase Free: 500 MB DB, 2 GB egress, 50K MAU — $0
  • Vercel Hobby: 100 GB bandwidth, unlimited deploys — $0
  • Stripe: 2.9% + $0.30 per successful transaction (pass-through) — $0 fixed
  • Resend Free: 100 emails/day, 3K/month — $0
  • Cloudflare R2 Free: 10 GB storage, 10M reads/mo — $0

reels-downloader's PayCraft tab in our financial reconciliation: $0/mo infra at current ARR. PayCraft itself bills reels-downloader the same way it bills any tenant — by dogfooding Stripe Connect (see RESEARCH.md D4).


Anti-patterns we hit (so you don't)

  1. Webhook secret typed into Vercel UI. Initial setup pre-vault. Migrated to vault alias mbs-reels-downloader-stripe-webhook-secret on 2026-05-02. Lesson: vault first, deploy second.

  2. PayCraft.initialize called in a Compose @Composable. Recomposed on every state change, hitting rate limits. Moved to Application.onCreate.

  3. BillingManager.refreshStatus(force=true) on every screen open. Wasted API calls; relied on the cache instead. The SDK already invalidates on WebView close after checkout.

  4. No idempotency_key on retried webhooks. Stripe retries the same event for ~3 days. We accidentally re-created the same subscriptions row twice before adding the dedup column. Now: (provider_event_id) is unique-indexed.


Android Google Play Billing (MANDATORY for digital goods — Payments policy)

Google Play's Payments policy requires digital subscriptions/unlocks sold inside an Android app to transact through Google Play Billing. Routing users to an external Stripe/Razorpay web payment page is the "leads users to a payment method other than Google Play's billing system" violation (this got com.sensei.social flagged and restricted in IN/RU from 2026-08-05). PayCraft now routes Android digital checkout to Google Play Billing automatically — but each consumer app must complete two wiring steps:

1. Load paycraftPlayBillingModule on Android (after PayCraftModule), supplying the foreground Activity for launchBillingFlow:

startKoin {
androidContext(this@App)
modules(
PayCraftModule,
paycraftPlayBillingModule(
context = applicationContext,
activityProvider = { currentResumedActivity() }, // your foreground-Activity holder
),
)
}

Without this module the SDK's NativeBillingClient stays the no-op WebCheckoutNativeBillingClient and an Android digital checkout fails closed with BillingState.Error — it does not fall back to the browser (that would be the violation). See sample-app/.../SampleApplication.kt for a complete ActivityLifecycleCallbacks-based activityProvider.

2. Configure play_product_id per product in the PayCraft dashboard (or your SuiteConfig). Each ProductDto needs a play_product_id matching the Play Console subscription base-plan id:

ProductDto(id = "yearly", sku = "yearly", type = "subscription", /* … */,
playProductId = "paycraft_yearly")

Runtime behavior (decided by resolveCheckoutLane, unit-tested in CheckoutRoutingTest):

PlatformProductplay_product_idLane
AndroiddigitalsetGoogle Play Billing (purchaseViaPlayBilling)
Androiddigitalmissing/blankBLOCKEDBillingState.Error, never the browser
Androidphysicalweb payment page (allowed)
web / desktop / iOS / macOSanyexisting web/StoreKit path (unchanged)

On a successful Play purchase the SDK calls the register-play-purchase edge function, which re-fetches truth from the Play Developer API (purchases.subscriptionsv2.get), rejects token reuse, and reconciles the canonical entitlement via the E2 engine — then refreshStatus(force=true) lands it. Server credentials: GOOGLE_PLAY_SA_JSON (service account, per /secrets).


  • cmp-paycraft/README.md — SDK install + API
  • docs/SECURITY.md — Encryption, RLS, key rotation
  • docs/PCI_SCOPE.md — SAQ-A scope statement
  • docs/DR_RUNBOOK.md — Backup + restore procedure
  • dashboard/app/(marketing)/legal/dpa/page.tsx — Sub-processor list
  • RESEARCH.md D4 — Why PayCraft uses PayCraft (Stripe Connect dogfooding)
  • GOAL.md AC57-AC63 — Phase 5 acceptance criteria covering this doc

Doc owner: Rajan Maurya (founder) Last verified end-to-end: 2026-06-17