Build an escrow service with V0 using Next.js, Stripe Connect for held-funds release, and Supabase for contract and milestone tracking. You'll create buyer/seller flows, manual capture payments, milestone-based releases, and a dispute resolution system — all in about 2-4 hours without touching a terminal.
What you're building
Escrow services protect both buyers and sellers in transactions by holding funds until delivery is confirmed. Whether you are building a freelance marketplace, a P2P service platform, or a contract-based project management tool, escrow ensures neither party gets cheated.
V0 generates the contract management UI, Stripe integration code, and milestone tracking system from prompts. Stripe Connect handles seller onboarding and fund transfers, while PaymentIntents with manual capture enable the hold-then-release pattern that is the core of any escrow system.
The architecture uses Next.js App Router with Server Components for contract detail pages, client components for the multi-step contract creation form, API routes for Stripe payment operations (fund, release, refund), Supabase for contract state management, and Stripe Connect for seller payouts.
Final result
A complete escrow platform with contract lifecycle management, held-funds payment processing, milestone-based releases, Stripe Connect seller payouts, and dispute resolution.
Tech stack
Prerequisites
- A V0 account (Premium plan recommended for the complex Stripe integration)
- A Supabase project (free tier works — connect via V0's Connect panel)
- A Stripe account with Connect enabled (test mode works for development)
- Understanding of escrow concepts: funds are held by a third party until conditions are met
Build steps
Set up the project with Supabase and Stripe Connect
Create a new V0 project. Use the Connect panel to add Supabase and Stripe via the Vercel Marketplace. Then prompt V0 to create the database schema for contracts, milestones, disputes, and transactions.
1// Paste this prompt into V0's AI chat:2// Build an escrow service platform. Create a Supabase schema with:3// 1. users_profiles: id (uuid PK FK to auth.users), full_name (text), role (text check in 'buyer','seller','both'), stripe_account_id (text), onboarded (boolean default false)4// 2. contracts: id (uuid PK), buyer_id (uuid FK to auth.users), seller_id (uuid FK to auth.users), title (text), description (text), amount_cents (int), currency (text default 'usd'), status (text default 'draft' check in 'draft','funded','in_progress','delivered','disputed','completed','refunded'), funded_at (timestamptz), delivered_at (timestamptz), completed_at (timestamptz), created_at (timestamptz)5// 3. milestones: id (uuid PK), contract_id (uuid FK to contracts), title (text), amount_cents (int), status (text default 'pending' check in 'pending','funded','released','refunded'), position (int)6// 4. disputes: id (uuid PK), contract_id (uuid FK to contracts), raised_by (uuid FK to auth.users), reason (text), resolution (text), status (text default 'open'), created_at (timestamptz)7// 5. transactions: id (uuid PK), contract_id (uuid FK to contracts), type (text check in 'fund','release','refund'), amount_cents (int), stripe_transfer_id (text), created_at (timestamptz)8// Add RLS policies: buyers and sellers can access their own contracts.Pro tip: Use V0's Git panel to connect to GitHub early — the escrow service has many API routes and benefits from version-controlled incremental development.
Expected result: Supabase is connected with all five tables created. Stripe keys are auto-provisioned in the Vars tab.
Build the Stripe Connect onboarding flow for sellers
Create an API route that sets up Stripe Connect Express accounts for sellers. When a seller needs to receive payouts, they go through Stripe's hosted onboarding flow to verify their identity and bank details.
1import { NextRequest, NextResponse } from 'next/server'2import Stripe from 'stripe'3import { createClient } from '@/lib/supabase/server'45const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)67export async function POST(req: NextRequest) {8 const supabase = await createClient()9 const { data: { user } } = await supabase.auth.getUser()1011 if (!user) {12 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })13 }1415 const { data: profile } = await supabase16 .from('users_profiles')17 .select('stripe_account_id')18 .eq('id', user.id)19 .single()2021 let accountId = profile?.stripe_account_id2223 if (!accountId) {24 const account = await stripe.accounts.create({25 type: 'express',26 email: user.email,27 metadata: { userId: user.id },28 })29 accountId = account.id3031 await supabase32 .from('users_profiles')33 .update({ stripe_account_id: accountId })34 .eq('id', user.id)35 }3637 const accountLink = await stripe.accountLinks.create({38 account: accountId,39 refresh_url: `${req.nextUrl.origin}/dashboard`,40 return_url: `${req.nextUrl.origin}/dashboard?onboarded=true`,41 type: 'account_onboarding',42 })4344 return NextResponse.json({ url: accountLink.url })45}Expected result: Sellers click 'Set up payments' which creates a Stripe Connect account and redirects them to Stripe's hosted onboarding form. On completion, they return to the dashboard.
Create the escrow fund and release API routes
Build the core escrow payment routes. The fund route creates a Stripe PaymentIntent with manual capture to hold the buyer's funds. The release route captures the held payment and transfers funds to the seller's connected account.
1import { NextRequest, NextResponse } from 'next/server'2import Stripe from 'stripe'3import { createClient } from '@supabase/supabase-js'45const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)6const supabase = createClient(7 process.env.SUPABASE_URL!,8 process.env.SUPABASE_SERVICE_ROLE_KEY!9)1011export async function POST(req: NextRequest) {12 const { contractId } = await req.json()1314 const { data: contract } = await supabase15 .from('contracts')16 .select('*, users_profiles!contracts_seller_id_fkey(stripe_account_id)')17 .eq('id', contractId)18 .single()1920 if (!contract || contract.status !== 'draft') {21 return NextResponse.json({ error: 'Invalid contract' }, { status: 400 })22 }2324 const paymentIntent = await stripe.paymentIntents.create({25 amount: contract.amount_cents,26 currency: contract.currency,27 capture_method: 'manual',28 metadata: { contractId: contract.id },29 transfer_data: {30 destination: contract.users_profiles.stripe_account_id,31 },32 })3334 await supabase35 .from('contracts')36 .update({ status: 'funded', funded_at: new Date().toISOString() })37 .eq('id', contractId)3839 await supabase.from('transactions').insert({40 contract_id: contractId,41 type: 'fund',42 amount_cents: contract.amount_cents,43 })4445 return NextResponse.json({ clientSecret: paymentIntent.client_secret })46}Pro tip: Stripe holds authorized funds for up to 7 days by default. For longer escrow periods, you'll need to re-authorize — mention this in your contract terms.
Expected result: The fund route authorizes the buyer's card without capturing. The release route (separate file) captures and transfers to the seller.
Build the contract detail page with lifecycle tracking
Create the contract detail page showing the current status, milestones, action buttons, and activity timeline. Different actions are available based on the current contract status and the user's role.
1// Paste this prompt into V0's AI chat:2// Build a contract detail page at app/contracts/[id]/page.tsx.3// Requirements:4// - Server Component that fetches the contract with milestones, transactions, and disputes5// - Show contract status as a Stepper/progress component with stages: Draft, Funded, In Progress, Delivered, Completed6// - Display contract details in a Card: title, description, amount, buyer name, seller name7// - Show milestones as a list of Card components with title, amount, and status Badge (pending/funded/released)8// - Action buttons change based on status and user role:9// - Buyer on 'draft': "Fund Contract" Button10// - Seller on 'funded': "Mark as Delivered" Button11// - Buyer on 'delivered': "Release Payment" Button (green) and "Dispute" Button (red)12// - Both users: "Raise Dispute" Button when status is funded or delivered13// - Use AlertDialog for confirming release and refund actions14// - Show transaction history in a timeline below the contract using custom vertical timeline with Badge for type15// - Use Separator between the contract details and the timeline sectionsExpected result: The contract page shows the full lifecycle status, milestones, and appropriate action buttons based on the contract state and the current user's role.
Handle Stripe webhooks for payment events
Build the webhook handler that processes escrow-related Stripe events: payment authorization, transfers, and refunds. Update contract and transaction records accordingly.
1import { NextRequest, NextResponse } from 'next/server'2import Stripe from 'stripe'3import { createClient } from '@supabase/supabase-js'45const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)6const supabase = createClient(7 process.env.SUPABASE_URL!,8 process.env.SUPABASE_SERVICE_ROLE_KEY!9)1011export async function POST(req: NextRequest) {12 const body = await req.text()13 const sig = req.headers.get('stripe-signature')!1415 let event: Stripe.Event16 try {17 event = stripe.webhooks.constructEvent(18 body,19 sig,20 process.env.STRIPE_WEBHOOK_SECRET!21 )22 } catch {23 return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })24 }2526 if (event.type === 'payment_intent.amount_capturable_updated') {27 const pi = event.data.object as Stripe.PaymentIntent28 const contractId = pi.metadata.contractId2930 await supabase31 .from('contracts')32 .update({ status: 'funded', funded_at: new Date().toISOString() })33 .eq('id', contractId)34 }3536 if (event.type === 'transfer.created') {37 const transfer = event.data.object as Stripe.Transfer38 const contractId = transfer.metadata?.contractId3940 if (contractId) {41 await supabase.from('transactions').insert({42 contract_id: contractId,43 type: 'release',44 amount_cents: transfer.amount,45 stripe_transfer_id: transfer.id,46 })4748 await supabase49 .from('contracts')50 .update({ status: 'completed', completed_at: new Date().toISOString() })51 .eq('id', contractId)52 }53 }5455 return NextResponse.json({ received: true })56}Expected result: The webhook updates contract status when funds are authorized and when transfers complete. All payment events are logged in the transactions table.
Build the dispute resolution workflow
Create the dispute page where users can raise and resolve disputes on contracts. Include reason submission, admin review, and resolution actions (release to seller or refund to buyer).
1// Paste this prompt into V0's AI chat:2// Build a dispute resolution page at app/disputes/[id]/page.tsx.3// Requirements:4// - Server Component that fetches the dispute with its contract details and both parties' info5// - Show dispute status Badge (open/resolved), reason text, raised by user with Avatar6// - Display the contract summary Card with amount, title, and current status7// - Admin actions: "Release to Seller" Button and "Refund to Buyer" Button in an AlertDialog with confirmation8// - Release calls /api/escrow/release to capture payment and transfer to seller9// - Refund calls /api/escrow/refund to cancel the PaymentIntent10// - A Textarea for admin to add resolution notes11// - Timeline showing dispute events (raised, notes added, resolved) with timestamps12// - Both parties can add comments to the dispute via a Textarea and Button13// - Use Separator between the dispute details and the comment threadPro tip: Set STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY in the Vars tab. Only the publishable key gets the NEXT_PUBLIC_ prefix.
Expected result: Disputes show the full context of the contract. Admins can release funds to the seller or refund the buyer. Both parties can add comments.
Complete code
1import { NextRequest, NextResponse } from 'next/server'2import Stripe from 'stripe'3import { createClient } from '@supabase/supabase-js'45const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)6const supabase = createClient(7 process.env.SUPABASE_URL!,8 process.env.SUPABASE_SERVICE_ROLE_KEY!9)1011export async function POST(req: NextRequest) {12 const { contractId } = await req.json()1314 const { data: contract } = await supabase15 .from('contracts')16 .select('*')17 .eq('id', contractId)18 .in('status', ['funded', 'delivered'])19 .single()2021 if (!contract) {22 return NextResponse.json(23 { error: 'Contract not found or invalid status' },24 { status: 400 }25 )26 }2728 const paymentIntents = await stripe.paymentIntents.search({29 query: `metadata["contractId"]:"${contractId}"`,30 })3132 const pi = paymentIntents.data[0]33 if (!pi || pi.status !== 'requires_capture') {34 return NextResponse.json(35 { error: 'No capturable payment found' },36 { status: 400 }37 )38 }3940 await stripe.paymentIntents.capture(pi.id)4142 await supabase43 .from('contracts')44 .update({45 status: 'completed',46 completed_at: new Date().toISOString(),47 })48 .eq('id', contractId)4950 await supabase.from('transactions').insert({51 contract_id: contractId,52 type: 'release',53 amount_cents: contract.amount_cents,54 })5556 return NextResponse.json({ success: true })57}Customization ideas
Add milestone-based partial releases
Instead of releasing the full amount at once, allow milestone-by-milestone fund releases as each deliverable is approved by the buyer.
Add automatic release after deadline
Set a deadline on contracts where funds auto-release to the seller if the buyer doesn't dispute within the window, using a Vercel Cron job.
Add contract templates
Create reusable contract templates for common service types (web design, copywriting, consulting) with pre-filled terms and milestone structures.
Add real-time status notifications
Use Supabase Realtime to notify buyers and sellers instantly when contract status changes, milestones are completed, or disputes are raised.
Common pitfalls
Pitfall: Using capture_method: automatic instead of manual for escrow payments
How to avoid: Always set capture_method: 'manual' when creating the PaymentIntent. Call stripe.paymentIntents.capture() only when the buyer confirms delivery.
Pitfall: Not handling the 7-day authorization window for held funds
How to avoid: For escrow periods longer than 7 days, store the payment method and re-authorize when approaching the expiry. Alert users about the timeline.
Pitfall: Exposing STRIPE_SECRET_KEY with NEXT_PUBLIC_ prefix
How to avoid: Set STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET in Vars without NEXT_PUBLIC_ prefix. Only NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is safe for the client.
Pitfall: Not validating contract status before performing actions
How to avoid: Check the current contract status in every API route before performing the action. Only allow valid transitions (draft to funded, delivered to completed, etc.).
Best practices
- Use Stripe PaymentIntents with capture_method: manual for the hold-then-release escrow pattern. Only capture when delivery is confirmed.
- Use request.text() instead of request.json() in the Stripe webhook handler for proper signature verification.
- Store all payment state transitions in the transactions table for a complete audit trail of fund movements.
- Validate contract status transitions server-side — never rely on client-side checks to prevent invalid actions.
- Use Stripe Connect Express for seller onboarding — Stripe handles identity verification and bank account setup.
- Connect to GitHub via V0's Git panel early — the escrow service has many API routes that benefit from version control.
- Set STRIPE_WEBHOOK_SECRET in the Vars tab (no NEXT_PUBLIC_ prefix) and register the production webhook URL after deploying.
AI prompts to try
Copy these prompts to build this project faster.
I'm building an escrow service with Next.js and Stripe. I need to implement the hold-then-release payment pattern using PaymentIntents with capture_method: manual. Show me how to create the authorization, handle the 7-day hold window, capture on delivery confirmation, and transfer to a seller's Stripe Connect account using transfer_data.destination. Include error handling for expired authorizations.
Build the dispute resolution workflow for an escrow service. Create a disputes page that shows the contract summary, dispute reason, and resolution options. Add AlertDialog for confirming Release to Seller and Refund to Buyer actions. Include a comment thread for both parties. The release action calls stripe.paymentIntents.capture() and the refund action calls stripe.paymentIntents.cancel(). Update contract and dispute status atomically.
Frequently asked questions
How long can funds be held in escrow with Stripe?
Standard card authorizations are held for up to 7 days. For longer periods, you need to re-authorize by storing the payment method and creating a new PaymentIntent. Some card networks support extended authorizations up to 31 days.
Do I need Stripe Connect for the escrow service?
Yes. Stripe Connect enables you to hold funds on behalf of buyers and release them to sellers. You act as the platform, and each seller has their own Express account for receiving payouts.
How does the dispute resolution work?
Either party can raise a dispute, which freezes the contract. An admin reviews the dispute, reads both parties' comments, and decides to either release funds to the seller or refund the buyer. All actions are logged.
Can I use milestone-based releases instead of all-at-once?
Yes. The milestones table supports breaking contracts into multiple deliverables. Each milestone can be funded and released independently by creating separate PaymentIntents per milestone amount.
What happens if the buyer's card expires during the hold period?
The authorization will fail to capture. Store the customer's payment method ID so you can re-authorize with a new PaymentIntent. Alert the buyer to update their card if the authorization expires.
Can RapidDev help build a custom escrow service?
Yes. RapidDev has built 600+ apps including marketplace platforms with complex payment flows, escrow systems, and Stripe Connect integrations. Book a free consultation to discuss your specific escrow requirements.
What V0 plan do I need for this project?
The Premium plan ($20/month) is recommended. The escrow service has multiple API routes, Stripe Connect integration, and role-based contract pages that require many prompt iterations to build correctly.
How do I test the escrow flow without real money?
Stripe test mode is enabled by default via the Vercel Marketplace. Use test card 4242 4242 4242 4242 to simulate payments. Use the Stripe Dashboard to manually trigger webhook events for testing the complete flow.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation