MotionLab sells one Pro plan through two checkouts. On the web it's Stripe Checkout. On Android it's Google Play Billing. Two stores with two lifecycles and two kinds of IDs, plus two ways of telling you something changed. The web app doesn't care about any of that. It wants one answer: is this user Pro right now?
We're building MotionLab, personalized stretching and mobility sessions. Earlier posts covered how AI sessions pick exercises and our Postgres push queue. This one covers billing. Everything funnels into a single subscriptions row per user in Supabase. Stripe webhooks write it, a Play verification endpoint writes it, and the UI only ever reads it.
Stack: Next.js 16.2 (App Router route handlers), Supabase Postgres with a service-role client on the server, stripe 21.x and googleapis for the Android Publisher API. Snippets are trimmed from the real code.
1. One row, keyed by user
Here's the row, as it appears in our Supabase Database types:
subscriptions: {
Row: {
user_id: string;
status: string;
interval: string | null;
stripe_customer_id: string | null;
stripe_subscription_id: string | null;
stripe_price_id: string | null;
current_period_end: string | null;
started_at: string | null;
cancel_at_period_end: boolean;
updated_at: string;
};
};
status uses Stripe's vocabulary (active, trialing, past_due, canceled and so on) and interval is month or year. user_id is the conflict key for every write, so each writer does an upsert(..., { onConflict: "user_id" }) and the last write wins. The client-side type mirrors this with timestamps in milliseconds. A missing row maps to a DEFAULT_SUBSCRIPTION with status: 'none', so the UI never has to handle null.
Yes, the columns say stripe_. They predate the Play integration, and the end of this post covers what that costs.
2. Checkout: put the user ID where every later event can find it
The checkout route verifies the Supabase access token, then creates the session:
const session = await stripe.checkout.sessions.create({
mode: "subscription",
customer_email: user.email,
line_items: [{ price: priceId, quantity: 1 }],
metadata: { supabaseUid: user.id, referredByCreatorId },
subscription_data: {
metadata: { supabaseUid: user.id, referredByCreatorId },
},
success_url: `${appUrl}/home?checkout=success`,
cancel_url: `${appUrl}/pricing`,
allow_promotion_codes: true,
});
The user ID goes in twice, and that's on purpose. Session metadata lives on the Checkout Session, which only matters for checkout.session.completed. subscription_data.metadata gets copied onto the Subscription itself, and that's the object every later event points at: renewals, plan changes, cancellations, failed payments. If you only set the first one, your webhook can identify the user exactly once, on the first payment.
The same route refuses to create live-mode sessions from localhost:
if (appUrl.includes("localhost") && process.env.STRIPE_SECRET_KEY?.startsWith("sk_live")) {
return NextResponse.json(
{ error: "Cannot create live-mode checkout sessions from localhost. Set NEXT_PUBLIC_APP_URL." },
{ status: 500 }
);
}
It's a cheap way to make sure a live key on a laptop can't start real charges during local development.
3. The webhook: raw body first, then re-fetch
Stripe signs the exact bytes it sends, so the signature check needs the raw body. With App Router route handlers that's simple: you read request.text() and skip request.json(). There's no body-parser config to turn off.
export async function POST(request: Request) {
const body = await request.text();
const sig = request.headers.get("stripe-signature");
if (!sig) {
return NextResponse.json({ error: "Missing signature" }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
const supabase = createServerClient();
try {
switch (event.type) {
// checkout.session.completed, customer.subscription.updated / .deleted,
// invoice.payment_succeeded / .payment_failed, charge.refunded, charge.dispute.created
}
return NextResponse.json({ received: true });
} catch (err) {
console.error("[webhook] handler error:", err);
return NextResponse.json({ error: "Handler error" }, { status: 500 });
}
}
The outer try matters. If a Stripe API call or our own code throws, the handler returns a 500 and Stripe redelivers. Stripe's webhook docs say it retries for up to three days in live mode.
The same docs also warn that Stripe "doesn't guarantee the delivery of events in the order that they're generated." So for checkout completion and both invoice events, the handler ignores whatever subscription snapshot the event carries. It retrieves the subscription from Stripe and writes the current state:
case "invoice.payment_succeeded": {
const invoice = event.data.object as Stripe.Invoice;
const subId =
(invoice as unknown as { subscription?: string }).subscription ??
invoice.parent?.subscription_details?.subscription;
if (!subId) break;
const sub = await stripe.subscriptions.retrieve(
typeof subId === "string" ? subId : (subId as { id: string }).id
);
const uid = await resolveUid(supabase, sub);
if (!uid) break;
await writeSubscription(supabase, uid, sub);
// ...plus a referral side effect, see section 5
break;
}
That costs an extra API call per event. In exchange, a late or duplicate delivery writes the subscription as it is now, not as it was when the event fired.
4. Stripe moved two fields under us
That subId expression looks fussy, and git history explains why. Two of the earliest fixes to this webhook trace back to API-shape changes, not logic bugs:
-
current_period_endmoved. Our first version readsub.current_period_end * 1000. In Stripe's 2025-03-31 "basil" API version, billing periods moved from the subscription to its items, so the subscription-level field is gone. The fix landed a day after the first billing commit. -
invoice.subscriptionmoved. The same release replaced it withinvoice.parent.subscription_details.subscription.
The current mapper reads the period from the first item and guards against it being absent:
async function writeSubscription(
supabase: ReturnType<typeof createServerClient>,
uid: string,
sub: Stripe.Subscription
): Promise<void> {
const priceItem = sub.items.data[0];
const interval = priceItem?.price.recurring?.interval as SubscriptionInterval | undefined;
const { data: existing } = await supabase
.from("subscriptions")
.select("started_at")
.eq("user_id", uid)
.maybeSingle();
const existingStartedAt = existing?.started_at
? new Date(existing.started_at as string).getTime()
: null;
const payload = {
user_id: uid,
status: sub.status as UserSubscription["status"],
interval: interval ?? null,
stripeCustomerId: sub.customer as string,
stripeSubscriptionId: sub.id,
stripePriceId: priceItem?.price.id ?? null,
currentPeriodEnd: priceItem?.current_period_end
? priceItem.current_period_end * 1000
: null,
startedAt: existingStartedAt ?? (sub.start_date * 1000),
cancelAtPeriodEnd: sub.cancel_at_period_end,
updatedAt: Date.now(),
};
// ...then one upsert into subscriptions (snake_case columns, ISO timestamps),
// { onConflict: "user_id" }
}
Note the started_at read before the write. The profile page shows "Member since", and we didn't want that date to reset every time someone resubscribes or changes plans. Once a row has a start date, later Stripe writes keep it.
There's a small type gap too. charge.invoice exists at runtime but isn't in the Stripe types we're pinned to, so a helper does the cast in one place and nowhere else.
Lesson: pin your SDK and know which API version your webhook endpoint is on. When a field "randomly" goes undefined, check the changelog before you check your logic.
Users from before the migration
MotionLab started on Firebase and moved auth and subscriptions to Supabase in May. Subscriptions created before that carry a firebaseUid in metadata, not a supabaseUid. resolveUid handles both. It prefers the new key and otherwise falls back to the Stripe customer's email:
// New subscriptions use supabaseUid; legacy Firebase ones used firebaseUid
if (sub.metadata?.supabaseUid) return sub.metadata.supabaseUid;
// Legacy: look up the Supabase user by customer email
const customerId = sub.customer as string;
const customer = await stripe.customers.retrieve(customerId);
if (customer.deleted || !("email" in customer) || !customer.email) return null;
const { data } = await supabase.auth.admin.listUsers();
const match = data?.users?.find((u) => u.email === customer.email);
return match?.id ?? null;
Matching on email is a migration bridge, not something to build on. New subscriptions never take that path.
5. Google Play: verify the token on the server, write the same row
On Android, the app completes the purchase with Play Billing and posts the purchase token to /api/play/sync-purchase (the Android client lives in a separate codebase). The route verifies the Supabase token, then asks Google rather than trusting the client:
const publisher = google.androidpublisher({ version: "v3", auth: getPlayAuth() });
const { data } = await publisher.purchases.subscriptionsv2.get({
packageName: PACKAGE_NAME,
token: purchase_token,
});
const lineItem = data.lineItems?.[0];
const expiryTimeIso = lineItem?.expiryTime ?? null;
const subscriptionState = data.subscriptionState;
const status: string =
subscriptionState === "SUBSCRIPTION_STATE_ACTIVE" ? "active" :
subscriptionState === "SUBSCRIPTION_STATE_CANCELED" ? "active" : // still active until expiry
subscriptionState === "SUBSCRIPTION_STATE_ON_HOLD" ? "past_due" :
subscriptionState === "SUBSCRIPTION_STATE_PAUSED" ? "past_due" :
"canceled";
const interval = product_id.includes("monthly") ? "month" : "year";
const now = new Date().toISOString();
const cancelAtPeriodEnd = subscriptionState === "SUBSCRIPTION_STATE_CANCELED";
const { error: upsertError } = await supabase.from("subscriptions").upsert(
{
user_id: user.id,
status,
interval,
stripe_customer_id: null,
stripe_subscription_id: purchase_token,
stripe_price_id: null,
current_period_end: expiryTimeIso,
started_at: data.startTime ?? now,
cancel_at_period_end: cancelAtPeriodEnd,
updated_at: now,
},
{ onConflict: "user_id" }
);
Google's subscription lifecycle guide calls subscriptionsv2.get "the source of truth for subscription management". It also explains the mapping above. A canceled Play subscription keeps its entitlement until expiryTime, which is why CANCELED maps to active with cancel_at_period_end: true, exactly the shape a Stripe "cancel at period end" produces. Account hold and paused subscriptions should lose access, so they map to past_due, which the UI doesn't treat as Pro.
The point of the mapping is that nothing downstream branches on the store. The profile page renders "Renews" or "Cancels" from cancel_at_period_end and current_period_end the same way for both.
Side effects get their own idempotency key
Paid periods also feed a creator-referral ledger. That table has unique (source, external_payment_id), using the Stripe invoice ID or the Play latestOrderId, and inserts go through upsert(..., { onConflict: "source,external_payment_id", ignoreDuplicates: true }). A redelivered webhook or a repeated sync can't double-count a payment. The subscription row can be overwritten freely because it's state. Ledger rows are events, so they need a natural key.
6. Reading it back, and the redirect race
The browser never queries subscriptions directly. It calls /api/subscription with its access token, and the route reads the row with the service-role client and maps it to the shared type. Entitlement in the UI is one line:
const isSubscribed =
subscription?.status === "active" || subscription?.status === "trialing";
There's one race left. Stripe redirects the browser to /home?checkout=success, and the browser can land there before the webhook has written the row. So the page polls:
// Webhook may not have processed yet when the browser lands on the success URL,
// so retry up to 5x at 2-second intervals until we see an active/trialing status.
async function poll() {
if (cancelled) return;
attempts++;
const [sub, count] = await Promise.all([
getUserSubscription(user!.id).catch(() => null),
getGatedSessionCount(user!.id).catch(() => 0),
]);
if (cancelled) return;
setSubscription(sub);
setGatedCount(count);
const isActive = sub?.status === "active" || sub?.status === "trialing";
if (!isActive && attempts < MAX) {
setTimeout(poll, 2000);
}
}
It's bounded and cancellable on unmount. If the webhook is slower than that, the next page load picks the row up anyway, because the auth provider fetches the subscription when the session loads.
What we'd keep, and what we'd change
- Normalize at the write, not the read. Both stores collapse into one status vocabulary when they're written, so every reader stays store-agnostic.
-
Name columns for the concept, not the vendor.
stripe_subscription_idholding a Play purchase token works, but it's a trap for the next reader, and "Play rows are the ones wherestripe_customer_idis null" is an implicit rule living in a query. Aprovidercolumn plusexternal_subscription_idwould say it out loud. - One row per user is an assumption. It's right for "one active plan at a time". If a user ever pays through both stores at once, last write wins, so decide on purpose what should happen.
- Re-fetch on webhooks where you can. The event tells you that something changed. The API tells you what's true now.
If you're curious what the Pro plan actually unlocks, it's on the pricing page.
How do you model cross-store subscriptions: one row, one row per store, or an entitlements table? Tell us in the comments.
This post was drafted with AI assistance and reviewed by the MotionLab team.
Top comments (0)