Seamplan
stripe → supabase

Stripe webhook fires before Supabase row exists checkout session

The webhook isn't late — your client insert is. Treat checkout.session.completed as the source of truth, upsert the subscription row by Stripe id, and never require a prior client write for the webhook handler to succeed.

Why it happens

The browser creates a Checkout Session and may insert a "pending" row. Stripe's checkout.session.completed webhook often arrives before that insert commits — or the insert fails silently. If your webhook does select where user_id = ? and 404s, you drop a paid event. The seam is ordering across two writers (client + Stripe), not Stripe reliability.

Env-var matrix

Variable · which dashboard · publishable / secret / signing · differs per environment?

env-var matrix
variabledashboardkindper env?
STRIPE_SECRET_KEYStripesecretyes
STRIPE_WEBHOOK_SECRETStripe → Developers → Webhookssigningyes
NEXT_PUBLIC_SUPABASE_URLSupabasepublishableyes
SUPABASE_SERVICE_ROLE_KEY
server-only — webhook handler uses this, never the anon key
Supabasesecretyes

The fix

ts
import Stripe from "stripe";
import { createClient } from "@supabase/supabase-js";

export async function onCheckoutCompleted(event: Stripe.Event) {
  const session = event.data.object as Stripe.Checkout.Session;
  const supabase = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!
  );

  const { error } = await supabase.from("subscriptions").upsert(
    {
      stripe_customer_id: session.customer as string,
      stripe_subscription_id: session.subscription as string,
      status: "active",
      user_id: session.metadata?.user_id,
    },
    { onConflict: "stripe_subscription_id" }
  );

  if (error) throw error;
}

What else breaks nearby

Companion repo

open the repo →

← all seams · plan this live