Build a Stripe-powered escrow service in Lovable where funds are held using manual capture PaymentIntents and released or refunded based on transaction state. A state machine with audit trail, Edge Function status transitions, and a dispute resolution flow protect both buyers and sellers.
What you're building
Stripe's manual capture mode creates a PaymentIntent that authorizes the card and holds the funds without immediately charging them. The authorization is held for up to 7 days (for most cards). During this window, you can either capture the funds (releasing them to your account) or cancel the PaymentIntent (returning the hold to the buyer's card). This is the technical foundation of escrow.
The state machine enforces which transitions are allowed. A transaction in pending state can only move to funded (buyer adds payment method) or expired (hold period elapsed). A funded transaction can move to released (seller delivers, buyer approves) or disputed. A disputed transaction can move to resolved_release or resolved_refund based on admin mediation. Attempting an invalid transition (e.g., releasing a disputed transaction directly) is blocked by the Edge Function.
Every state transition is recorded in the escrow_audit table with the actor's user ID, the old state, the new state, a human-readable reason, and a timestamp. This creates a complete legal-grade audit trail that protects the platform in case of disputes and chargebacks.
Final result
A complete escrow service with Stripe manual capture, enforced state machine, full audit trail, and dispute resolution workflow.
Tech stack
Prerequisites
- Lovable Pro account for multi-function Edge Function generation
- Stripe account with manual capture enabled (available on all Stripe accounts)
- STRIPE_SECRET_KEY, VITE_STRIPE_PUBLISHABLE_KEY, and STRIPE_WEBHOOK_SECRET in Cloud tab → Secrets
- Supabase project with service role key in Secrets
- Understanding of Stripe authorization holds and the 7-day capture window
- Deployed Lovable app URL (Stripe payment confirmation requires a real domain)
Build steps
Set up the escrow schema and state machine
Create the Supabase tables for transactions, audit trail, and disputes. Define the state machine as a Postgres constraint function that validates transitions.
1Create an escrow service schema in Supabase:23Tables:4- escrow_transactions: id (uuid pk), buyer_id (uuid references auth.users), seller_id (uuid references auth.users), title (text), description (text), amount_cents (int), currency (text default 'usd'), state (text default 'pending'), stripe_payment_intent_id (text), stripe_charge_id (text), hold_expires_at (timestamptz), created_at, updated_at56- escrow_audit: id (uuid pk), transaction_id (uuid references escrow_transactions), actor_id (uuid references auth.users), from_state (text), to_state (text), reason (text), stripe_event_id (text nullable), metadata (jsonb default '{}'), created_at78- escrow_disputes: id (uuid pk), transaction_id (uuid references escrow_transactions unique), opened_by (uuid references auth.users), reason (text), buyer_evidence (text nullable), seller_evidence (text nullable), admin_decision (text nullable: release|refund), resolved_at (timestamptz nullable), created_at910RLS:11- escrow_transactions: buyer and seller can SELECT their own transactions. Service role full access.12- escrow_audit: buyer and seller can SELECT audit entries for their transactions. No direct INSERT/UPDATE for users.13- escrow_disputes: buyer and seller can SELECT/UPDATE their evidence fields. Service role full access.1415Create a check constraint on escrow_transactions: state must be one of ('pending','funded','released','disputed','refunded','expired')1617Create a SQL function validate_state_transition(from_state text, to_state text, actor_role text) returns bool that encodes the allowed transitions.Pro tip: Create a SQL trigger on escrow_transactions that automatically inserts into escrow_audit whenever the state column changes. This ensures no state change can happen without an audit record, even from direct database updates.
Expected result: All three tables are created. The state check constraint prevents invalid state values. The audit trigger fires on every state change. TypeScript types are generated.
Build the transaction creation and funding flow
Create the Edge Functions to create escrow transactions and collect the buyer's payment using Stripe manual capture.
1Create two Supabase Edge Functions:231. supabase/functions/create-escrow/index.ts4- Accept: { sellerId, title, description, amountCents }5- Verify the caller is authenticated (buyer)6- Create a Stripe PaymentIntent with: amount=amountCents, currency='usd', capture_method='manual', metadata={transactionId: will update}7- Insert into escrow_transactions: buyer_id=auth user, seller_id, title, description, amount_cents, state='pending', stripe_payment_intent_id=pi.id, hold_expires_at=now+6days8- Update the PaymentIntent metadata with the transaction ID: PATCH /v1/payment_intents/{id} body: metadata[transactionId]=transaction.id9- Return: { transactionId, clientSecret: pi.client_secret }10112. supabase/functions/fund-escrow/index.ts 12- Called after the frontend confirms the PaymentIntent (buyer entered card details)13- Accept: { transactionId }14- Verify the caller is the buyer for this transaction15- Fetch the transaction and verify state='pending'16- The PaymentIntent should be in 'requires_capture' state after frontend confirmation17- Fetch the PaymentIntent from Stripe to verify status='requires_capture'18- Update state to 'funded' in Supabase19- Insert audit record: from_state='pending', to_state='funded'20- Return: { success: true }Pro tip: Set hold_expires_at to 6 days from creation, not 7. Stripe's authorization hold lasts 7 days, but you need a buffer day for the expiration cron job to run and cancel the hold before Stripe auto-releases it.
Expected result: Creating an escrow transaction returns a clientSecret. After the buyer confirms payment with test card 4242 4242 4242 4242, the PaymentIntent status is requires_capture. Calling fund-escrow moves the transaction to funded state.
Build the release, refund, and dispute Edge Functions
Create the Edge Functions that handle state transitions: releasing funds to the seller, initiating a dispute, and resolving disputes with either a release or refund.
1Create four more Supabase Edge Functions:231. supabase/functions/release-escrow/index.ts4- Called by the buyer to approve delivery and release funds5- Verify caller is buyer, transaction state='funded'6- Call Stripe POST /v1/payment_intents/{id}/capture7- Update state='released', stripe_charge_id from capture response8- Insert audit: from_state='funded', to_state='released'9102. supabase/functions/dispute-escrow/index.ts11- Called by buyer or seller to open a dispute12- Verify transaction state='funded'13- Update state='disputed'14- Insert into escrow_disputes: opened_by, reason15- Insert audit: from_state='funded', to_state='disputed'16173. supabase/functions/resolve-dispute/index.ts (admin only)18- Accept: { transactionId, decision: 'release'|'refund', adminReason }19- Verify caller has admin role20- If decision='release': call /v1/payment_intents/{id}/capture21- If decision='refund': call /v1/payment_intents/{id}/cancel22- Update state='released' or 'refunded'23- Update escrow_disputes: admin_decision, resolved_at24- Insert audit25264. supabase/functions/expire-escrow/index.ts (cron job)27- Query all transactions where state='funded' AND hold_expires_at < now()28- For each: call Stripe /v1/payment_intents/{id}/cancel29- Update state='expired'30- Insert audit: reason='Hold period expired'Pro tip: For the release-escrow function, use Stripe's capture with amount_to_capture parameter if you want to allow partial captures (e.g., if the delivered item was incomplete). Capture a smaller amount and return the rest to the buyer's hold.
Expected result: The release function captures the payment and moves state to released. The dispute function opens a dispute. The resolve function handles admin decisions. The expire cron cancels held payments past their deadline.
Build the transaction dashboard
Ask Lovable to create the main escrow dashboard showing all transactions the user is involved in, with status Badges and action buttons contextual to the current state.
1Build an escrow dashboard at src/pages/EscrowDashboard.tsx.23Requirements:4- Fetch all transactions where buyer_id OR seller_id = current user5- Show two Tabs: 'As Buyer' and 'As Seller'6- Each transaction as a Card with:7 - Title and description8 - Amount formatted as currency9 - State Badge: pending=gray, funded=blue, released=green, disputed=red, refunded=orange, expired=gray10 - Hold expires countdown if state='funded': 'Expires in X days' with a yellow badge if < 48 hours11 - Contextual action Buttons:12 - Buyer + funded: 'Release Funds' Button (opens Confirmation Dialog) + 'Open Dispute' Button13 - Buyer + pending: 'Fund Escrow' Button (shows Stripe Elements form)14 - Seller + funded: 'View Transaction' (read-only)15 - Disputed: 'Submit Evidence' Button for the non-opener party16- Clicking 'Submit Evidence' opens a Dialog with a Textarea for the evidence text and a Submit button17- Empty state if no transactions: 'No escrow transactions yet. Start one to protect your next deal.'18- At the top, summary metrics: Active Held Amount, Released (30 days), Disputed CountExpected result: The dashboard shows transactions separated into buyer and seller tabs. Action buttons change based on the current state. The dispute evidence dialog is accessible for disputed transactions.
Build the audit trail and transaction detail page
Ask Lovable to create the transaction detail page showing the complete audit trail as a timeline.
1Build a transaction detail page at src/pages/EscrowDetail.tsx (route: /escrow/:id).23Requirements:4- Fetch the transaction with its audit trail and dispute (if exists) from Supabase5- Transaction summary Card at the top: title, description, amount, current state Badge, parties (buyer name/email, seller name/email)6- Below the summary: Timeline component showing the audit trail7 - Each escrow_audit row as a timeline item with: actor name, from_state → to_state (with Badges), reason, formatted timestamp8 - Color-code timeline dots by transition type: green for release, red for dispute/refund, blue for fund, gray for expire9- If state='disputed': show the Dispute section10 - Dispute reason, opened by, creation date11 - Two evidence panels side by side: 'Buyer Evidence' and 'Seller Evidence'12 - If evidence is empty for the current user's side and dispute is unresolved, show the Submit Evidence form inline13 - Admin decision section (visible to admin role only)14- If state='funded': show the Stripe hold information Card with hold_expires_at and a countdown15- Action buttons matching the dashboard contextual buttonsExpected result: The detail page shows the full transaction history as a visual timeline. Disputed transactions show both parties' evidence panels. The audit trail records every state change with actor and reason.
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}910serve(async (req: Request) => {11 if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })1213 try {14 const authHeader = req.headers.get('Authorization') ?? ''15 const userClient = createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_ANON_KEY') ?? '', {16 global: { headers: { Authorization: authHeader } },17 })18 const { data: { user } } = await userClient.auth.getUser()19 if (!user) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: corsHeaders })2021 const { transactionId } = await req.json()22 const supabase = createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '')2324 const { data: tx } = await supabase25 .from('escrow_transactions')26 .select('id, buyer_id, state, stripe_payment_intent_id, amount_cents')27 .eq('id', transactionId)28 .single()2930 if (!tx) return new Response(JSON.stringify({ error: 'Transaction not found' }), { status: 404, headers: corsHeaders })31 if (tx.buyer_id !== user.id) return new Response(JSON.stringify({ error: 'Only the buyer can release funds' }), { status: 403, headers: corsHeaders })32 if (tx.state !== 'funded') return new Response(JSON.stringify({ error: `Cannot release from state: ${tx.state}` }), { status: 400, headers: corsHeaders })3334 const stripeKey = Deno.env.get('STRIPE_SECRET_KEY') ?? ''35 const captureRes = await fetch(`https://api.stripe.com/v1/payment_intents/${tx.stripe_payment_intent_id}/capture`, {36 method: 'POST',37 headers: { Authorization: `Basic ${btoa(stripeKey + ':')}` },38 })39 const captured = await captureRes.json()40 if (captured.error) throw new Error(captured.error.message)4142 await supabase.from('escrow_transactions').update({43 state: 'released',44 stripe_charge_id: captured.latest_charge,45 updated_at: new Date().toISOString(),46 }).eq('id', transactionId)4748 await supabase.from('escrow_audit').insert({49 transaction_id: transactionId,50 actor_id: user.id,51 from_state: 'funded',52 to_state: 'released',53 reason: 'Buyer approved delivery and released funds',54 metadata: { stripe_charge_id: captured.latest_charge },55 })5657 return new Response(JSON.stringify({ success: true }), { headers: corsHeaders })58 } catch (err) {59 const message = err instanceof Error ? err.message : 'Internal error'60 return new Response(JSON.stringify({ error: message }), { status: 500, headers: corsHeaders })61 }62})Customization ideas
Milestone-based escrow
Instead of one lump sum, split the escrow into multiple milestones. Add a milestones table with title, amount, and status per transaction. Each milestone is a separate Stripe PaymentIntent. Buyers fund milestones individually and release payment upon completing each deliverable. The transaction is complete when all milestones are released.
Platform fee with Stripe Connect
Add a platform fee by using Stripe Connect transfers. When releasing funds, call the Stripe Transfer API to send the payment to the seller's connected Stripe account minus the platform fee. This enables a marketplace model where you take a percentage on every completed transaction.
Escrow for physical goods with delivery confirmation
Add a shipment_tracking_url field to transactions. The seller provides a tracking number when the item ships. Integrate a shipping status polling Edge Function that checks carrier APIs. When the package shows as delivered, automatically trigger a 72-hour countdown for the buyer to dispute or the funds auto-release.
Automated escrow agreement document
Generate a PDF escrow agreement when a transaction is created. Include the transaction terms, parties' identities (masked), amount, and platform dispute resolution policy. Store the PDF in Supabase Storage and attach the URL to the transaction. Both parties receive the agreement by email and must acknowledge it before funding.
Transaction messaging system
Add a messages table linked to escrow_transactions for buyer-seller communication. Messages are timestamped and visible to both parties. Include a message thread on the transaction detail page using a shadcn/ui ScrollArea. All messages become part of the evidence package in case of a dispute.
Common pitfalls
Pitfall: Using automatic capture instead of manual capture for the PaymentIntent
How to avoid: Always set capture_method: 'manual' when creating escrow PaymentIntents. After confirmation, the status becomes requires_capture, and you have up to 7 days to either capture (release) or cancel (refund the hold).
Pitfall: Not enforcing state machine transitions in the Edge Function
How to avoid: At the start of every state transition Edge Function, check that the current state matches the expected from-state. Return a 400 error with the message 'Cannot [action] from state: [current_state]' if the transition is invalid.
Pitfall: Forgetting that Stripe authorization holds expire after 7 days
How to avoid: Run the expire-escrow cron Edge Function daily. It finds all funded transactions where hold_expires_at < now() and calls the Stripe PaymentIntent cancel API before Stripe auto-releases. Move these to expired state and notify both parties.
Pitfall: Allowing both parties to initiate release independently
How to avoid: The release function should only accept calls from the buyer (the party who put funds in). The seller should only be able to mark delivery as complete, which notifies the buyer to approve and release.
Best practices
- Always use capture_method: manual on PaymentIntents for escrow. Never use automatic capture — it charges the card immediately with no hold-and-release mechanism.
- Implement the state machine in both the Edge Function (check before executing) and as a Postgres check constraint. Defense in depth ensures no invalid state can be stored even if the Edge Function has a bug.
- Record every state transition in the audit table with actor, reason, and timestamp. This is non-negotiable for a financial system — every change must be traceable.
- Set hold_expires_at to 6 days (not 7) to give your expiration cron job a buffer before Stripe's 7-day auto-release kicks in.
- Never let buyers call the release function on a transaction in disputed state. Enforce this in the Edge Function: if state is disputed, return an error directing them to the dispute resolution process.
- Add Stripe idempotency keys to capture and cancel API calls. If the Edge Function is retried after a timeout, the idempotent call to Stripe returns the same result without double-capturing.
- Notify both parties via email at every state transition. Sellers need to know when a transaction is funded (so they can deliver) and buyers need to know when a dispute decision is made.
AI prompts to try
Copy these prompts to build this project faster.
I'm building an escrow service with Stripe manual capture in a Lovable app. Explain how Stripe's manual capture flow works: when does the card get authorized vs charged? What is the difference between capture, cancel, and refund for a PaymentIntent in requires_capture state? How long does the authorization hold last and what happens if I don't capture or cancel within that window?
Add a dispute evidence submission form to the escrow detail page. For transactions in disputed state, show a Card for each party (buyer and seller). If the current user's evidence field is empty and the dispute is unresolved, show a Textarea labeled 'Your Evidence' with placeholder 'Describe what happened and why you should win this dispute. Include any relevant details about the transaction, communication, or delivery.' Below the Textarea, show file upload guidance: 'You can describe screenshots or messages here — attach files by describing them clearly.' Add a Submit Evidence Button that calls a Supabase update on the escrow_disputes table (setting buyer_evidence or seller_evidence depending on role) with react-hook-form and Zod validation (minimum 50 characters).
In Supabase, create a SQL trigger function that fires AFTER UPDATE on escrow_transactions when the state column changes. The trigger should insert a row into escrow_audit with transaction_id=NEW.id, actor_id=auth.uid() (fall back to a system UUID if auth is not available), from_state=OLD.state, to_state=NEW.state, reason='Automatic state sync', and created_at=now(). This provides a safety net audit record even when state changes happen through direct database updates rather than Edge Functions.
Frequently asked questions
What is manual capture and how is it different from a normal Stripe charge?
A normal Stripe PaymentIntent charges the card immediately when confirmed. Manual capture (capture_method: 'manual') splits the process: first it authorizes the card (reserves the funds on the customer's bank), but does not charge until you explicitly call the capture endpoint. The authorization hold lasts up to 7 days. This is the technical mechanism that enables escrow — funds are committed by the buyer but not transferred to you until you decide to capture.
What happens to the funds during the hold period?
The funds stay on the buyer's card as a reserved amount. The buyer sees the hold on their bank statement but the money has not been transferred anywhere. Your Stripe balance shows nothing yet. When you capture, the money moves to your Stripe balance. When you cancel, the hold is released and the reservation disappears from the buyer's statement within a few business days.
Can I hold funds for longer than 7 days?
Not with standard Stripe PaymentIntents. The maximum authorization period is 7 days for most card types (some commercial cards allow longer). For escrow arrangements that need longer hold periods, consider collecting the payment fully (capture immediately) and issuing a refund if the transaction falls through. This is less ideal but removes the 7-day limitation.
How do I handle a dispute where both parties are at fault?
Add a partial resolution option to your dispute resolution flow. Instead of binary release or refund, allow the admin to specify a split: e.g., release 70% to the seller and refund 30% to the buyer. Implement this with Stripe's partial capture (capture with amount_to_capture less than the full amount) and then issue a refund for the remaining amount.
Can Stripe Radar block escrow transactions as suspicious?
Stripe Radar may flag some transactions if the payment pattern looks unusual. To reduce false positives, pass customer metadata (email, name) when creating the PaymentIntent and ensure your account's business description accurately describes the escrow service. You can also add Radar rules exceptions for your platform's specific patterns.
How do I prove a transaction was legitimate in a chargeback?
Your escrow_audit table is your primary evidence. Export the complete audit trail for the transaction, including funding, delivery confirmation, and any messages exchanged. Stripe allows you to submit evidence for chargebacks in the Dashboard with documents, screenshots, and transaction histories. The detailed audit trail with timestamps makes it much harder for a buyer to win a fraudulent chargeback.
Is there help available for building a production escrow service?
RapidDev builds production financial transaction systems in Lovable including escrow, marketplace payouts, and complex multi-party payment flows. Reach out if you need help designing the full architecture for a production escrow platform.
Do I need to be a licensed money transmitter to run an escrow service?
This depends on your jurisdiction and the nature of transactions. In many cases, using Stripe as the payment processor means Stripe holds the regulatory licenses. However, if you are holding funds on behalf of third parties as a core service (not just as a feature of your product), consult a legal advisor. Stripe's usage terms also have specific requirements around escrow-like services.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation