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')).
Related
- Example: Stripe webhooks for a step-by-step webhook function
- Environment variables and secrets
- Authentication and JWT verification
- Edge Function returns 401