Sell Digital Downloads From Any AI-Built Website or Web App

Sell a downloadable digital product with Stripe and Supabase, then give only verified buyers secure access to the private file.

Roadmap & Resources

Choose the Digital Product

Check the Prerequisites

Buyers sign in before they buy. Each purchase belongs to the signed-in account, so the buyer can come back later and download again.

Supabase sign-in already works in my website or web app

Add User Authentication to Any Website with Supabase

No working sign-in yet? Set it up there first, then come back to this guide.

/video/add-user-authentication-supabase

This guide sells one downloadable product. It is not a storefront or shopping cart.

Plan the Digital Product

Start with the one file you want to sell — for example a PDF, ebook, ZIP pack, template, planner, or design asset.

Plan the Digital Product

Tell AI what you are selling and let it inspect the existing product page, purchase flow, and Supabase setup before changing anything.

I want to sell one downloadable digital product from this existing website or web app. Product: {{PRODUCT_NAME}} Download type: {{PRODUCT_TYPE}} Before changing anything, inspect the existing project. Determine: - Where the current product or offer is presented - Whether a Buy button or payment flow already exists - Whether Supabase authentication is already working - Which Supabase project and runtime patterns this app uses, including any existing Edge Functions, migrations or SQL files - Whether Stripe is already integrated anywhere in the project - Whether a private Storage bucket already exists - The smallest implementation path for selling this one downloadable product securely Important: - Do not make changes yet - Do not build a storefront - Do not add multiple products - Do not add a cart - Do not add subscriptions - Do not use a public permanent download URL - Do not trust a Stripe success redirect as proof of payment - Do not put Stripe Secret keys or Supabase privileged credentials in browser code The final design should use: - An authenticated buyer - A server-created Stripe Checkout Session - A verified Stripe webhook - A trusted purchase record that browser code cannot write - Private file storage - Short-lived download access generated only after checking ownership The buyer downloads exactly one file. If the product is made of several files, plan to deliver them as one ZIP. If Supabase authentication is not working in this project, stop and tell me to add it first. Do not create authentication in this task. If the project already has a Stripe integration, a purchases table, or Edge Functions named create-checkout or stripe-webhook, tell me what they do and whether they would conflict with this design. Do not change them. Tell me: 1. What already exists 2. What needs to be added 3. Where the product page and buy action currently live 4. Where the file should live 5. Which server-side functions will be needed Explain the plan in beginner-friendly language. Do not modify anything yet.

What are you selling?

30-Day Content Planner, Website Template Pack, Brand Guide...

What will the buyer download?

Review the plan before continuing. The payment and the download will be connected later — first we need one clear product and one secure ownership model.

Prepare the Private File

Upload the Product Privately

Keep the file in a private Supabase Storage bucket, so it has no public link anyone could share.

Supabase Storage

Create the private bucket your plan recommended, or reuse a private bucket you already have Keep Public bucket turned off Upload the product file Write down the bucket name and the file's exact path inside the bucket

Example path inside the bucket

downloads/your-product-file.zip

IMPORTANT

Do not make the file public, and do not add Storage policies that let buyers read it. The download function you build in Step 5 decides who gets temporary access.

NOTE

Large ZIPs or media files may run into your Supabase plan's current upload and file-size limits. Check the limit for your plan before uploading a very large file.

Confirm the File

Product file uploaded

Bucket is private

Bucket name and exact Storage object path recorded

No permanent public download URL is being used

Set Up the Stripe Payment

Create the Product and One-Time Price

Work in Stripe test mode first. No real money moves until you go live.

Open Stripe

Switch to test mode, then create the product and its price.

https://dashboard.stripe.com

Switch Stripe to test mode Create one Product for the file you are selling Add one price and choose One-off, not Recurring Set the real amount and currency Copy the Price ID — it starts with price_ Copy your test Secret Key from Stripe's API keys page

NOTE

Do not create a Payment Link for this product. Your server creates the checkout, so every purchase is tied to the signed-in buyer.

Supabase Edge Functions Secrets

Supabase Edge Function Secrets

STRIPE_SECRET_KEY STRIPE_PRICE_ID

Use these exact names.

STRIPE_SECRET_KEY

Your Stripe TEST Secret Key

STRIPE_PRICE_ID

The one-time price's Price ID

Copy from Stripe, paste into Supabase

Open Supabase

https://supabase.com/dashboard

IMPORTANT

Paste the Secret Key directly into Supabase — never into your website code or this guide. Use the test key, not a live one.

Prepare the Secure Purchase Backend

Your AI writes the server side. Nothing is run or deployed yet — you do that in the next section.

Prepare the Purchase Backend

Create the secure purchase record and Stripe checkout flow without trusting the browser or success page.

Using the plan we approved, prepare the secure purchase backend for selling one digital download with Stripe and Supabase. Do not run SQL, deploy functions or change the website in this task. I will run and deploy everything myself in the next step. First re-inspect the current project. If it already keeps Supabase migrations or Edge Functions in the repository, follow those conventions and add the files there too. Either way, give me the final SQL as one block and each function's complete code, ready to paste into the Supabase dashboard. These values are already set as Supabase Edge Function secrets. Read them from the function environment. Never print, log or hardcode them: - STRIPE_SECRET_KEY — my Stripe test Secret key - STRIPE_PRICE_ID — the one-time Price for this product STRIPE_WEBHOOK_SECRET does not exist yet. Stripe creates it when I register the webhook endpoint in the next step. For privileged database access, use the current supported way a Supabase Edge Function reaches the database with server-side credentials. Do not ask me to paste a service-role key, a Supabase secret key or a database password. Use the current supported Stripe library and Supabase Edge Function patterns for this runtime. Do not copy an old import URL, API version or deprecated helper from memory. If a table or function with one of the names below already exists for something else — for example an existing Stripe subscription webhook — do not overwrite, merge into or repurpose it. Choose a distinct name and tell me. A. PURCHASE TABLE Create one simple purchase table for this one digital product, following this project's naming and schema conventions. Each row records one verified purchase and identifies: - The signed-in Supabase user who bought the product - The Stripe Checkout Session id, unique, so the same payment can never be recorded twice - The Stripe Price the buyer paid for - Whether Stripe reported a test or a live payment - When the purchase was recorded Security rules: - Enable Row Level Security - A signed-in user may read only their own purchase rows - No browser role may insert, update or delete purchase rows — not their own, and not anyone else's - Only trusted server-side code writes verified purchases - Do not add an anonymous or public read policy - Do not disable or weaken Row Level Security to make something work Do not create orders, products, prices, carts, inventory, quantity or customer tables. Do not add columns for later. B. FUNCTION: create-checkout An authenticated user function. Keep Supabase JWT verification ON. - Identify the buyer from the verified Supabase session, and reject the request if there is no valid signed-in user - The browser sends nothing that decides the purchase: no Price id, product id, user id, amount, currency or return URL. Ignore any such values if they are sent - Before creating anything, check the purchase table. If this user already owns the product, do not start another checkout — return a clear result saying they already own it - Create a Stripe-hosted Checkout Session in one-time payment mode, with exactly one line item: the Price in STRIPE_PRICE_ID, quantity 1 - Tie the session to the buyer on the server: set the session's client reference id to the Supabase user id, and put the same user id in the session metadata - You may prefill the customer email from the verified session, never from the browser - Build the success and cancel URLs on the server. The success URL returns the buyer to the product page with a simple marker that only means they just came back from checkout. The cancel URL returns them to the product page - If this project has no trustworthy server-side app URL, require a secret named APP_URL, read it, and tell me the exact value to set. Do not hardcode localhost or guess my domain - Return only the Checkout URL, or the already-owned result - This function is called from the browser, so handle CORS and preflight the way this runtime currently supports Do not unlock anything in this function. Reaching the success URL proves nothing — anyone can open it. C. FUNCTION: stripe-webhook An external Stripe endpoint. Supabase JWT verification must be OFF for this function only — the Stripe signature is what protects it. It is called by Stripe's servers, not a browser, so it needs no CORS handling. Signature first: - Read the raw request body before parsing anything - Read the Stripe signature header - Verify the signature with STRIPE_WEBHOOK_SECRET using the Stripe library's own verification, in the form that is correct for this runtime - If the signature is invalid, respond with a client error such as 400 and do nothing else - If STRIPE_WEBHOOK_SECRET is missing, fail clearly with a server error. Never process an unverified event, and never invent a secret Handle these events: - checkout.session.completed - checkout.session.async_payment_succeeded Send both through the same fulfillment path: 1. Retrieve the Checkout Session from Stripe by id, with its line items, instead of trusting the copy inside the event. 2. Record a purchase only when all of these are true: the session is a one-time payment; its payment status is paid; it has exactly one line item and that line item's Price is STRIPE_PRICE_ID; and the client reference id and the metadata both carry the same user id. 3. If the payment status is not paid yet, as with a delayed payment method, acknowledge the event and record nothing. checkout.session.async_payment_succeeded arrives later if the payment succeeds. 4. If the session is for a different Price or has no user association, it is not a purchase of this product. Log it without secrets, acknowledge it and record nothing. Never guess a user. 5. Record the purchase with an insert that does nothing when that Checkout Session id already exists, so retries and replays can never create duplicate ownership. Responses: - Invalid signature: 400 - A real processing failure, such as Stripe or the database being unavailable: a server error, so Stripe retries - Purchase recorded, already recorded, deliberately ignored, or an event type this function does not handle: 2xx Never grant access from browser code, and never because a URL contains something like ?success=true. D. OUTPUT When you are done, report: - The SQL to run, as one block - The functions created, each with its complete code and whether JWT verification is ON or OFF - The exact environment and secret names each function needs, including APP_URL if it is required - How the buyer's identity is tied to the Checkout Session - How duplicate webhook delivery is prevented Do not implement the download function yet. Do not tell me payments are working — nothing has been deployed or tested.

