Stripe One-Time Payments with Supabase
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”:
- Your app asks a Supabase Edge Function to start Checkout
- Stripe shows its hosted payment page
- The user pays
- Stripe tells your webhook the payment succeeded
- Your webhook updates Postgres
- 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).
- Open Stripe Dashboard → Products
- Add a product, for example
Fleet Pro - Set pricing to One time (not recurring)
- Copy the Price ID (
price_…)
Then open Developers → API keys and copy the Secret key:
| Mode | Secret key | You will store it as |
|---|---|---|
| Test | sk_test_… | STRIPE_SECRET_KEY_DEV |
| Live | sk_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.
- Open Stripe Dashboard → Developers → Webhooks
- Click Add endpoint
- 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.
- Under Select events, choose
checkout.session.completed - Click Add endpoint
- Open the endpoint you just created and reveal the Signing secret (
whsec_…) - Copy it and store it as:
| Mode | You will store it as |
|---|---|
| Test | STRIPE_WEBHOOK_SECRET_DEV |
| Live | STRIPE_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.
| Column | Purpose |
|---|---|
access_plan | Feature gate. Moves from free to fleet_pro after payment |
access_status | Set to active on purchase |
stripe_customer_id | Stripe Customer id (cus_…) |
stripe_checkout_session_id | Last Checkout Session (cs_…) |
stripe_payment_intent_id | PaymentIntent (pi_…) |
fleet_pro_purchased_at | When 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_LIVE | Secrets 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_*.
| Secret | Why you need it |
|---|---|
IS_LIVE | Switches 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:
- Accept
POSTwith the user JWT - Validate the JWT and load the profile
- Reject ineligible users (wrong role, unverified email, already Pro)
- If the profile has no
stripe_customer_id, create a Stripe Customer - Persist that Customer id by calling the
set_stripe_customer_idRPC with the service-role client - Create a Checkout Session with
mode: "payment", the Price ID, andmetadata.user_id - 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:
- Read the raw body (do not parse JSON before verification)
- Verify
Stripe-Signature - Insert
event.idintostripe_webhook_events - On
checkout.session.completed, readuser_idfrom session metadata - Call the
activate_fleet_pro_purchaseRPC with the service-role client - If activation fails, delete the event row and return
500so 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:
- Read the Supabase session access token
POSTto${SUPABASE_URL}/functions/v1/create-checkout-session- Send
Authorization: Bearer <token>andapikey: <anon key> - Redirect to the returned
url - After Stripe sends the user back, re-fetch the profile until
access_planis 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
- The browser calls
create-checkout-sessionwith the user JWT. You need the JWT so only a real signed-in user can start a purchase. - The function loads
user_profilesand rejects bad cases early. That saves you from creating useless Stripe sessions. - The function creates a Stripe Customer if needed and saves
stripe_customer_id. Customer first, plan later. Saving the customer does not grant Pro. - The function creates a Checkout Session and returns the hosted
url. - The browser redirects to Stripe. Card details never touch your servers.
Phase 2 — Customer pays
- The user pays on Stripe Checkout.
- Stripe redirects to your success URL. Treat this as “user returned,” not “access granted.”
Phase 3 — Webhook grants access
- Stripe posts
checkout.session.completedtostripe-webhook. - The webhook verifies the signature and stores
evt_…. Signature proof first. Idempotency second. - The webhook calls
activate_fleet_pro_purchase. This is the only place Pro is granted. - The browser re-fetches the profile and unlocks features once the plan is Pro.
Common outcomes
| What happened | What you should see |
|---|---|
| User cancels Checkout | Cancel URL. Plan stays free |
| Expired or missing JWT | 401 from checkout |
| Already purchased | 409 |
| Bad webhook signature | 400. No database write |
| Duplicate webhook | Event 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_LIVEset 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=trueonly when you are ready for real charges
Security reminders
These are the rules that keep the flow trustworthy:
- Stripe secret keys stay in Edge Function env only
- Verify the webhook signature before any database write
- Validate the user JWT when creating Checkout
- Block clients from updating plan and Stripe columns
- Allow
activate_*only forservice_role - Treat webhook processing as idempotent via
evt_…
You now have the full path: sell with Stripe Checkout, confirm with a webhook, unlock from Postgres.
Related reading
- Stripe Subscription Setup with Supabase — recurring billing, plan switching, cancel and resume
- Google OAuth Integration with React and Supabase — authentication flows and token handling
Want to read more articles?