Skip to content

Add Payments to a Supabase App: Subscriptions, Webhooks, RLS

Add subscriptions to a Supabase app. Flint runs billing, an Edge Function webhook stores each user's status, and row-level security gates paid data on it.
Getting Paid12 min readBy Flint Pay, Product & API Team

Your app already runs on Supabase: Postgres for data, Supabase Auth for sign-in, and Edge Functions for anything that needs a secret. That's often because the builder set it up. Lovable's built-in backend runs on Supabase's open-source foundation, and Lovable can connect your own Supabase project instead. Bolt connects a Supabase project when you pick Supabase as the database. Now you want people to pay monthly for it.

The short answer#

Keep the billing in Flint and keep one fact in Supabase.

Flint runs everything that involves money: the hosted checkout page, the saved card, each renewal, retries on a declined card, receipts and failed-payment emails, a hosted account where subscribers update their card or cancel, and refunds. Supabase keeps a single row per user with their subscription status. Two Edge Functions and one policy connect them:

  1. create-checkout, called by a signed-in user, creates the user's Flint customer and a hosted checkout session for your plan, then returns its URL.
  2. flint-webhook, called by Flint, verifies the signature, fetches the subscription, and writes its current status to the user's row.
  3. A row-level security policy lets a user read paid rows only while that status is trialing or active.

There's no product catalog, price table, or invoice history to mirror into your database, and no renewal job to schedule: Flint keeps those records and sends the webhook when anything changes. Create a Flint sandbox to follow along. Test keys are free, and Flint processes cards on Stripe, so you don't need a Stripe account of your own.

Store the subscription status in one table#

Add a migration with a billing table keyed to auth.users. It holds the Flint IDs and a cached copy of the subscription's state:

SQL
-- supabase/migrations/20261026000000_billing.sql
create table public.billing (
  user_id uuid primary key references auth.users (id) on delete cascade,
  customer_id text not null unique,
  subscription_id text,
  status text,
  subscription_plan_id text,
  current_period_end_at timestamptz,
  updated_at timestamptz not null default now()
);

alter table public.billing enable row level security;

-- Signed-in users read their own row. Only the service role writes.
revoke all on table public.billing from anon, authenticated;
grant select on table public.billing to authenticated;
grant select, insert, update on table public.billing to service_role;

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

The status column is a copy of what Flint holds, and only your Edge Functions write it, using the service role, which bypasses row-level security. The browser can read its own row and nothing else. If the client could write the column, any user could open the browser console and set themselves to active.

That's also why the columns live in their own table instead of on profiles. If profiles has a policy that lets each user update their own row, that policy would cover a status column too. Revoking the grants matters as much as the policy: on existing Supabase projects, a new table in public starts with every privilege granted to anon and authenticated, and adding policies doesn't take those grants back.

ColumnComes fromUsed for
customer_idcreate-checkout, when it creates the Flint customerFinding the user's row when a webhook arrives
subscription_idThe webhookTelling a new subscription from an old one
statusThe webhookAccess: one of trialing, active, scheduled, incomplete, past_due, paused, or canceled
subscription_plan_idThe webhookWhich plan they're on, if access differs by plan
current_period_end_atThe webhookWhen the current paid period ends

Create the customer and start checkout#

Create the plan once, in the sandbox, as How to take payments in a vibe-coded app shows, and keep its plan_... ID.

Then add Edge Function secrets. On your Supabase project, run:

Shell
supabase secrets set FLINT_API_KEY=flint_test_...
supabase secrets set FLINT_PLAN_ID=plan_...
supabase secrets set APP_URL=https://yourapp.com

The Flint secret key goes into Edge Function secrets and nowhere else. Not in client code, not in a VITE_ or NEXT_PUBLIC_ variable, which build tools copy into the JavaScript every visitor downloads. Supabase provides SUPABASE_URL and the project's keys to Edge Functions on its own, and names starting with SUPABASE_ are reserved, so use your own prefix.

The checkout function uses Supabase's withSupabase wrapper with auth: "user", which rejects any request without a valid user JWT, handles the browser's CORS preflight, and gives the handler the caller's claims and a service role client:

TypeScript
// supabase/functions/create-checkout/index.ts
import { withSupabase } from "npm:@supabase/server@1";

const FLINT_API = "https://api.withflintpay.com/v1";

async function flintPost(path: string, body: unknown, idempotencyKey?: string) {
  const headers: Record<string, string> = {
    "Content-Type": "application/json",
    Authorization: `Bearer ${Deno.env.get("FLINT_API_KEY")}`,
  };
  if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
  const response = await fetch(`${FLINT_API}${path}`, {
    method: "POST",
    headers,
    body: JSON.stringify(body),
  });
  if (!response.ok) throw new Error(`Flint ${path} returned ${response.status}`);
  return (await response.json()).data;
}

export default {
  fetch: withSupabase({ auth: "user" }, async (_req, ctx) => {
    const user = ctx.userClaims!;
    const db = ctx.supabaseAdmin;

    const { data: billing } = await db
      .from("billing")
      .select("customer_id, subscription_id, status")
      .eq("user_id", user.id)
      .maybeSingle();

    // One subscription per user. Send existing subscribers to manage it instead.
    if (billing?.subscription_id && billing.status !== "canceled") {
      return Response.json({ error: "subscription_exists" }, { status: 409 });
    }

    let customerId = billing?.customer_id;
    if (!customerId) {
      const customer = await flintPost(
        "/customers",
        {
          email: user.email,
          external_reference_id: user.id,
          on_email_conflict: "return_existing",
        },
        `customer-${user.id}`,
      );
      customerId = customer.customer_id;
      const { error } = await db
        .from("billing")
        .upsert({ user_id: user.id, customer_id: customerId }, { onConflict: "user_id" });
      if (error) throw error;
    }

    const session = await flintPost("/checkout-sessions", {
      type: "subscription_plan",
      subscription_plan_id: Deno.env.get("FLINT_PLAN_ID"),
      customer_id: customerId,
      prefill: { email: user.email },
      redirects: { success_url: `${Deno.env.get("APP_URL")}/welcome` },
    });

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

The function works from ctx.userClaims.id, the user ID verified from the JWT, and never from an ID in the request body. That matters because supabaseAdmin bypasses row-level security: a query through it returns whatever it asks for, so every query filters on the verified ID. The example assumes users sign in with an email, which subscription checkout requires.

Creating the customer is safe to repeat. on_email_conflict: "return_existing" returns the Flint customer that already has the email instead of failing, two requests racing for one new email get the same customer, and the Idempotency-Key makes a retried request return the original response. external_reference_id stores the Supabase user ID on the Flint customer, so you can find the user from the Flint dashboard during support.

In the app, the Upgrade button invokes the function and sends the browser to the URL exactly as returned:

TypeScript
const { data, error } = await supabase.functions.invoke("create-checkout");
if (!error) window.location.assign(data.url);

supabase.functions.invoke sends the user's session JWT for you. The user pays on Flint's page with a card, Apple Pay, or Google Pay, and card details never reach your app or your database. The success page only welcomes them back. Access comes from the webhook, because a user can pay and close the tab before the page loads.

Write the status from a webhook Edge Function#

Edge Functions require a valid JWT on every request by default, and Flint's webhook deliveries don't carry one, so the platform would reject them with a 401 before your code runs. Turn the check off for this one function in supabase/config.toml:

toml
[functions.flint-webhook]
verify_jwt = false

The setting travels with the project, so the function behaves the same locally and when deployed. supabase functions deploy flint-webhook --no-verify-jwt does the same for a single deploy. Leave create-checkout on the default.

Turning the check off means anyone can call the URL, so the handler authenticates each request itself with Flint's signature. Flint signs deliveries with the Standard Webhooks spec, whose JavaScript library is published on npm as standardwebhooks:

TypeScript
// supabase/functions/flint-webhook/index.ts
import { withSupabase } from "npm:@supabase/server@1";
import { Webhook } from "npm:standardwebhooks@1";

type FlintEvent = {
  webhook_event_id: string;
  event_type: string;
  data: { object: { subscription_id?: string } };
};

const webhook = new Webhook(Deno.env.get("FLINT_WEBHOOK_SECRET")!);
const ok = () => new Response(null, { status: 200 });

export default {
  fetch: withSupabase({ auth: "none" }, async (req, ctx) => {
    // Verify against the exact bytes Flint sent, before parsing anything.
    const body = await req.text();
    let event: FlintEvent;
    try {
      event = webhook.verify(body, Object.fromEntries(req.headers)) as FlintEvent;
    } catch {
      return new Response("invalid signature", { status: 400 });
    }

    const subscriptionId = event.data.object.subscription_id;
    if (!event.event_type.startsWith("subscription.") || !subscriptionId) return ok();

    // Deliveries repeat and arrive out of order, so act on the current state.
    const response = await fetch(
      `https://api.withflintpay.com/v1/subscriptions/${subscriptionId}`,
      { headers: { Authorization: `Bearer ${Deno.env.get("FLINT_API_KEY")}` } },
    );
    if (!response.ok) return new Response("retry", { status: 502 });
    const { data: subscription } = await response.json();

    const db = ctx.supabaseAdmin;
    const { data: row } = await db
      .from("billing")
      .select("subscription_id")
      .eq("customer_id", subscription.customer_id)
      .maybeSingle();
    if (!row) return ok(); // A customer this app didn't create.

    // A late event for an old, ended subscription must not overwrite a newer one.
    const isOlder = row.subscription_id && row.subscription_id !== subscription.subscription_id;
    if (isOlder && subscription.status === "canceled") return ok();

    const { error } = await db
      .from("billing")
      .update({
        subscription_id: subscription.subscription_id,
        status: subscription.status,
        subscription_plan_id: subscription.subscription_plan_id,
        current_period_end_at: subscription.current_period_end_at,
        updated_at: new Date().toISOString(),
      })
      .eq("customer_id", subscription.customer_id);
    if (error) return new Response("retry", { status: 500 });

    return ok();
  }),
};

Three details make it safe to run more than once:

  • It reads the body with req.text(). The signature covers the raw bytes, so the handler verifies before it parses.
  • It sets the row instead of toggling it. Every delivery writes the subscription's current status as Flint reports it now. A repeated delivery writes the same values again, and an old event that arrives late still writes today's state, so you don't need to track which events you've seen.
  • It returns an error when it can't finish. A failed Flint read or database write returns a 5xx, and Flint retries the delivery, up to 9 attempts over about three days. Flint expects a response within 10 seconds, and the handler does one API read and one database write.

Register the endpoint with your sandbox key, pointing at the deployed function's URL, https://<project-ref>.supabase.co/functions/v1/flint-webhook:

cURL
curl -X POST https://api.withflintpay.com/v1/webhook-endpoints \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $FLINT_API_KEY" \
  -H "Idempotency-Key: webhook-supabase-sandbox" \
  -d '{
    "url": "https://<project-ref>.supabase.co/functions/v1/flint-webhook",
    "enabled_events": [
      "subscription.created",
      "subscription.updated",
      "subscription.activated",
      "subscription.payment_succeeded",
      "subscription.past_due",
      "subscription.dunning_exhausted",
      "subscription.paused",
      "subscription.resumed",
      "subscription.plan_changed",
      "subscription.cancellation_scheduled",
      "subscription.reactivated",
      "subscription.canceled"
    ]
  }'

The response is the only time Flint shows the endpoint's whsec_... secret. Store it right away with supabase secrets set FLINT_WEBHOOK_SECRET=whsec_.... Supabase functions read a new secret immediately, without a redeploy.

The list covers every change the row stores: subscription.payment_succeeded moves current_period_end_at forward at each renewal, and subscription.updated arrives when a trial converts. Webhooks covers secret rotation and listing failed deliveries.

Gate paid data with row-level security#

With the status in Postgres, a policy can decide which rows a user reads. Say the app has lessons, some of them premium:

SQL
alter table public.lessons enable row level security;

revoke all on table public.lessons from anon, authenticated;
grant select on table public.lessons to anon, authenticated;

create policy "Free lessons for everyone, premium for subscribers"
on public.lessons for select
to anon, authenticated
using (
  not is_premium
  or exists (
    select 1 from public.billing
    where billing.user_id = (select auth.uid())
      and billing.status in ('trialing', 'active', 'past_due')
  )
);

The inner query reads billing as the signed-in user, so it can only ever see that user's own row. A signed-out visitor has no auth.uid(), so they get the free lessons only. The same exists check works in a policy on storage.objects for paid files.

Decide what each status means before you write the list:

  • trialing and active: paid access.
  • past_due: a renewal failed, and Flint is emailing the subscriber a link to update their card and, by default, retrying on days 1, 4, 9, and 16 after the decline. The policy above keeps access during the retries, which pairs with a banner asking for a new card. Remove past_due from the list to cut access on the first failure.
  • incomplete, scheduled, paused, and canceled: no access. incomplete means the first payment is still being collected.

A subscriber who cancels for the end of the period stays active, with cancel_at_period_end set, until the period ends, so the policy keeps their access for the time they paid for. If access depends on the plan, check subscription_plan_id in the same policy, and keep in mind that a downgrade with billing_starts: "next_renewal" changes the plan on the subscription right away: hold the current access until current_period_end_at yourself.

Row-level security protects what the database returns. Your UI only reflects it: hiding a button or reading status in a React component decides what a user sees, not what they can fetch. For checks that live in app code, such as an API route, a server action, or a call to an AI model your app pays for, see How to add a paywall to your app.

Test it locally, then go live#

Run the stack and the functions on your machine. Edge Functions read local secrets from supabase/functions/.env:

Shell
# supabase/functions/.env
FLINT_API_KEY=flint_test_...
FLINT_PLAN_ID=plan_...
APP_URL=http://localhost:5173
FLINT_WEBHOOK_SECRET=whsec_...

Use a daily plan in the sandbox so renewals arrive within a day. Put the secret from flint listen --print-secret in FLINT_WEBHOOK_SECRET, then start everything:

Shell
supabase start
supabase functions serve
flint listen --forward-to http://localhost:54321/functions/v1/flint-webhook

supabase functions serve runs your functions at http://localhost:54321/functions/v1/<function-name>, and the Flint CLI forwards your sandbox's events there, signed, without a public tunnel. If a secret doesn't come through, pass the file explicitly with supabase functions serve --env-file supabase/functions/.env.

TestHowWhat the row and policy should do
A paid signupUpgrade, then pay with 4242 4242 4242 4242, any future expiry, any CVCstatus becomes trialing or active, and premium lessons appear
A failed renewalSave the 4000 0000 0000 0341 card and point the subscription at it with POST /v1/subscriptions/{id}/payment-methodThe next daily renewal fails, status becomes past_due, and Flint emails the user a link to update their card
A cancellationCall POST /v1/subscriptions/{id}/cancel with cancel_immediately: truestatus becomes canceled, and premium lessons disappear
A repeated deliveryResend an event that already succeededNothing changes
A forged writeFrom the browser console, run supabase.from("billing").update({ status: "active" }) as a free userThe call returns a permission error and the row doesn't change, because the client has no update grant

Subscribing with the 0341 card from the start leaves the subscription incomplete, so save it after a successful signup. Testing lists the other test cards.

Going live doesn't change your code. On the production Supabase project:

  1. Verify your business on the Flint dashboard and create a live key, which starts with flint_live_.
  2. Re-create the plan with the live key. Sandbox IDs don't exist in live mode, so FLINT_PLAN_ID changes.
  3. Register a live webhook endpoint at the production function's URL with the live key. It receives only live events and has its own whsec_ secret.
  4. Set FLINT_API_KEY, FLINT_PLAN_ID, and FLINT_WEBHOOK_SECRET to the live values on the production project. A staging Supabase project keeps the sandbox key and its own sandbox endpoint.
  5. Run the migration, deploy both functions, pay once with a real card, check that the row turns trialing or active, and refund the payment.

Going live has the full checklist. If your app is hosted on Vercel, Railway, Render, Netlify, or Cloudflare instead of calling Edge Functions, Add payments on Railway, Vercel, Render, and Netlify covers server routes and environment variables there.

The prompt#

Paste this into Lovable, Bolt, Claude Code, or Codex. Coding agents can read the Flint docs through the docs MCP server at https://developers.withflintpay.com/mcp; AI agents shows how to connect it.

Text
Add a paid subscription to this Supabase app with Flint
(https://developers.withflintpay.com). Use the Flint docs for
every endpoint.

1. Migration: a public.billing table keyed to auth.users with
   customer_id (unique), subscription_id, status,
   subscription_plan_id, current_period_end_at, updated_at.
   Enable RLS. Revoke all from anon and authenticated, grant
   select to authenticated, and add one select policy for
   (select auth.uid()) = user_id. No insert, update, or delete
   for clients.
2. Edge Function create-checkout (JWT verification on): use
   withSupabase from npm:@supabase/server@1 with auth "user".
   Reuse the user's Flint customer_id from billing, or create
   one with POST /v1/customers (email, external_reference_id =
   user id, on_email_conflict "return_existing") and save it.
   Then create a checkout session with type "subscription_plan",
   FLINT_PLAN_ID, and customer_id, and return its url.
3. Edge Function flint-webhook with verify_jwt = false in
   supabase/config.toml: read the raw body with req.text(),
   verify it with npm:standardwebhooks and FLINT_WEBHOOK_SECRET,
   fetch the subscription from GET /v1/subscriptions/{id}, and
   update the billing row by customer_id with its current status.
   Set values, never toggle them.
4. An RLS policy so premium rows are readable only while status
   is trialing, active, or past_due, and a banner for past_due.
5. Keep FLINT_API_KEY in Edge Function secrets only, never in a
   VITE_ or NEXT_PUBLIC_ variable.
6. Test with a sandbox key and a daily plan: a 4242 signup, a
   failed renewal with the 0341 card, a cancellation, a repeated
   delivery, and a client trying to update its own status.

In Lovable or Bolt, add the secrets in the builder's secrets settings or in your Supabase project's Edge Function secrets, then register the webhook endpoint at the function URL the builder deploys.

What your Supabase app doesn't build#

With billing in Flint, the Supabase side stays one table, two functions, and a policy, however the billing grows:

You wantOn Flint
Failed-payment emails and retriesBuilt in: four retries over 16 days by default, and an email with a link to update the card
A page where subscribers update their card or cancelFlint's hosted customer account, on your own domain if you want
Receipts for every renewalBuilt in: each renewal is an order with its own receipt
Refunding one monthA refund on that renewal's order
An annual planA second plan with billing_interval: "yearly"
Upgrades with credit for unused timePOST /v1/subscriptions/{id}/change-plan

Each row changes the subscription in Flint, the webhook fires, and the same handler writes the new status. Your database never holds an invoice, a proration, or a card. Why payment-first APIs fall short shows what those features cost when the records live in your app instead, and the Stripe go-live checklist for vibe-coded apps goes through the rest of the billing work one item at a time.

Sign up for Flint, add the two Edge Functions, and run the tests against your sandbox.

FAQ#

Can Supabase handle Stripe subscriptions on its own?#

Supabase gives your app a Postgres database, auth, storage, and Edge Functions. Charging a card, renewing it each month, and retrying a decline come from a payment provider that your Edge Functions call. With Flint, which processes cards on Stripe, Supabase stores one row per user with the subscription's status, and Flint keeps the plans, renewals, and payment records.

Do I need a backend to take payments with Supabase?#

Edge Functions are the backend. Your Flint secret key and webhook handler need server-side code, and an Edge Function is server-side code that Supabase hosts. If you don't want any functions at all, a Flint payment link sells a plan from a plain URL, but then your app has to match subscribers to users by email.

How do I check subscription status in Supabase?#

Read the user's row: supabase.from("billing").select("status").maybeSingle() returns only the signed-in user's row because of its row-level security policy. For data that only subscribers should get, put the status check in the policy on that data, so the database enforces it for every query.

Does Lovable work with Flint?#

Yes. Lovable writes Edge Functions, deploys them to its Supabase-based backend or to your own Supabase project, and keeps API keys in Supabase's secret store, out of your app's code. Paste the prompt, add your Flint keys as secrets, and register the webhook endpoint at the function's URL. Bolt works the same way when the project uses Supabase.

Is an Edge Function webhook without JWT verification secure?#

Yes, when the handler verifies Flint's signature before doing anything else. Turning off verify_jwt only removes Supabase's check for a user token, which Flint's deliveries never carry. The signature proves the request came from Flint and that the body wasn't changed, and the handler fetches the subscription from Flint with your secret key before writing anything.

Sources#

Third-party facts above were checked on 2026-10-26.

  • Securing Edge Functions: withSupabase from npm:@supabase/server@1, the user and none auth modes, ctx.supabaseAdmin bypassing RLS, and verify_jwt = false with signature verification for external webhooks.
  • Authorization headers: verify_jwt is on by default and returns a 401 before the function runs when the Authorization header has no valid JWT; turn it off for webhooks from external providers.
  • Function configuration: the [functions.<name>] verify_jwt = false setting in supabase/config.toml.
  • Deploy to production: function URLs at https://<project-ref>.supabase.co/functions/v1/<name>, and config.toml function settings applied across environments.
  • supabase functions deploy CLI reference: the --no-verify-jwt flag.
  • Environment variables: supabase secrets set, local secrets in supabase/functions/.env, the reserved SUPABASE_ prefix, and new secrets read without a redeploy.
  • Development environment: supabase functions serve and the local URL http://localhost:54321/functions/v1/<function-name>.
  • CORS: withSupabase handles CORS preflight requests.
  • Row Level Security: the (select auth.uid()) policy pattern, the service_role bypassing RLS, default grants on new public tables, and policies not revoking grants.
  • Securing your API: default privileges on new objects in public.
  • Storage access control: Storage permissions are RLS policies on storage.objects.
  • @supabase/server README: ctx.userClaims.id is the user ID from the JWT.
  • Standard Webhooks JavaScript library: published on npm as standardwebhooks, with verify(payload, headers).
  • Lovable: Supabase integration and Lovable: built-in backend: the built-in backend uses Supabase's open-source foundation, Lovable writes and deploys Edge Functions, and secrets live in the Supabase project.
  • Bolt: Supabase: Bolt projects can use a Supabase database, auth, and Edge Functions; new projects default to a Bolt database.