# 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.

Source: https://supahost.app/docs/integrations/stripe · Updated 2026-11-11

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

```sql
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`.

```ts
// 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:

```ts
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](/docs/functions/cors).

## 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.

```ts
// 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:

```text
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:

```bash
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:

```ts
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](/docs/functions/stripe-webhooks) for a step-by-step webhook function
- [Environment variables and secrets](/docs/functions/secrets)
- [Authentication and JWT verification](/docs/functions/auth)
- [Edge Function returns 401](/kb/function-401)
