Feature spec
AdvancedCategory
payments-commerce
Build with AI
1–2 days with Lovable or V0
Custom build
3–6 weeks custom dev
Running cost
$0–$25/mo up to 100 users · $100–$400/mo at 10K users
Works on
Everything it takes to ship a Digital Asset Marketplace — parts, prompts, and real costs.
A digital asset marketplace needs Supabase Storage (private bucket, signed download URLs), Stripe Connect Express for seller payouts and platform commission splits, and an assets table with a purchases table that gates download access. With Lovable or V0 you can reach a working marketplace in 1–2 days. Expect $0–$80/month running cost up to 1,000 users, scaling to $100–$400/month at 10,000 with heavy file egress.
What a Digital Asset Marketplace Actually Is
A digital asset marketplace lets creators upload files — templates, fonts, music loops, Figma assets, code snippets — set a price, and receive payouts when buyers purchase. The platform earns a commission on each transaction. Three technical problems make this feature Advanced difficulty relative to a standard storefront: First, download links must be cryptographically gated (signed URLs that expire) so buyers can't share or re-use links. Second, Stripe Connect Express must be onboarded per seller before any payout can be issued — it's a multi-step external flow, not a simple API call. Third, RLS policies must correctly separate what sellers see (their own assets and earnings) from what buyers see (only their purchased downloads). The 70% problem is real here — AI tools get the storefront and upload form right, but seller payouts and signed-URL enforcement need explicit prompting.
What users consider table stakes in 2026
- Seller upload flow with file preview, metadata form (title, description, tags, price, license type), and progress indicator for large files
- Buyer checkout with immediate secure download link post-payment — no redirect to email, no waiting
- License terms (Personal, Commercial, Extended) clearly displayed on product page before purchase and in download confirmation
- Seller dashboard showing total earnings, pending payout balance, sales count per asset, and a bar chart of sales over time
- Search and filter by category, price range, file format, and rating — with full-text search on title and tags
- Download count and star rating displayed on each asset listing so buyers can judge quality before purchasing
Anatomy of the Feature
Six components span the buyer journey and the seller backend. AI tools handle the UI well; the signed URL layer and Stripe Connect payout chain are where explicit prompting is essential.
Asset upload form
UIA multi-step wizard: step 1 is file upload (drag-and-drop with progress bar) to a private Supabase Storage bucket; step 2 is metadata (title, description, tags, price in cents, license type dropdown); step 3 is a preview confirmation. Built with shadcn/ui Stepper or a custom multi-step form with react-hook-form and zod validation.
Note: Upload the actual asset file to a private bucket and a watermarked or low-resolution preview to a separate public bucket. Never store both in the same bucket — the private bucket is the revenue-protecting layer.
Supabase Storage + signed URL gate
BackendThe paid asset file lives in a private Supabase Storage bucket. After payment verification, a server-side function calls supabase.storage.from('assets').createSignedUrl(file_path, 3600) to generate a 60-minute expiring URL. This URL is returned to the buyer and recorded in purchases.downloaded_at. The URL cannot be used after expiry and carries no persistent public path.
Note: Never expose the raw file_path to the client. The signed URL generation must happen in a Server Action or Edge Function — never on the client where file_path is visible.
Stripe Connect payment flow
ServiceStripe Connect Express handles seller onboarding (external Stripe-hosted flow) and automatic payouts. Each payment intent is created with application_fee_amount set to the platform commission (e.g., 20% of price_cents). Stripe automatically transfers the seller's portion to their Connect account on each charge.
Note: Stripe Connect onboarding generates a one-time account link URL that must open in a full browser window (window.open or full page redirect), not inside an iframe or preview environment.
Assets and purchases tables
DataThe assets table stores file_path (private path in Supabase Storage), preview_url (public watermarked path), price_cents, license_type, tags (text[]), and download_count. The purchases table records buyer_id, asset_id, stripe_payment_intent_id, and downloaded_at. RLS on purchases restricts rows to the buyer. RLS on assets allows public SELECT for browse but restricts UPDATE/DELETE to the seller.
Note: download_count increments must use an atomic SQL UPDATE (SET download_count = download_count + 1) to avoid race conditions under concurrent purchases.
Payment webhook handler
BackendA Supabase Edge Function or Next.js API route receives Stripe's payment_intent.succeeded webhook, verifies the signature using the raw request body and constructEventAsync(), inserts the purchase record, increments download_count atomically, and updates the seller's pending earnings in the sellers table.
Note: Raw body preservation is critical: never JSON.parse the body before passing it to constructEventAsync(). Idempotency check: query purchases for existing stripe_payment_intent_id before inserting.
Seller dashboard
UIA protected /seller page showing total earnings, pending payout balance fetched from the Stripe Connect API (stripe.accounts.retrieve(seller.stripe_account_id)), sales count, and a Recharts bar chart of sales over the last 30 days grouped by week. Asset management table with edit title/price and unpublish toggle.
Note: Pending vs. paid payout distinction requires fetching the Stripe Connect balance endpoint — Supabase alone cannot know what Stripe has transferred.
The data model
Four tables power the marketplace: sellers (Stripe Connect linkage), assets, purchases, and reviews. Paste this schema into the Supabase SQL editor before prompting your AI tool.
1-- Seller Stripe Connect accounts2create table public.sellers (3 id uuid primary key default gen_random_uuid(),4 user_id uuid references auth.users(id) on delete cascade not null unique,5 stripe_account_id text,6 payout_enabled boolean not null default false,7 created_at timestamptz not null default now()8);910alter table public.sellers enable row level security;1112create policy "Sellers manage own record"13 on public.sellers for all14 using (auth.uid() = user_id)15 with check (auth.uid() = user_id);1617-- Digital assets18create table public.assets (19 id uuid primary key default gen_random_uuid(),20 seller_id uuid references public.sellers(id) on delete cascade not null,21 title text not null,22 description text not null,23 price_cents int not null check (price_cents >= 50),24 currency text not null default 'usd',25 file_path text not null, -- private Supabase Storage path26 preview_url text not null, -- public watermarked preview27 license_type text not null check (license_type in ('personal','commercial','extended')),28 tags text[] not null default '{}',29 download_count int not null default 0,30 rating_avg numeric(3,2),31 is_published boolean not null default true,32 created_at timestamptz not null default now()33);3435alter table public.assets enable row level security;3637create policy "Anyone can browse published assets"38 on public.assets for select39 using (is_published = true);4041create policy "Sellers manage own assets"42 on public.assets for all43 using (44 seller_id in (45 select id from public.sellers where user_id = auth.uid()46 )47 );4849create index assets_search_idx on public.assets using gin(to_tsvector('english', title || ' ' || description));50create index assets_tags_idx on public.assets using gin(tags);5152-- Purchases (grant download access)53create table public.purchases (54 id uuid primary key default gen_random_uuid(),55 buyer_id uuid references auth.users(id) on delete set null,56 asset_id uuid references public.assets(id) on delete restrict not null,57 stripe_payment_intent_id text not null unique,58 amount_paid int not null,59 downloaded_at timestamptz,60 download_count int not null default 0,61 created_at timestamptz not null default now()62);6364alter table public.purchases enable row level security;6566create policy "Buyers see own purchases"67 on public.purchases for select68 using (auth.uid() = buyer_id);6970create policy "Sellers see purchases of their assets"71 on public.purchases for select72 using (73 asset_id in (74 select a.id from public.assets a75 join public.sellers s on s.id = a.seller_id76 where s.user_id = auth.uid()77 )78 );7980-- Reviews (buyer-written, post-purchase)81create table public.reviews (82 id uuid primary key default gen_random_uuid(),83 buyer_id uuid references auth.users(id) on delete set null not null,84 asset_id uuid references public.assets(id) on delete cascade not null,85 rating int not null check (rating between 1 and 5),86 body text,87 created_at timestamptz not null default now(),88 constraint reviews_unique_buyer_asset unique (buyer_id, asset_id)89);9091alter table public.reviews enable row level security;9293create policy "Buyers write own reviews"94 on public.reviews for insert95 with check (auth.uid() = buyer_id);9697create policy "Anyone reads reviews"98 on public.reviews for select using (true);99100-- Atomic download count increment (call via supabase.rpc)101create or replace function increment_download_count(asset_id uuid)102returns void language sql security definer as $$103 update public.assets set download_count = download_count + 1 where id = asset_id;104$$;Heads up: The increment_download_count RPC function ensures atomic increments that survive concurrent purchases. Call it via supabase.rpc('increment_download_count', { asset_id: '...' }) from your webhook handler instead of a read-then-write pattern.
Build it — pick your path
Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.
Required when you need escrow with configurable holding periods, DMCA takedown workflows, multi-file bundles, affiliate commission tracking, or subscription-based library access alongside per-asset purchase.
Step by step
- 1Design a multi-vendor escrow layer: hold seller funds in Stripe for a configurable period (e.g., 14 days) before auto-transfer using Stripe Connect delayed transfers
- 2Build a DMCA workflow: reports table, seller notification email with 72-hour response window, auto-unpublish on expiry of window, admin review queue
- 3Add bundle support: bundles table (id, seller_id, name, price_cents), bundle_assets join table; signed URL generation loops over all asset file_paths in the bundle
- 4Wire affiliate tracking: referral_codes table, utm_source tracking on checkout, affiliate commission Edge Function triggered by payment_intent.succeeded
Where this path bites
- Stripe Connect compliance review can add 2–5 business days before the platform can process live payments
- Complex RLS policies for bundles, affiliate tables, and DMCA state require careful testing — a missing policy is a security hole, not a build error
Third-party services you'll need
Three services power the marketplace. Stripe Connect is non-negotiable for seller payouts; Supabase handles everything else:
| Service | What it does | Free tier | Paid from |
|---|---|---|---|
| Stripe Connect | Seller onboarding, automatic payout splits, platform commission (application_fee_amount), and payout management | Free to set up; test mode is free | 2.9% + $0.30 per charge; Connect fee included in standard Stripe rate; platform keeps application_fee_amount (approx) |
| Supabase Storage | Private bucket for paid asset files (signed URL gated), public bucket for watermarked previews | 1GB storage free | $0.021/GB stored, $0.09/GB egress (approx) |
| Supabase | PostgreSQL DB (assets, purchases, sellers, reviews), Auth, RLS, Edge Functions for webhook handler and download gate | Free (2 projects, 500MB DB) | Pro $25/mo (approx) |
Swipe the table sideways to see pricing.
What it costs to run
Drag through the tiers to see how your monthly bill scales with users — no surprises later.
Estimated monthly running cost
Supabase free tier for DB and auth. Storage cost negligible at low volume. Stripe fees apply only on actual purchases. Supabase Pro not required at this scale unless upload volume is large.
Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.
What breaks when AI tools build this
The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.
Permanent public download URL instead of signed URL
Symptom: AI tools often generate download links using the public Supabase Storage URL pattern (e.g., https://[project].supabase.co/storage/v1/object/public/assets/[path]). Anyone who obtains this URL — from browser DevTools, a shared screenshot, or a buyer sharing the link — can download the file indefinitely without paying.
Fix: Store asset files in a PRIVATE Supabase Storage bucket. Generate download links server-side only via supabase.storage.from('assets').createSignedUrl(file_path, 3600) after verifying a valid purchase record. Never expose file_path to the client in any API response. Explicitly state 'signed URL expiring in 60 minutes, not a public URL' in your prompt.
Stripe Connect onboarding blocked inside iframe
Symptom: Stripe Express onboarding generates an external Stripe-hosted URL. When opened inside the Lovable preview iframe, the Stripe page detects the iframe context and either blocks the flow entirely or breaks the OAuth handshake, leaving sellers stuck at a blank screen.
Fix: Stripe Connect onboarding must open as a full browser navigation — either window.location.href = accountLinkUrl or window.open(accountLinkUrl, '_self'). Test exclusively on the published Lovable URL or deployed Vercel URL, never in the preview.
Seller not linked to Stripe account when first asset goes on sale
Symptom: Lovable or V0 may generate the asset upload flow independently of the seller onboarding flow. A seller can publish assets before completing Stripe Connect onboarding. When a buyer purchases, Stripe has no destination for the platform's application_fee transfer and the payment fails or the payout is lost.
Fix: Before allowing asset publishing (is_published = true), check sellers.payout_enabled = true. Show a 'Complete your payout setup' banner that links to the Stripe Connect onboarding flow if payout_enabled is false. Block the 'Publish asset' button at the UI level and enforce it in the Server Action.
Overly permissive RLS on purchases table exposes buyer history
Symptom: AI-generated RLS on the purchases table often uses a single SELECT policy that allows any authenticated user to see all rows. This lets any buyer query purchases for any asset and see other buyers' purchase records, payment intent IDs, and download counts.
Fix: Apply two separate RLS SELECT policies: one for buyers (WHERE buyer_id = auth.uid()) and one for sellers (WHERE asset_id in (SELECT id FROM assets WHERE seller_id IN (SELECT id FROM sellers WHERE user_id = auth.uid()))). These must be separate policies — a single permissive policy covers both, which is the dangerous default.
Non-atomic download_count increment creates race conditions
Symptom: AI tools generate download count increments as read-then-write: SELECT download_count, then UPDATE SET download_count = [count + 1]. Under concurrent purchases — common on popular assets — two webhook handler invocations read the same count and both write the same incremented value, causing one sale to go uncounted.
Fix: Use the supabase.rpc('increment_download_count', { asset_id }) function from the data model above, which executes a single atomic UPDATE SET download_count = download_count + 1 in SQL. Never read the count in application code before incrementing.
Best practices
Always store paid assets in a PRIVATE Supabase Storage bucket — public buckets turn every download link into a permanent free access URL
Check sellers.payout_enabled before allowing asset publishing — a payment that cannot payout is worse than no payment at all
Rate-limit the download endpoint per purchase (maximum 5 downloads per purchase record) to prevent one purchase from serving unlimited distribution
Use Stripe webhook idempotency keys and check for existing stripe_payment_intent_id before inserting a purchase — Stripe retries webhooks on 5xx and creates duplicate purchase records without this check
Add full-text search from day one using Supabase's built-in pg_search or GIN indexes on title and tags — text search added later requires a schema migration on a table that may already have thousands of assets
Display pending vs. paid payout separately in the seller dashboard — sellers need to know what is in transit to Stripe and what has cleared, not just a total earnings number
Serve preview images from a public bucket with watermarking rather than downscaling the original — watermarks are easier to enforce and visually signal purchase is needed
When You Need Custom Development
A single-category marketplace selling simple digital files (templates, icons, audio) is achievable with Lovable or V0 in 1–2 days. Move to custom development when the business model adds complexity the AI layer cannot reliably handle:
- Escrow with a configurable holding period (e.g., 14-day buyer protection) before seller payouts release — requires Stripe Connect delayed transfers and a release scheduler
- DMCA takedown workflow with seller notice period, auto-unpublish on expiry, and admin review queue — not a UI feature but a legal operations system
- Subscription access to the full library (Envato Elements model) running alongside per-asset purchases — requires two entitlement systems and Stripe Billing in addition to Connect
- Multi-file bundles with per-bundle purchase gating — signed URLs must loop over all bundle asset file_paths server-side and return a time-limited ZIP or multiple links
RapidDev builds these features for production
Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.
Frequently asked questions
Do I need Stripe Connect or can I use regular Stripe?
You need Stripe Connect if sellers are different people or businesses who receive payouts from your platform. Regular Stripe (without Connect) deposits all funds into one account — yours. If you are the only seller, regular Stripe is fine. For a true marketplace where third-party creators upload and sell their own work, Stripe Connect Express is the right product — it handles seller KYC, tax forms, and automatic payout splits via application_fee_amount.
How do I prevent buyers from sharing download links?
Generate signed Supabase Storage URLs server-side that expire after 60 minutes, and limit download count to a maximum of 5 per purchase record. The expiring URL means a shared link becomes worthless after an hour. The download count limit catches automated scrapers that download once and redistribute. Store the signed URL request timestamp in purchases.downloaded_at so you can audit unusual download patterns.
What is the platform fee and how does it work?
When you create a Stripe Payment Intent for a marketplace purchase, you set application_fee_amount equal to your desired platform cut (e.g., 20% of price_cents). Stripe collects the full payment from the buyer, retains your fee, and transfers the remainder to the seller's Stripe Connect account automatically. You control the percentage — common marketplace rates are 10–30% depending on the asset category.
How do sellers get paid — instantly or after a delay?
By default, Stripe Connect uses a 2-business-day rolling payout schedule to the seller's bank account after each charge settles. You can configure this to instant payouts (Stripe Instant Payouts, available in select countries for a fee) or delayed payouts (escrow model, where you hold funds for a set period before releasing). The holding period is configured on your Stripe Connect account settings.
How do I handle refunds when the asset is already downloaded?
Digital asset refunds are a policy decision, not a technical one. Technically, you call stripe.refunds.create({ payment_intent: id }) and set the purchases row status to 'refunded', which your download gate checks before generating a signed URL. Most digital marketplaces have a strict no-refund policy for downloaded items, with exceptions for assets that fail to open or differ materially from the preview. Define this policy in your terms before launch.
Can I sell bundles of multiple files?
Not with the basic schema above. Bundles require a bundles table (id, seller_id, name, price_cents) and a bundle_assets join table linking bundle_id to asset_id. The download endpoint then generates signed URLs for every asset in the bundle. This is an extension of the base schema — add it explicitly to your prompt if bundles are a day-one requirement, otherwise add it as a follow-up feature after the single-asset flow is validated.
How do I protect assets from piracy?
Signed expiring URLs stop casual link sharing. For higher-value assets (premium fonts, music licenses, commercial templates), embed buyer metadata in the downloaded file using a watermark or invisible steganography — this traces leaked copies back to the specific buyer. The most practical approach for most marketplaces is: expiring signed URLs, download count limits, clear license terms with legal recourse, and DMCA takedown compliance.
Do I need to handle sales tax on digital goods?
Yes, in many jurisdictions. Digital goods are taxable in the EU (VAT MOSS), UK (VAT), Australia (GST), and an increasing number of US states. Stripe Tax (an add-on, approximately $0.50 per transaction) automates tax calculation and collection at checkout based on buyer location. For a marketplace, the tax obligation typically falls on the platform, not the seller. Add Stripe Tax to your prompt if you expect international buyers.
Need this feature production-ready?
RapidDev builds a digital asset marketplace into real apps — auth, database, payments — at $13K–$25K.
Book a free consultation30-min call. No commitment.
