Skip to main content
RapidDev - Software Development Agency

How to Build a Digital Downloads with Lovable

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'll build

  • Digital products catalog with shadcn/ui Cards showing name, price, preview image, and file type Badge
  • Stripe Checkout Session for purchase with server-side price verification in an Edge Function
  • Supabase Storage private bucket holding all downloadable files
  • Webhook Edge Function triggering on checkout.session.completed to create a purchases row and generate the first download link
  • Time-limited signed URL generation via an Edge Function (1-hour expiry per download)
  • Buyer's downloads page showing all purchased products with Download button and remaining access info
  • Download event log tracking every download attempt per purchase for analytics
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Advanced15 min read3–4 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
TL;DR

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

LovableFrontend app builder
Stripe CheckoutHosted payment page
Supabase StoragePrivate file storage
SupabaseDatabase and Edge Functions
shadcn/uiUI components
React Hook Form + ZodProduct creation form

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

1

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.

prompt.txt
1Create a digital downloads schema in Supabase:
2
3Tables:
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_at
5- 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_at
6- 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_at
7
8RLS:
9- digital_products: public SELECT for is_active=true
10- 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)
12
13Storage bucket setup:
14- Create a private bucket named 'digital-downloads'
15- No public policies access only via signed URLs from Edge Functions
16- 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.

2

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.

prompt.txt
1Build a digital products catalog page at src/pages/Store.tsx.
2
3Requirements:
4- Fetch all is_active digital_products from Supabase
5- 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 currency
12 - '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 Dialog
15 2. Call the create-download-checkout Edge Function with productId
16 3. Show a loading Spinner on the button: 'Preparing checkout...'
17 4. Redirect to session.url on success
18
19Also build a product detail page at src/pages/ProductDetail.tsx:
20- Full description, all product details
21- A 'Table of Contents' or 'What's included' section if the product has a contents JSONB column
22- Sample preview section (if preview_url is set, show an embedded viewer)
23- Buy Now button with the same flow

Pro 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.

3

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.

prompt.txt
1Create two Supabase Edge Functions:
2
31. supabase/functions/create-download-checkout/index.ts
4- Accept: { productId: string }
5- Fetch product from Supabase to get price_cents and stripe_price_id
6- 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}/store
11 - customer_email from the authenticated user's JWT (parse from Authorization header)
12 - metadata: { productId, userId }
13- Insert a pending purchase row in Supabase
14- Return: { url: session.url }
15
162. supabase/functions/download-webhook/index.ts
17- Verify Stripe signature using constructEventAsync
18- Handle checkout.session.completed:
19 - Extract metadata.productId and metadata.userId
20 - Update purchase row: status='completed', stripe_payment_intent_id
21 - Return 200
22- Return 200 for all unhandled events

Pro 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.

4

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.

supabase/functions/generate-download-url/index.ts
1// supabase/functions/generate-download-url/index.ts
2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
4
5const corsHeaders = {
6 'Access-Control-Allow-Origin': '*',
7 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
8 'Content-Type': 'application/json',
9}
10
11const EXPIRY_SECONDS = 3600 // 1 hour
12
13serve(async (req: Request) => {
14 if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
15
16 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 })
22
23 const { purchaseId } = await req.json()
24 const supabase = createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '')
25
26 const { data: purchase } = await supabase
27 .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()
31
32 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 })
35
36 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 }
40
41 const { data: signedData, error: signError } = await supabase.storage
42 .from('digital-downloads')
43 .createSignedUrl(product.file_path, EXPIRY_SECONDS)
44
45 if (signError || !signedData?.signedUrl) {
46 return new Response(JSON.stringify({ error: 'Failed to generate download link' }), { status: 500, headers: corsHeaders })
47 }
48
49 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 })
57
58 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.

5

Build the buyer's downloads page

Ask Lovable to create the downloads page where buyers see all their purchased products with Download buttons.

prompt.txt
1Build a downloads page at src/pages/MyDownloads.tsx. Require authentication.
2
3Requirements:
4- Fetch all completed purchases for the current user joined with digital_products
5- Display as a grid of Cards, each showing:
6 - Product name, file type Badge, file size
7 - 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 Function
10 - Loading Spinner on the button while the signed URL is being generated
11 - On success: window.open(url, '_blank') to trigger the download
12 - 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' Button
14- 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

supabase/functions/generate-download-url/index.ts
1import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
2import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
3
4const corsHeaders = {
5 'Access-Control-Allow-Origin': '*',
6 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
7 'Content-Type': 'application/json',
8}
9
10const EXPIRY_SECONDS = 3600
11
12serve(async (req: Request) => {
13 if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
14
15 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 })
21
22 const { purchaseId } = await req.json()
23 const supabase = createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '')
24
25 const { data: purchase } = await supabase
26 .from('purchases')
27 .select('id, user_id, status, download_count, digital_products(file_path, download_limit)')
28 .eq('id', purchaseId).single()
29
30 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 })
34
35 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 })
38
39 const { data: signedData } = await supabase.storage
40 .from('digital-downloads')
41 .createSignedUrl(product.file_path, EXPIRY_SECONDS)
42
43 if (!signedData?.signedUrl)
44 return new Response(JSON.stringify({ error: 'Failed to generate link' }), { status: 500, headers: corsHeaders })
45
46 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 })
53
54 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.

ChatGPT Prompt

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.

Lovable Prompt

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.

Build Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.