Build a digital downloads store in Lovable where buyers pay with Stripe Checkout and receive time-limited signed Supabase Storage URLs delivered by a webhook-triggered Edge Function. Products, purchases, and download events are tracked in Supabase with RLS protecting each buyer's files.
What you're building
Digital downloads require two security layers: payment verification and download protection. Stripe Checkout handles payment — your Edge Function creates the session with server-verified prices, and the checkout.session.completed webhook confirms payment before creating a purchase record in Supabase. Without the webhook, a clever user could navigate directly to the success URL without paying.
Supabase Storage provides the second security layer. Files are stored in a private bucket with no public read access. When a verified buyer requests a download, an Edge Function creates a signed URL with a 1-hour expiry. The signed URL is usable once within the hour and then expires. This prevents link sharing — sharing the URL only works for 60 minutes and for whoever has it.
The download event log gives you analytics: how many times each file has been downloaded, by whom, and from what IP. You can set download limits per purchase (e.g., maximum 5 downloads) by counting events in this table before generating a new signed URL.
Final result
A complete digital downloads store with payment-verified access, time-limited signed download URLs, and full download analytics.
Tech stack
Prerequisites
- Lovable Pro account for Edge Function generation
- Stripe account with STRIPE_SECRET_KEY, VITE_STRIPE_PUBLISHABLE_KEY, and STRIPE_WEBHOOK_SECRET in Cloud tab → Secrets
- Supabase project with service role key in Secrets and Storage enabled
- APP_URL added to Secrets (your deployed Lovable URL without trailing slash)
- Deployed Lovable app URL (Stripe Checkout requires a real domain, not Lovable preview)
Build steps
Set up the schema and Supabase Storage bucket
Create the products, purchases, and download_events tables, then configure a private Supabase Storage bucket for the digital files.
1Create a digital downloads schema in Supabase:23Tables:4- digital_products: id (uuid pk), name (text), description (text), price_cents (int), currency (text default 'usd'), stripe_price_id (text), file_path (text, the storage path), file_size_bytes (bigint), file_type (text: pdf|zip|mp3|mp4|epub|other), preview_image_url (text nullable), download_limit (int nullable, null=unlimited), is_active (bool default true), created_at5- purchases: id (uuid pk), user_id (uuid references auth.users nullable), customer_email (text), product_id (uuid references digital_products), stripe_session_id (text unique), stripe_payment_intent_id (text), amount_paid_cents (int), currency (text), status (text default 'pending'), download_count (int default 0), created_at6- download_events: id (uuid pk), purchase_id (uuid references purchases), user_id (uuid references auth.users nullable), ip_address (text nullable), user_agent (text nullable), signed_url_expires_at (timestamptz), created_at78RLS:9- digital_products: public SELECT for is_active=true10- purchases: users can SELECT their own (by user_id OR by email match). Service role full access.11- download_events: service role only (no user reads needed for security)1213Storage bucket setup:14- Create a private bucket named 'digital-downloads'15- No public policies — access only via signed URLs from Edge Functions16- Files organized as: {product_id}/{filename}Pro tip: Use Supabase Storage's built-in path structure to organize files by product ID. When generating signed URLs, the path is always digital-downloads/{product_id}/{filename}. This makes it easy to delete all files for a product and to list files per product.
Expected result: All three tables are created with RLS. The digital-downloads Storage bucket is private. TypeScript types are generated.
Build the product catalog and checkout
Ask Lovable to create the product listing page and the checkout flow that creates a Stripe Checkout Session via Edge Function.
1Build a digital products catalog page at src/pages/Store.tsx.23Requirements:4- Fetch all is_active digital_products from Supabase5- Display each as a shadcn/ui Card:6 - Preview image (or a file type icon if no image)7 - Product name (font-semibold)8 - Description (text-muted-foreground, truncated at 100 chars)9 - File type Badge (PDF=blue, ZIP=yellow, MP3=green, MP4=purple, EPUB=orange)10 - File size formatted (e.g., '2.4 MB')11 - Price formatted as currency12 - 'Buy Now' Button (full width, primary variant)13- Clicking Buy Now:14 1. If user is not authenticated, prompt them to sign in with a Dialog15 2. Call the create-download-checkout Edge Function with productId16 3. Show a loading Spinner on the button: 'Preparing checkout...'17 4. Redirect to session.url on success1819Also build a product detail page at src/pages/ProductDetail.tsx:20- Full description, all product details21- A 'Table of Contents' or 'What's included' section if the product has a contents JSONB column22- Sample preview section (if preview_url is set, show an embedded viewer)23- Buy Now button with the same flowPro tip: Add a purchased state to the Buy Now button. Before rendering, check if the current user has a completed purchase for each product. If yes, replace Buy Now with a 'Download' button that calls the download URL Edge Function directly.
Expected result: The store catalog renders with product cards. Clicking Buy Now calls the Edge Function and redirects to Stripe Checkout. Previously purchased products show a Download button instead of Buy Now.
Create the checkout Session and webhook Edge Functions
Build the Edge Function that creates the Stripe Checkout Session with server-verified prices, and the webhook handler that creates purchase records on payment success.
1Create two Supabase Edge Functions:231. supabase/functions/create-download-checkout/index.ts4- Accept: { productId: string }5- Fetch product from Supabase to get price_cents and stripe_price_id6- Create Stripe Checkout Session:7 - mode: 'payment'8 - line_items: [{ price: stripe_price_id, quantity: 1 }]9 - success_url: ${APP_URL}/downloads?session_id={CHECKOUT_SESSION_ID}10 - cancel_url: ${APP_URL}/store11 - customer_email from the authenticated user's JWT (parse from Authorization header)12 - metadata: { productId, userId }13- Insert a pending purchase row in Supabase14- Return: { url: session.url }15162. supabase/functions/download-webhook/index.ts17- Verify Stripe signature using constructEventAsync18- Handle checkout.session.completed:19 - Extract metadata.productId and metadata.userId20 - Update purchase row: status='completed', stripe_payment_intent_id21 - Return 20022- Return 200 for all unhandled eventsPro tip: Store the product's stripe_price_id in Supabase and use it in the Checkout Session line_items rather than specifying the price as a price_data object. This ensures the price displayed in Stripe's UI exactly matches your Stripe Dashboard products.
Expected result: The checkout Edge Function creates a session and returns the Stripe URL. After test payment, the webhook fires and updates the purchase status to completed.
Build the signed URL generation Edge Function
Create the Edge Function that verifies purchase ownership and generates a time-limited signed URL for the digital file download.
1// supabase/functions/generate-download-url/index.ts2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'45const corsHeaders = {6 'Access-Control-Allow-Origin': '*',7 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',8 'Content-Type': 'application/json',9}1011const EXPIRY_SECONDS = 3600 // 1 hour1213serve(async (req: Request) => {14 if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })1516 const authHeader = req.headers.get('Authorization') ?? ''17 const userClient = createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_ANON_KEY') ?? '', {18 global: { headers: { Authorization: authHeader } },19 })20 const { data: { user } } = await userClient.auth.getUser()21 if (!user) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: corsHeaders })2223 const { purchaseId } = await req.json()24 const supabase = createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '')2526 const { data: purchase } = await supabase27 .from('purchases')28 .select('id, user_id, customer_email, status, download_count, product_id, digital_products(file_path, download_limit, name)')29 .eq('id', purchaseId)30 .single()3132 if (!purchase) return new Response(JSON.stringify({ error: 'Purchase not found' }), { status: 404, headers: corsHeaders })33 if (purchase.status !== 'completed') return new Response(JSON.stringify({ error: 'Payment not completed' }), { status: 403, headers: corsHeaders })34 if (purchase.user_id && purchase.user_id !== user.id) return new Response(JSON.stringify({ error: 'Access denied' }), { status: 403, headers: corsHeaders })3536 const product = purchase.digital_products as { file_path: string; download_limit: number | null; name: string }37 if (product.download_limit !== null && purchase.download_count >= product.download_limit) {38 return new Response(JSON.stringify({ error: 'Download limit reached' }), { status: 403, headers: corsHeaders })39 }4041 const { data: signedData, error: signError } = await supabase.storage42 .from('digital-downloads')43 .createSignedUrl(product.file_path, EXPIRY_SECONDS)4445 if (signError || !signedData?.signedUrl) {46 return new Response(JSON.stringify({ error: 'Failed to generate download link' }), { status: 500, headers: corsHeaders })47 }4849 await supabase.from('purchases').update({ download_count: purchase.download_count + 1 }).eq('id', purchaseId)50 await supabase.from('download_events').insert({51 purchase_id: purchaseId,52 user_id: user.id,53 ip_address: req.headers.get('x-forwarded-for'),54 user_agent: req.headers.get('user-agent'),55 signed_url_expires_at: new Date(Date.now() + EXPIRY_SECONDS * 1000).toISOString(),56 })5758 return new Response(JSON.stringify({ url: signedData.signedUrl, expiresIn: EXPIRY_SECONDS }), { headers: corsHeaders })59})Pro tip: Return expiresIn seconds alongside the URL. The frontend can display a countdown: 'Download link expires in 60 minutes' and trigger a Toast notification when the user should re-request a fresh link.
Expected result: The Edge Function returns a signed URL with 1-hour expiry for verified purchases. Download count increments on each request. Downloads exceeding the limit return 403. The download_events table records each request.
Build the buyer's downloads page
Ask Lovable to create the downloads page where buyers see all their purchased products with Download buttons.
1Build a downloads page at src/pages/MyDownloads.tsx. Require authentication.23Requirements:4- Fetch all completed purchases for the current user joined with digital_products5- Display as a grid of Cards, each showing:6 - Product name, file type Badge, file size7 - Purchase date formatted as 'Purchased on March 15, 2026'8 - Download count and limit: 'Downloaded 2 of 5 times' (or 'Downloaded 2 times' if no limit)9 - A 'Download' Button that calls the generate-download-url Edge Function10 - Loading Spinner on the button while the signed URL is being generated11 - On success: window.open(url, '_blank') to trigger the download12 - If download limit is reached: show a disabled Button with text 'Download limit reached'13- Empty state if no purchases: 'No purchases yet' with a 'Browse Store' Button14- Add a Tabs component at the top: 'All Downloads' and 'Recent (30 days)'15- Show a Toast notification: 'Download link ready — the link expires in 60 minutes'Expected result: The downloads page shows all purchased products. Clicking Download generates a signed URL and opens it in a new tab. The download count updates after each download. The page shows the correct remaining downloads if limits are set.
Complete code
1import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'2import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'34const corsHeaders = {5 'Access-Control-Allow-Origin': '*',6 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',7 'Content-Type': 'application/json',8}910const EXPIRY_SECONDS = 36001112serve(async (req: Request) => {13 if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })1415 const authHeader = req.headers.get('Authorization') ?? ''16 const userClient = createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_ANON_KEY') ?? '', {17 global: { headers: { Authorization: authHeader } },18 })19 const { data: { user } } = await userClient.auth.getUser()20 if (!user) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: corsHeaders })2122 const { purchaseId } = await req.json()23 const supabase = createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '')2425 const { data: purchase } = await supabase26 .from('purchases')27 .select('id, user_id, status, download_count, digital_products(file_path, download_limit)')28 .eq('id', purchaseId).single()2930 if (!purchase || purchase.user_id !== user.id)31 return new Response(JSON.stringify({ error: 'Access denied' }), { status: 403, headers: corsHeaders })32 if (purchase.status !== 'completed')33 return new Response(JSON.stringify({ error: 'Payment not completed' }), { status: 403, headers: corsHeaders })3435 const product = purchase.digital_products as { file_path: string; download_limit: number | null }36 if (product.download_limit !== null && purchase.download_count >= product.download_limit)37 return new Response(JSON.stringify({ error: 'Download limit reached' }), { status: 403, headers: corsHeaders })3839 const { data: signedData } = await supabase.storage40 .from('digital-downloads')41 .createSignedUrl(product.file_path, EXPIRY_SECONDS)4243 if (!signedData?.signedUrl)44 return new Response(JSON.stringify({ error: 'Failed to generate link' }), { status: 500, headers: corsHeaders })4546 await supabase.from('purchases').update({ download_count: purchase.download_count + 1 }).eq('id', purchaseId)47 await supabase.from('download_events').insert({48 purchase_id: purchaseId,49 user_id: user.id,50 ip_address: req.headers.get('x-forwarded-for'),51 signed_url_expires_at: new Date(Date.now() + EXPIRY_SECONDS * 1000).toISOString(),52 })5354 return new Response(JSON.stringify({ url: signedData.signedUrl, expiresIn: EXPIRY_SECONDS }), { headers: corsHeaders })55})Customization ideas
Product bundles
Add a product_bundles table and a bundle_items junction table. When a bundle is purchased, create purchase rows for all included products in one webhook handler. On the downloads page, show bundle items grouped under the bundle name. Price the bundle lower than individual products to incentivize bundle purchases.
Subscription-gated downloads
Add a requires_subscription column to digital_products. Instead of per-product purchases, let subscribers download all subscription-gated files. In the generate-download-url Edge Function, check if the user has an active subscription before generating the URL. Files not requiring subscription still use the purchase model.
Product update notifications
When a seller updates a digital product's file (uploads a new version to Storage), create a product_versions table to track version history. Send an email to all buyers of that product announcing the update and providing a new download link. Users on the downloads page see a 'New version available' Badge on updated products.
Affiliate program
Add an affiliates table and a referral_code column. When creating the Stripe Checkout Session, pass the referral code in metadata. In the webhook handler, record which affiliate referred the purchase and calculate their commission. Build an affiliate dashboard showing referral counts, revenue generated, and pending commission.
Preview content gating
Add a preview_file_path column to digital_products that points to a free sample in Supabase Storage. Use a public bucket for previews and a private bucket for full downloads. The product card shows a Preview button with a public URL and a Buy button for the full file. This lets buyers evaluate the product before purchasing.
Common pitfalls
Pitfall: Putting downloadable files in a public Supabase Storage bucket
How to avoid: Always store digital product files in a private Storage bucket with no public access policies. Generate signed URLs from an Edge Function only after verifying the user has a completed purchase for that product.
Pitfall: Relying on the Stripe Checkout success_url redirect to create purchase records
How to avoid: Create purchase records only in the checkout.session.completed webhook handler. The webhook fires server-side after Stripe confirms payment. The success page should only display purchase details fetched from Supabase — never create records there.
Pitfall: Generating the download URL directly in the frontend without an Edge Function
How to avoid: Always generate signed URLs in a Supabase Edge Function that first verifies purchase ownership using the user's JWT. The Edge Function uses the service role key securely in Deno.env and returns only the signed URL to the frontend.
Pitfall: Not handling the case where a purchase exists but payment is not completed
How to avoid: In the generate-download-url Edge Function, check purchase.status === 'completed' before generating the URL. Return a specific error for pending status: 'Payment is still processing. Please wait a moment and try again.'
Best practices
- Store all purchasable files in a private Supabase Storage bucket. Never put digital products in a public bucket.
- Generate signed URLs only in Edge Functions after verifying purchase ownership. The service role key used to create signed URLs must stay server-side.
- Create purchase records only via the checkout.session.completed webhook, not from the success_url redirect. The webhook is the only authoritative confirmation of payment.
- Set reasonable signed URL expiry times. One hour is standard for most downloads. For very large files, increase to 4-8 hours to prevent expiration during a slow download.
- Track download events in a separate table rather than incrementing a counter only. Download events give you detailed analytics: when, by whom, and from what IP each file was accessed.
- Add download limits for products where content piracy is a concern. Limit to 5-10 downloads per purchase for eBooks and courses. Make limits visible to buyers before purchase.
- Handle the case where a user deletes their account after purchasing. Add customer_email as a fallback on purchases so they can prove ownership without a user account.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a digital downloads store in a Lovable app using Supabase Storage. Explain how Supabase Storage signed URLs work: how are they generated, what determines the expiry, and what prevents someone from extending or forging a signed URL? Also explain the difference between public and private buckets and when I should use each for a digital downloads product.
Add a product upload form at /admin/products/new (admin-only route). Use react-hook-form with Zod validation for: product name (required), description (required, Textarea), price in dollars (number Input, min 0.99), file type Select (PDF, ZIP, MP3, MP4, EPUB, Other), download limit (number Input, optional), and preview image URL (optional text Input). Add a file upload zone using Supabase Storage. On drag-drop or click-to-browse, upload the file to the digital-downloads bucket at path {productId}/{filename} and save the file_path. Show upload progress using a Progress bar. On form submit, insert the product row and redirect to /store.
In Supabase, create a SQL view digital_products_with_sales that joins digital_products with a count of completed purchases and sum of amount_paid_cents from the purchases table. Return: product id, name, file_type, price_cents, is_active, total_sales_count, total_revenue_cents, last_purchased_at. Grant SELECT to service role only (for admin analytics). Also create a function get_buyer_purchases(p_email text) that returns all purchases for a given email even when no user account exists — this supports guest purchase lookup.
Frequently asked questions
Why do I need signed URLs instead of just giving buyers the file path?
Files in a private Supabase Storage bucket cannot be accessed directly by URL — they require authentication. Signed URLs contain a temporary cryptographic token that proves the holder is authorized to download the file, without exposing your service role key. The token expires after the configured time, so even if the URL is shared or leaked, it becomes useless after the expiry period.
Can I increase the signed URL expiry beyond 1 hour for large files?
Yes. The EXPIRY_SECONDS constant in the Edge Function controls the expiry. For large video files or course bundles that take time to download, set it to 14400 (4 hours) or 28800 (8 hours). For sensitive documents where you want strict access control, keep it at 3600 (1 hour) or lower. The expiry is passed to supabase.storage.from('bucket').createSignedUrl(path, expirySeconds).
How do I handle refunds for digital products?
Issue the refund via the Stripe API or Stripe Dashboard. Then update the purchase status in Supabase to refunded. In the generate-download-url Edge Function, check for refunded status and return a 403 error. The buyer can no longer download the file. Optionally, keep refunded downloads active for a grace period (e.g., 24 hours) if your terms of service allow it.
Can I sell digital products to guest users without requiring account creation?
Yes, with adjustments. During checkout, collect the buyer's email in the Stripe Checkout Session (customer_email). Store the email on the purchase row. Add a 'retrieve your download' page where users enter their purchase email to receive a magic link (Supabase Auth email OTP). After verifying their email, they can see their downloads. This requires the generate-download-url Edge Function to handle non-authenticated users via email verification.
What file types can I store in Supabase Storage?
Supabase Storage accepts any file type. There are no restrictions on PDF, ZIP, MP3, MP4, EPUB, or other formats. The maximum file size on the free tier is 50MB per file. On Pro ($25/month), the limit is 5GB per file. For very large files (course videos, large datasets), consider Cloudflare R2 or AWS S3 as an alternative storage backend linked via a custom Edge Function.
How do I track which customers downloaded which files?
The download_events table records every download with purchase_id, user_id, IP address, and timestamp. Query this table with the service role from an admin dashboard to see: total downloads per product, downloads per customer, geographical distribution (rough, from IP), and download frequency. You can also set up alerts if a single purchase generates an unusually high number of downloads in a short time.
Is there help available for building a larger digital marketplace?
RapidDev builds production digital product stores in Lovable including multi-seller marketplaces, subscription libraries, and course platforms with Supabase Storage and Stripe. Contact us if you need a more complex digital products architecture.
Can I offer free downloads alongside paid products?
Yes. Add a price_cents = 0 option and skip the Stripe Checkout step for free products. In the store, show a Download button directly instead of Buy Now. For free downloads, generate the signed URL immediately on button click without requiring a purchase record. Add a free_downloads table to track email capture for lead generation, separate from the purchases table.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation