Stripe

Add Stripe payments to an app on SupaHost. Where each piece runs, a subscriptions table with RLS, a Checkout function, a signed webhook function, secrets, and testing with the Stripe CLI.

A typical Stripe setup has three parts: a way to start a payment (Checkout), a way to hear back from Stripe (webhooks), and a table in your database that records what each customer is paying for. On SupaHost, the first two are Edge Functions and the third is a normal Postgres table protected by Row Level Security.

Your Stripe secret key never goes near the browser. The app calls your function, the function calls Stripe.

1. Store subscription state

create table public.subscriptions (
  id text primary key,                 -- Stripe subscription id
  user_id uuid not null references auth.users (id) on delete cascade,
  customer_id text not null,           -- Stripe customer id
  status text not null,                -- active, trialing, past_due, canceled...
  price_id text,
  current_period_end timestamptz,
  updated_at timestamptz not null default now()
);

alter table public.subscriptions enable row level security;

grant select on public.subscriptions to authenticated;

create policy "Users read their own subscription"
  on public.subscriptions for select
  to authenticated
  using ((select auth.uid()) = user_id);

There are no insert or update policies on purpose. Only the webhook function, using the service_role key, writes to this table, so a user can't mark themselves as paid.

2. Add your Stripe keys as secrets

On the project's Functions tab, open the Secrets card and add:

Name Value
STRIPE_SECRET_KEY sk_test_... while testing, sk_live_... in production
STRIPE_WEBHOOK_SECRET the whsec_... signing secret of your webhook endpoint
APP_URL https://invoices.example.com

Values are stored in Vault, never shown again, and reach your functions within about a minute. Names can't start with SUPABASE_; SUPABASE_URL, SUPABASE_ANON_KEY and SUPABASE_SERVICE_ROLE_KEY are already provided.

3. A function that starts Checkout

This function needs the signed-in user, so keep Require JWT on (the default). Note that the anon key also passes that check, so the function still confirms the user itself with getUser.

// create-checkout/index.ts
import Stripe from 'npm:stripe'
import { createClient } from 'npm:@supabase/supabase-js@2'

const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!)

Deno.serve(async (req) => {
  const supabase = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_ANON_KEY')!
  )
  const token = (req.headers.get('Authorization') ?? '').replace('Bearer ', '')
  const { data: { user } } = await supabase.auth.getUser(token)
  if (!user) return new Response('Unauthorized', { status: 401 })

  const { priceId } = await req.json()
  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    line_items: [{ price: priceId, quantity: 1 }],
    customer_email: user.email,
    client_reference_id: user.id,
    subscription_data: { metadata: { user_id: user.id } },
    success_url: `${Deno.env.get('APP_URL')}/billing?done=1`,
    cancel_url: `${Deno.env.get('APP_URL')}/billing`,
  })

  return Response.json({ url: session.url })
})

Call it from the app and send the user to Stripe:

const { data, error } = await supabase.functions.invoke('create-checkout', {
  body: { priceId: 'price_123' },
})
if (!error) window.location.href = data.url

functions.invoke adds the user's access token automatically. If the browser calls it from another origin, add CORS headers to the function; see CORS for browser calls.

4. A function that receives webhooks

Stripe doesn't send a Supabase JWT, so turn Require JWT off for this function on the Functions tab. The Stripe signature check takes its place.

// stripe-webhook/index.ts
import Stripe from 'npm:stripe'
import { createClient } from 'npm:@supabase/supabase-js@2'

const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!)
const crypto = Stripe.createSubtleCryptoProvider()
const admin = createClient(
  Deno.env.get('SUPABASE_URL')!,
  Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)

Deno.serve(async (req) => {
  const body = await req.text() // the raw body, needed for the signature check
  let event: Stripe.Event
  try {
    event = await stripe.webhooks.constructEventAsync(
      body,
      req.headers.get('stripe-signature')!,
      Deno.env.get('STRIPE_WEBHOOK_SECRET')!,
      undefined,
      crypto
    )
  } catch {
    return new Response('Bad signature', { status: 400 })
  }

  if (event.type.startsWith('customer.subscription.')) {
    const sub = event.data.object as Stripe.Subscription
    const item = sub.items.data[0]
    const { error } = await admin.from('subscriptions').upsert({
      id: sub.id,
      user_id: sub.metadata.user_id,
      customer_id: String(sub.customer),
      status: sub.status,
      price_id: item?.price.id ?? null,
      current_period_end: item?.current_period_end
        ? new Date(item.current_period_end * 1000).toISOString()
        : null,
      updated_at: new Date().toISOString(),
    })
    if (error) return new Response(error.message, { status: 500 })
  }

  return Response.json({ received: true })
})

Returning a 500 makes Stripe retry later, which is what you want if the database write failed. Pin the npm:stripe version you tested with (for example npm:stripe@<major>) so a new release doesn't change behaviour under you. Field names such as current_period_end have moved between Stripe API versions; check them against the version your account uses.

In the Stripe dashboard, add a webhook endpoint at:

https://app-one.acme.supahost.space/functions/v1/stripe-webhook

and subscribe it to customer.subscription.created, customer.subscription.updated and customer.subscription.deleted. Copy its signing secret into STRIPE_WEBHOOK_SECRET.

5. Test with the Stripe CLI

With test-mode keys, forward events to the deployed function and trigger some:

stripe listen --forward-to https://app-one.acme.supahost.space/functions/v1/stripe-webhook
stripe trigger customer.subscription.created

stripe listen prints its own whsec_... secret; use that as STRIPE_WEBHOOK_SECRET while testing this way. Check the result in the function's logs (Functions tab, Logs card, filtered to stripe-webhook) and in the subscriptions table in Studio.

Using the subscription in your app

Read it like any other table. RLS returns only the signed-in user's row:

const { data: sub } = await supabase
  .from('subscriptions')
  .select('status, price_id, current_period_end')
  .in('status', ['active', 'trialing'])
  .maybeSingle()

const isPro = Boolean(sub)

To gate data in the database itself, reference the table from your policies, for example using (exists (select 1 from public.subscriptions s where s.user_id = (select auth.uid()) and s.status = 'active')).

Updated 11 November 2026 · View as Markdown · Something wrong or missing? Tell us