Web Development

Stripe One-Time Payments with Supabase

July 29, 2026
12 min read

Stripe One-Time Payments with Supabase

You want a user to pay once for Pro access, then unlock features in your app. Stripe should handle the money. Supabase should decide who has access.

This guide walks you through that setup in order. At each step you will know what you are building and why it comes next.

We use Stripe Checkout for a one-time payment. There are no Stripe Subscriptions. Access is stored in Postgres and granted only after a verified webhook.

If you need recurring monthly or yearly billing instead, read Stripe Subscription Setup with Supabase.

Checkout creates the payment. The webhook grants access. Your database is the source of truth.


What you are building

When a signed-in user clicks “Upgrade”:

  1. Your app asks a Supabase Edge Function to start Checkout
  2. Stripe shows its hosted payment page
  3. The user pays
  4. Stripe tells your webhook the payment succeeded
  5. Your webhook updates Postgres
  6. Your UI re-reads the profile and unlocks Pro

The browser never talks to Stripe with a secret key. The browser never flips the plan to Pro. That keeps the flow safe even if someone tampers with the client.

┌─────────┐
│ Browser │  POST + user JWT
└────┬────┘
     │
     ▼
create-checkout-session          ← Edge Function (secret key stays here)
     │
     ├──► Postgres: load profile, check eligibility
     ├──► Stripe: create Customer (cus_…) if needed
     ├──► Stripe: create Checkout Session (cs_…, mode: payment)
     │
     ▼
┌─────────────────┐
│ Stripe Checkout │  user pays on hosted page
└────────┬────────┘
         │
         ├──► Browser: redirect to success URL
         │
         ▼
stripe-webhook: checkout.session.completed (signed)
         │
         ├──► Postgres: store evt_… (idempotency)
         ├──► Postgres: activate plan (fleet_pro + Stripe IDs)
         │
         ▼
Browser re-fetches profile → unlocks Pro

Keep this picture in mind. Everything below fills in one piece of it.


Step 1 — Create what you are selling in Stripe

Before any code, Stripe needs a product to charge for.

Do this twice: once with Test mode ON (sandbox), once with Test mode OFF (live).

  1. Open Stripe Dashboard → Products
  2. Add a product, for example Fleet Pro
  3. Set pricing to One time (not recurring)
  4. Copy the Price ID (price_…)

Then open Developers → API keys and copy the Secret key:

ModeSecret keyYou will store it as
Testsk_test_…STRIPE_SECRET_KEY_DEV
Livesk_live_…STRIPE_SECRET_KEY_LIVE

Keep the Secret key in Supabase secrets only. Never put it in the frontend or commit it to git.


Step 2 — Tell Stripe where to report successful payments

After the user pays, Stripe must call your backend. That call is the webhook. It is the only trusted signal that payment happened.

Do this twice: once with Test mode ON, once with Test mode OFF.

  1. Open Stripe Dashboard → Developers → Webhooks
  2. Click Add endpoint
  3. Set the Endpoint URL to your Supabase function:
https://<project-ref>.supabase.co/functions/v1/stripe-webhook

Replace <project-ref> with your Supabase project reference.

  1. Under Select events, choose checkout.session.completed
  2. Click Add endpoint
  3. Open the endpoint you just created and reveal the Signing secret (whsec_…)
  4. Copy it and store it as:
ModeYou will store it as
TestSTRIPE_WEBHOOK_SECRET_DEV
LiveSTRIPE_WEBHOOK_SECRET_LIVE

The success redirect only means the user came back to your app. The webhook is how Stripe proves the charge. Without it, you would unlock access on a query string, which anyone can fake.

You have not deployed the function yet. That is fine. Create the endpoint now so the secrets are ready when you deploy.


Step 3 — Store purchase state in Postgres

Stripe knows about money. Your app needs to know about access. That lives in your database.

Columns on user_profiles

These fields are what your UI and feature gates will read.

ColumnPurpose
access_planFeature gate. Moves from free to fleet_pro after payment
access_statusSet to active on purchase
stripe_customer_idStripe Customer id (cus_…)
stripe_checkout_session_idLast Checkout Session (cs_…)
stripe_payment_intent_idPaymentIntent (pi_…)
fleet_pro_purchased_atWhen access was granted

Event table for webhook safety

Stripe can send the same event more than once. Store each event id so you process it once.

create table public.stripe_webhook_events (
  id text primary key,          -- Stripe event id (evt_…)
  type text not null,
  processed_at timestamptz not null default now()
);

Enable RLS with no client policies. Only service_role should write here.

If processing fails, delete the event row and return an error so Stripe can retry.

Also add a column-protection trigger so a logged-in user cannot UPDATE their own access_plan or Stripe fields from the client. Trusted writes will come from the Edge Functions in Step 5.


Step 4 — Point Supabase at Stripe with secrets

Your Edge Functions need env values for both sandbox and production. Store each secret twice with a suffix:

  • _DEV — Stripe test mode, local or staging app URL, test webhook signing secret
  • _LIVE — Stripe live mode, production app URL, live webhook signing secret

One flag chooses which set to read at runtime:

IS_LIVESecrets used
false (default)*_DEV
true*_LIVE

For example, STRIPE_SECRET_KEY_DEV holds sk_test_… and STRIPE_SECRET_KEY_LIVE holds sk_live_…. The same pattern applies to APP_URL_*, STRIPE_PRICE_*, and STRIPE_WEBHOOK_SECRET_*.

SecretWhy you need it
IS_LIVESwitches between sandbox and production values
APP_URL_*Success and cancel redirects, plus CORS
STRIPE_SECRET_KEY_*Calls the Stripe API from the server
STRIPE_PRICE_*The one-time Price for Checkout
STRIPE_WEBHOOK_SECRET_*Verifies that webhook calls really came from Stripe

Supabase also injects SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY into Edge Functions.

Flip modes without redeploying:

supabase secrets set IS_LIVE=false   # sandbox
supabase secrets set IS_LIVE=true    # production

Test and live Stripe customers are separate worlds. If you move a user between modes, clear their stripe_customer_id first.


Step 5 — Build the two Edge Functions

You need exactly two functions for this pattern.

create-checkout-session — start the payment

This is what your React app calls. Its job is to create a Checkout Session and return a URL. It must not grant Pro.

Flow inside the function:

  1. Accept POST with the user JWT
  2. Validate the JWT and load the profile
  3. Reject ineligible users (wrong role, unverified email, already Pro)
  4. If the profile has no stripe_customer_id, create a Stripe Customer
  5. Persist that Customer id by calling the set_stripe_customer_id RPC with the service-role client
  6. Create a Checkout Session with mode: "payment", the Price ID, and metadata.user_id
  7. Return { url, sessionId } so the browser can redirect

Step 5 is the important hand-off to Postgres. After Stripe returns cus_…, the Edge Function calls:

await supabaseAdmin.rpc("set_stripe_customer_id", {
  p_user_id: user.id,
  p_customer_id: customerId,
});

That RPC only writes stripe_customer_id. It does not change access_plan.

Define it as SECURITY DEFINER so it can update protected columns. Grant execute to service_role only, and revoke it from anon and authenticated. The browser can start Checkout, but it cannot call this RPC itself.

Attach the Supabase user id to the session so the webhook knows who paid:

metadata: { user_id: user.id, plan: "fleet_pro" }
client_reference_id: user.id

stripe-webhook — confirm payment and grant access

This is what Stripe calls. Its job is to verify the event and activate Pro.

Flow inside the function:

  1. Read the raw body (do not parse JSON before verification)
  2. Verify Stripe-Signature
  3. Insert event.id into stripe_webhook_events
  4. On checkout.session.completed, read user_id from session metadata
  5. Call the activate_fleet_pro_purchase RPC with the service-role client
  6. If activation fails, delete the event row and return 500 so Stripe retries
event = await stripe.webhooks.constructEventAsync(
  body,
  signature,
  webhookSecret,
);

After the signature check passes, grant access like this:

await supabaseAdmin.rpc("activate_fleet_pro_purchase", {
  p_user_id: userId,
  p_customer_id: customerId,
  p_payment_intent_id: paymentIntentId,
  p_checkout_session_id: sessionId,
});

That RPC sets access_plan, stores the Stripe ids, and writes fleet_pro_purchased_at. This is the only place Pro should be granted.

Same security model as above: SECURITY DEFINER, executable only by service_role. Clients must not be able to call it.

Deploy both

supabase functions deploy create-checkout-session --no-verify-jwt
supabase functions deploy stripe-webhook --no-verify-jwt

--no-verify-jwt is intentional:

  • Checkout validates the user JWT inside the function
  • The webhook has no user JWT. Stripe authenticates with the signature instead

If the gateway required a JWT on the webhook, Stripe’s requests would fail.


Step 6 — Call Checkout from the frontend

The UI only needs to start Checkout and then trust the database afterward.

const result = await createFleetProCheckoutSession();
if ("error" in result) {
  // show a user-friendly message
  return;
}
window.location.assign(result.url);

Under the hood:

  1. Read the Supabase session access token
  2. POST to ${SUPABASE_URL}/functions/v1/create-checkout-session
  3. Send Authorization: Bearer <token> and apikey: <anon key>
  4. Redirect to the returned url
  5. After Stripe sends the user back, re-fetch the profile until access_plan is Pro

Do not unlock features from ?checkout=success alone. Wait until Postgres says the plan changed. Webhooks can arrive a moment later.


Step 7 — Walk the full path once (with why at each stage)

Use this as your mental checklist while testing.

Phase 1 — Start Checkout

  1. The browser calls create-checkout-session with the user JWT. You need the JWT so only a real signed-in user can start a purchase.
  2. The function loads user_profiles and rejects bad cases early. That saves you from creating useless Stripe sessions.
  3. The function creates a Stripe Customer if needed and saves stripe_customer_id. Customer first, plan later. Saving the customer does not grant Pro.
  4. The function creates a Checkout Session and returns the hosted url.
  5. The browser redirects to Stripe. Card details never touch your servers.

Phase 2 — Customer pays

  1. The user pays on Stripe Checkout.
  2. Stripe redirects to your success URL. Treat this as “user returned,” not “access granted.”

Phase 3 — Webhook grants access

  1. Stripe posts checkout.session.completed to stripe-webhook.
  2. The webhook verifies the signature and stores evt_…. Signature proof first. Idempotency second.
  3. The webhook calls activate_fleet_pro_purchase. This is the only place Pro is granted.
  4. The browser re-fetches the profile and unlocks features once the plan is Pro.

Common outcomes

What happenedWhat you should see
User cancels CheckoutCancel URL. Plan stays free
Expired or missing JWT401 from checkout
Already purchased409
Bad webhook signature400. No database write
Duplicate webhookEvent id already stored. Safe no-op

Step 8 — Test in sandbox

Keep IS_LIVE=false.

Forward webhooks while you develop:

stripe listen --forward-to https://<project-ref>.supabase.co/functions/v1/stripe-webhook

Test cards:

  • Success: 4242 4242 4242 4242
  • Decline: 4000 0000 0000 0002

To re-test the same user, reset their purchase fields:

select set_config('app.access_update', '1', true);

update public.user_profiles
set
  access_plan = 'free',
  access_status = 'active',
  stripe_customer_id = null,
  stripe_payment_intent_id = null,
  stripe_checkout_session_id = null,
  fleet_pro_purchased_at = null,
  updated_at = now()
where id = (
  select id from auth.users where email = '[email protected]'
);

Clear stripe_customer_id whenever you switch that user between test and live Stripe.


Step 9 — Go live

When sandbox works end to end:

  • Live one-time Price stored as STRIPE_PRICE_*_LIVE
  • Live secret key stored as STRIPE_SECRET_KEY_LIVE
  • Live webhook on the same function URL, secret in STRIPE_WEBHOOK_SECRET_LIVE
  • APP_URL_LIVE set to your real HTTPS origin
  • Both functions deployed with --no-verify-jwt
  • Migration applied (columns, event table, RPCs)
  • Clients cannot call activate_fleet_pro_purchase
  • Set IS_LIVE=true only when you are ready for real charges

Security reminders

These are the rules that keep the flow trustworthy:

  1. Stripe secret keys stay in Edge Function env only
  2. Verify the webhook signature before any database write
  3. Validate the user JWT when creating Checkout
  4. Block clients from updating plan and Stripe columns
  5. Allow activate_* only for service_role
  6. Treat webhook processing as idempotent via evt_…

You now have the full path: sell with Stripe Checkout, confirm with a webhook, unlock from Postgres.

Tags:
Stripe
Supabase
Payments
Checkout
Edge Functions
Webhooks
One-time

Want to read more articles?