Build the Secure Purchase Flow

Run and Deploy the Purchase Backend

Use the SQL, function code and function names from your AI's report.

1. Create the Purchase Table

Open the SQL Editor, paste the purchase-table SQL into a new query, run it once, and confirm it completes without errors.

Supabase SQL Editor

2. Deploy the Purchase Functions

Deploy create-checkout and stripe-webhook with the JWT verification settings below, then copy the stripe-webhook endpoint URL.

Supabase Edge Functions

JWT Verification

create-checkout stripe-webhook

create-checkout

ON

stripe-webhook

OFF — its Stripe signature protects it

3. Create the Stripe Webhook

In Stripe test mode, create one webhook destination with scope Your account, paste the stripe-webhook endpoint URL, and select only the two events below — not all events. If Stripe asks for an API version, take the default.

Stripe Events

checkout.session.completed checkout.session.async_payment_succeeded

checkout.session.completed

Checkout finished

checkout.session.async_payment_succeeded

A delayed payment succeeded later

4. Save the Webhook Secret

Copy the destination's signing secret from Stripe and add it to Supabase Edge Function Secrets with this exact name. If your AI asked for APP_URL, add it there too. No redeploy needed.

Supabase Edge Function Secrets

STRIPE_WEBHOOK_SECRET

Use this exact name.

STRIPE_WEBHOOK_SECRET

Signing secret from your webhook destination

Copy from Stripe, paste into Supabase

Open Supabase

https://supabase.com/dashboard

Connect the Buy Button

Now the product page starts the secure checkout and waits for the verified purchase.

Connect the Secure Checkout

Connect the existing Buy button to server-created Stripe Checkout and wait for the verified purchase record after payment.

Connect this website or web app's existing Buy action to the secure purchase backend we built. Use the approved plan and the existing product page. Do not rebuild the page. First confirm in the current project: - Supabase authentication works and there is an existing Supabase client - The purchase table exists - The create-checkout function exists, and what it returns - Where the product page and its Buy action live now If you cannot see what create-checkout returns, ask me for the backend report from the previous step. Do not guess a response shape. Buy action: - Connect the existing Buy button to create-checkout through the existing authenticated Supabase client - If the visitor is signed out, send them through the project's existing sign-in flow first, then back to the product page - The browser sends no price, product, user id, amount or return URL - When a Checkout URL comes back, redirect to it - When the result says the user already owns the product, show the ownership state instead of starting checkout - Show a loading state and prevent double clicks while checkout is starting - If the page displays a price, it must match the Stripe price. Do not invent one Returning from Stripe: - Use the return marker only to show a temporary state such as "Confirming your purchase..." - While it shows, re-read the signed-in user's own purchase record a few times over a short, bounded period while the webhook arrives - Once the purchase record exists, treat the signed-in user as an owner - If it has not appeared when that time runs out, stop checking and tell the buyer it can take a moment and to refresh - Never grant ownership because of the URL, the redirect or the page the user landed on - No endless polling and no optimistic unlock Ownership state: - Ownership comes only from the user's own purchase record, read through the existing client and Row Level Security - If the signed-in user already owns the product, do not show the normal Buy flow. Show an ownership state such as "You own this" instead. The download button is added in the next step - Re-check ownership on sign-in, sign-out and account switch, and clear it on sign-out - Never store a trusted ownership flag in localStorage, sessionStorage or a cookie Do not: - Expose Stripe secrets or put a Price ID in browser code - Add Stripe.js or a publishable key just to redirect to hosted Checkout - Add subscriptions - Add a billing portal - Add invoices, receipts or accounting screens - Redesign unrelated parts of the page When you are done, report: - Which files changed - Where the Buy action lives and how it calls create-checkout - How the return from Stripe is confirmed - How ownership is read

Unlock the Download

Create Protected Download Access

Build the one function that hands out the file — only to buyers, and only for a moment.

Protect the Download

Give verified buyers a short-lived download link while refusing everyone who does not own the product.

Create one secure download function for the digital product we are selling. Follow this project's Edge Function conventions. Call it get-download-link unless the project already uses a different naming style, or a function with that name already exists for something else. It is an authenticated user function. Keep Supabase JWT verification ON. The product file lives in a private Supabase Storage bucket. Read its location from these Supabase Edge Function secrets, and fail clearly if either one is missing: - PRODUCT_FILE_BUCKET — the private bucket name - PRODUCT_FILE_PATH — the file's exact path inside that bucket Requirements: 1. Require a valid authenticated user, identified from the verified session. 2. Check the trusted purchase table for a verified purchase belonging to that user. 3. If no verified purchase exists, return a forbidden response with no Storage URL, bucket name or file path in it. 4. If a purchase exists, create a short-lived signed Supabase Storage URL for exactly that private file, using trusted server-side access. 5. Give the signed URL a short lifetime of around 60 seconds, and have it download the file with a sensible file name if the current Storage API supports that. 6. Never make the bucket public. 7. Never return a permanent file URL. 8. Never let the browser choose the bucket, the path, the user or the product. Ignore any such values if they are sent. 9. The server alone decides which file belongs to this product. Also: - Return only the signed URL, and only to a buyer - Never log the signed URL or any secret - The browser calls this function, so handle CORS and preflight the way this runtime currently supports - Use the current supported Supabase Storage signed-URL API for this runtime If this project already creates signed URLs for private Storage files, reuse that pattern. Ownership here means a verified purchase record. Do not use an uploader-owns-the-file rule, do not add Storage policies that let buyers read the file, and do not change the purchase table or the Stripe functions. When you are done, report: - The function's complete code and its JWT verification setting - How ownership is checked - The signed URL lifetime you chose - How I can call this function directly while signed in as an account that has NOT bought the product, to confirm it is refused — using my own signed-in session, never a secret key - A plain Storage URL for this file that I can open to confirm the private file does not load without a signed link Do not change the website yet. The Download button comes next.

Supabase Edge Function Secrets

PRODUCT_FILE_BUCKET PRODUCT_FILE_PATH

Use these exact names.

PRODUCT_FILE_BUCKET

The private bucket name from Step 2

PRODUCT_FILE_PATH

The file's exact path inside that bucket

Use the values you wrote down in Step 2

Open Supabase

https://supabase.com/dashboard

Then deploy get-download-link with JWT verification ON.

How Access Works

The purchase record proves that the buyer owns the product. The signed link only delivers the file temporarily. This protects the download on your site, but it cannot stop someone who legitimately bought the file from sharing their downloaded copy.

Add the Download Experience

Show Download only to buyers, and fetch a fresh link every time they use it.

Connect the Download Button

Show Download only to verified buyers and request a fresh short-lived link each time they download.

