# How to Build a Digital Downloads with Lovable

- Tool: How to Build with Lovable
- Difficulty: Advanced
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

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

## Before you start

- 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)

## Step-by-step guide

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

```
Create a digital downloads schema in Supabase:

Tables:
- 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
- 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
- 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

RLS:
- digital_products: public SELECT for is_active=true
- purchases: users can SELECT their own (by user_id OR by email match). Service role full access.
- download_events: service role only (no user reads needed for security)

Storage bucket setup:
- Create a private bucket named 'digital-downloads'
- No public policies — access only via signed URLs from Edge Functions
- 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.

```
Build a digital products catalog page at src/pages/Store.tsx.

Requirements:
- Fetch all is_active digital_products from Supabase
- Display each as a shadcn/ui Card:
  - Preview image (or a file type icon if no image)
  - Product name (font-semibold)
  - Description (text-muted-foreground, truncated at 100 chars)
  - File type Badge (PDF=blue, ZIP=yellow, MP3=green, MP4=purple, EPUB=orange)
  - File size formatted (e.g., '2.4 MB')
  - Price formatted as currency
  - 'Buy Now' Button (full width, primary variant)
- Clicking Buy Now:
  1. If user is not authenticated, prompt them to sign in with a Dialog
  2. Call the create-download-checkout Edge Function with productId
  3. Show a loading Spinner on the button: 'Preparing checkout...'
  4. Redirect to session.url on success

Also build a product detail page at src/pages/ProductDetail.tsx:
- Full description, all product details
- A 'Table of Contents' or 'What's included' section if the product has a contents JSONB column
- Sample preview section (if preview_url is set, show an embedded viewer)
- 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.

```
Create two Supabase Edge Functions:

1. supabase/functions/create-download-checkout/index.ts
- Accept: { productId: string }
- Fetch product from Supabase to get price_cents and stripe_price_id
- Create Stripe Checkout Session:
  - mode: 'payment'
  - line_items: [{ price: stripe_price_id, quantity: 1 }]
  - success_url: ${APP_URL}/downloads?session_id={CHECKOUT_SESSION_ID}
  - cancel_url: ${APP_URL}/store
  - customer_email from the authenticated user's JWT (parse from Authorization header)
  - metadata: { productId, userId }
- Insert a pending purchase row in Supabase
- Return: { url: session.url }

2. supabase/functions/download-webhook/index.ts
- Verify Stripe signature using constructEventAsync
- Handle checkout.session.completed:
  - Extract metadata.productId and metadata.userId
  - Update purchase row: status='completed', stripe_payment_intent_id
  - Return 200
- 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
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
  'Content-Type': 'application/json',
}

const EXPIRY_SECONDS = 3600 // 1 hour

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })

  const authHeader = req.headers.get('Authorization') ?? ''
  const userClient = createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_ANON_KEY') ?? '', {
    global: { headers: { Authorization: authHeader } },
  })
  const { data: { user } } = await userClient.auth.getUser()
  if (!user) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: corsHeaders })

  const { purchaseId } = await req.json()
  const supabase = createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '')

  const { data: purchase } = await supabase
    .from('purchases')
    .select('id, user_id, customer_email, status, download_count, product_id, digital_products(file_path, download_limit, name)')
    .eq('id', purchaseId)
    .single()

  if (!purchase) return new Response(JSON.stringify({ error: 'Purchase not found' }), { status: 404, headers: corsHeaders })
  if (purchase.status !== 'completed') return new Response(JSON.stringify({ error: 'Payment not completed' }), { status: 403, headers: corsHeaders })
  if (purchase.user_id && purchase.user_id !== user.id) return new Response(JSON.stringify({ error: 'Access denied' }), { status: 403, headers: corsHeaders })

  const product = purchase.digital_products as { file_path: string; download_limit: number | null; name: string }
  if (product.download_limit !== null && purchase.download_count >= product.download_limit) {
    return new Response(JSON.stringify({ error: 'Download limit reached' }), { status: 403, headers: corsHeaders })
  }

  const { data: signedData, error: signError } = await supabase.storage
    .from('digital-downloads')
    .createSignedUrl(product.file_path, EXPIRY_SECONDS)

  if (signError || !signedData?.signedUrl) {
    return new Response(JSON.stringify({ error: 'Failed to generate download link' }), { status: 500, headers: corsHeaders })
  }

  await supabase.from('purchases').update({ download_count: purchase.download_count + 1 }).eq('id', purchaseId)
  await supabase.from('download_events').insert({
    purchase_id: purchaseId,
    user_id: user.id,
    ip_address: req.headers.get('x-forwarded-for'),
    user_agent: req.headers.get('user-agent'),
    signed_url_expires_at: new Date(Date.now() + EXPIRY_SECONDS * 1000).toISOString(),
  })

  return new Response(JSON.stringify({ url: signedData.signedUrl, expiresIn: EXPIRY_SECONDS }), { headers: corsHeaders })
})
```

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

```
Build a downloads page at src/pages/MyDownloads.tsx. Require authentication.

Requirements:
- Fetch all completed purchases for the current user joined with digital_products
- Display as a grid of Cards, each showing:
  - Product name, file type Badge, file size
  - Purchase date formatted as 'Purchased on March 15, 2026'
  - Download count and limit: 'Downloaded 2 of 5 times' (or 'Downloaded 2 times' if no limit)
  - A 'Download' Button that calls the generate-download-url Edge Function
  - Loading Spinner on the button while the signed URL is being generated
  - On success: window.open(url, '_blank') to trigger the download
  - If download limit is reached: show a disabled Button with text 'Download limit reached'
- Empty state if no purchases: 'No purchases yet' with a 'Browse Store' Button
- Add a Tabs component at the top: 'All Downloads' and 'Recent (30 days)'
- 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 example

File: `supabase/functions/generate-download-url/index.ts`

```typescript
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
  'Content-Type': 'application/json',
}

const EXPIRY_SECONDS = 3600

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })

  const authHeader = req.headers.get('Authorization') ?? ''
  const userClient = createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_ANON_KEY') ?? '', {
    global: { headers: { Authorization: authHeader } },
  })
  const { data: { user } } = await userClient.auth.getUser()
  if (!user) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: corsHeaders })

  const { purchaseId } = await req.json()
  const supabase = createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '')

  const { data: purchase } = await supabase
    .from('purchases')
    .select('id, user_id, status, download_count, digital_products(file_path, download_limit)')
    .eq('id', purchaseId).single()

  if (!purchase || purchase.user_id !== user.id)
    return new Response(JSON.stringify({ error: 'Access denied' }), { status: 403, headers: corsHeaders })
  if (purchase.status !== 'completed')
    return new Response(JSON.stringify({ error: 'Payment not completed' }), { status: 403, headers: corsHeaders })

  const product = purchase.digital_products as { file_path: string; download_limit: number | null }
  if (product.download_limit !== null && purchase.download_count >= product.download_limit)
    return new Response(JSON.stringify({ error: 'Download limit reached' }), { status: 403, headers: corsHeaders })

  const { data: signedData } = await supabase.storage
    .from('digital-downloads')
    .createSignedUrl(product.file_path, EXPIRY_SECONDS)

  if (!signedData?.signedUrl)
    return new Response(JSON.stringify({ error: 'Failed to generate link' }), { status: 500, headers: corsHeaders })

  await supabase.from('purchases').update({ download_count: purchase.download_count + 1 }).eq('id', purchaseId)
  await supabase.from('download_events').insert({
    purchase_id: purchaseId,
    user_id: user.id,
    ip_address: req.headers.get('x-forwarded-for'),
    signed_url_expires_at: new Date(Date.now() + EXPIRY_SECONDS * 1000).toISOString(),
  })

  return new Response(JSON.stringify({ url: signedData.signedUrl, expiresIn: EXPIRY_SECONDS }), { headers: corsHeaders })
})
```

## Common mistakes

- **Putting downloadable files in a public Supabase Storage bucket** — Public buckets expose files to anyone who knows the URL. If your product URLs are guessable or leaked, users can download files without paying. Fix: 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.
- **Relying on the Stripe Checkout success_url redirect to create purchase records** — The success_url redirect is client-side and can be bypassed by navigating directly to the URL. A user could guess or construct a success URL with a fake session_id to access the downloads page. Fix: 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.
- **Generating the download URL directly in the frontend without an Edge Function** — Generating signed Supabase Storage URLs requires the service role key (which bypasses RLS). The service role key must never be exposed to the browser. Fix: 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.
- **Not handling the case where a purchase exists but payment is not completed** — The webhook creates the purchase as pending first, then updates it to completed. If a user navigates to the downloads page before the webhook fires, they see the product but cannot download it, with no explanation. Fix: 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.

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

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/digital-downloads
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/digital-downloads
