DEV Community

Tal Aizikov
Tal Aizikov

Posted on AI-assisted

One subscriptions row, two stores: Stripe and Google Play in Next.js + Supabase

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;
  };
};
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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 }
  );
}
Enter fullscreen mode Exit fullscreen mode

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 });
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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:

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" }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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" }
);
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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_id holding a Play purchase token works, but it's a trap for the next reader, and "Play rows are the ones where stripe_customer_id is null" is an implicit rule living in a query. A provider column plus external_subscription_id would 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)