Add the download experience to the existing product page and to the return state after checkout. Use the ownership state we already built and the get-download-link function. Keep the existing page design. If you cannot see what get-download-link returns, ask me for its report. Do not guess a response shape. Unpaid user: - Sees Buy - Never receives the file, a file path or a Storage URL - Gets nothing by calling the function manually, because the server refuses them Paid user: - Sees an ownership state such as "You own this" - Sees a Download button - Clicking Download requests a fresh signed link from get-download-link and starts the download straight away - Can come back later, sign in and download again without paying again Rules: - Request a signed link only when Download is clicked, never on page load - Never store the signed URL: not in lasting component state, localStorage, sessionStorage, cookies, the database or the page source, and not in logs - Never treat a signed URL as proof of ownership. The purchase record is the entitlement; the signed URL is only temporary delivery - Show a loading state while the link is requested, and prevent double clicks - If the function refuses, re-check ownership and show Buy - If a link fails or expires, let the buyer click Download again for a fresh one Do not add subscriptions, a billing portal, invoices, download counters or anything unrelated. Do not redesign the page. When you are done, report: - Which files changed - Where Download appears and how it calls get-download-link - How a fresh link is requested for each download - Confirmation that no signed URL is stored anywhere

Test Paid vs Unpaid Access

Test the Purchase and Download

Use a Stripe test card, one buyer account, and one account that has not paid. Your AI's report from Step 5 shows how to try the direct function call and the plain Storage URL.

A signed-out visitor and a signed-in non-buyer cannot get the file, even by calling the download function directly

The plain Supabase Storage URL does not open the private file

A test purchase completes in Stripe Checkout and a purchase record appears in Supabase

The buyer sees the ownership state, and Download works through a fresh signed link

A copied download link stops working after it expires

Resending the payment event from Stripe does not create a second purchase record

The buyer can sign in later and download again without paying again

Verify the Secure Delivery

Verify the Paid Download

Verify that payment is trusted server-side, unpaid users are blocked, and only verified buyers receive temporary file access.

Run a focused verification of the digital download sale we just built. Verify: - The Stripe Secret key stays server-side and appears nowhere in browser code - The webhook verifies the Stripe signature on the raw request body - The success redirect is never treated as payment authority - The browser cannot create its own purchase row - The browser cannot modify or delete another user's purchase - Purchase recording is idempotent: a replayed or retried webhook cannot create duplicate ownership - The expected Stripe product and Price are verified before ownership is granted - A payment that is not paid yet grants nothing - Signed-out access to the download is refused - Signed-in non-buyer access is refused, including a direct call to the download function - The private Storage file has no permanent public URL, and the bucket is not public - The browser cannot ask the server to sign an arbitrary bucket or file path - Signed download URLs are short-lived and never stored - A buyer can safely download again later from their account - Adding ?success=true or any similar marker to the URL by hand grants nothing If something directly related is incorrect, fix only that issue. Do not turn this into a broad security audit. Do not add features. When you are done, report: - What you verified, and how - Anything you fixed - Anything directly related that is still wrong - For going live later: one SQL statement that removes only test-mode purchase records, using the stored test or live flag. Do not run it.

Sign in as a real test buyer one last time and confirm Download works; then use a non-buyer account and confirm the same file remains unavailable.

Go Live (Optional)

Do this only after every test above passes — you can finish this guide in test mode. Stripe keeps test and live separate, so going live repeats the Stripe setup with live values.

1. Create the Live Stripe Setup

Switch Stripe to live mode and create the same Product with a One-off price. Then create a live webhook destination set up like your test one: the same stripe-webhook URL and the same two events.

2. Replace the Test Secrets

Update these Supabase Edge Function Secrets with their live values.

Replace in Supabase Edge Function Secrets

STRIPE_SECRET_KEY STRIPE_PRICE_ID STRIPE_WEBHOOK_SECRET

Same names, live values.

STRIPE_SECRET_KEY

Your LIVE Stripe Secret Key

STRIPE_PRICE_ID

The LIVE one-time price's Price ID

STRIPE_WEBHOOK_SECRET

The signing secret from the LIVE destination

Copy from live Stripe, paste into Supabase

Open Supabase

https://supabase.com/dashboard

IMPORTANT

Never mix test and live values: do not reuse a test key, test Price ID or test signing secret. After the swap, the test webhook stops working — that is expected.

3. Prepare the Live App

Run the test-purchase cleanup SQL from your verification report in the Supabase SQL Editor. If you set APP_URL, point it at your real site. Make sure your latest website changes are live.

4. Make One Real Purchase

Buy the product once yourself, confirm the purchase record appears in Supabase and Download works, and only then promote the product.