Skip to main content
RapidDev - Software Development Agency

How to Build Escrow service with V0

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

  • Contract creation with multi-step form for buyer-seller agreements using shadcn/ui Stepper and Card
  • Stripe PaymentIntent with capture_method: manual for hold-then-release payment pattern
  • Milestone tracking with funded/released/refunded status progression via Badge and Progress
  • Seller onboarding via Stripe Connect Express with automatic account creation
  • Dispute resolution workflow with reason submission and admin review using AlertDialog
  • Earnings dashboard for sellers and activity timeline for contract participants
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Advanced12 min read2-4 hoursV0 Premium or higherApril 2026RapidDev Engineering Team
TL;DR

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

V0AI Code Generator
Next.jsFull-Stack Framework
Tailwind CSSStyling
shadcn/uiComponent Library
SupabaseDatabase
Stripe ConnectPayments

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

1

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.

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

2

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.

app/api/connect/onboard/route.ts
1import { NextRequest, NextResponse } from 'next/server'
2import Stripe from 'stripe'
3import { createClient } from '@/lib/supabase/server'
4
5const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
6
7export async function POST(req: NextRequest) {
8 const supabase = await createClient()
9 const { data: { user } } = await supabase.auth.getUser()
10
11 if (!user) {
12 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
13 }
14
15 const { data: profile } = await supabase
16 .from('users_profiles')
17 .select('stripe_account_id')
18 .eq('id', user.id)
19 .single()
20
21 let accountId = profile?.stripe_account_id
22
23 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.id
30
31 await supabase
32 .from('users_profiles')
33 .update({ stripe_account_id: accountId })
34 .eq('id', user.id)
35 }
36
37 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 })
43
44 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.

3

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.

app/api/escrow/fund/route.ts
1import { NextRequest, NextResponse } from 'next/server'
2import Stripe from 'stripe'
3import { createClient } from '@supabase/supabase-js'
4
5const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
6const supabase = createClient(
7 process.env.SUPABASE_URL!,
8 process.env.SUPABASE_SERVICE_ROLE_KEY!
9)
10
11export async function POST(req: NextRequest) {
12 const { contractId } = await req.json()
13
14 const { data: contract } = await supabase
15 .from('contracts')
16 .select('*, users_profiles!contracts_seller_id_fkey(stripe_account_id)')
17 .eq('id', contractId)
18 .single()
19
20 if (!contract || contract.status !== 'draft') {
21 return NextResponse.json({ error: 'Invalid contract' }, { status: 400 })
22 }
23
24 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 })
33
34 await supabase
35 .from('contracts')
36 .update({ status: 'funded', funded_at: new Date().toISOString() })
37 .eq('id', contractId)
38
39 await supabase.from('transactions').insert({
40 contract_id: contractId,
41 type: 'fund',
42 amount_cents: contract.amount_cents,
43 })
44
45 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.

4

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.

prompt.txt
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 disputes
5// - Show contract status as a Stepper/progress component with stages: Draft, Funded, In Progress, Delivered, Completed
6// - Display contract details in a Card: title, description, amount, buyer name, seller name
7// - 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" Button
10// - Seller on 'funded': "Mark as Delivered" Button
11// - Buyer on 'delivered': "Release Payment" Button (green) and "Dispute" Button (red)
12// - Both users: "Raise Dispute" Button when status is funded or delivered
13// - Use AlertDialog for confirming release and refund actions
14// - Show transaction history in a timeline below the contract using custom vertical timeline with Badge for type
15// - Use Separator between the contract details and the timeline sections

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

5

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.

app/api/webhooks/stripe/route.ts
1import { NextRequest, NextResponse } from 'next/server'
2import Stripe from 'stripe'
3import { createClient } from '@supabase/supabase-js'
4
5const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
6const supabase = createClient(
7 process.env.SUPABASE_URL!,
8 process.env.SUPABASE_SERVICE_ROLE_KEY!
9)
10
11export async function POST(req: NextRequest) {
12 const body = await req.text()
13 const sig = req.headers.get('stripe-signature')!
14
15 let event: Stripe.Event
16 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 }
25
26 if (event.type === 'payment_intent.amount_capturable_updated') {
27 const pi = event.data.object as Stripe.PaymentIntent
28 const contractId = pi.metadata.contractId
29
30 await supabase
31 .from('contracts')
32 .update({ status: 'funded', funded_at: new Date().toISOString() })
33 .eq('id', contractId)
34 }
35
36 if (event.type === 'transfer.created') {
37 const transfer = event.data.object as Stripe.Transfer
38 const contractId = transfer.metadata?.contractId
39
40 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 })
47
48 await supabase
49 .from('contracts')
50 .update({ status: 'completed', completed_at: new Date().toISOString() })
51 .eq('id', contractId)
52 }
53 }
54
55 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.

6

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

prompt.txt
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' info
5// - Show dispute status Badge (open/resolved), reason text, raised by user with Avatar
6// - Display the contract summary Card with amount, title, and current status
7// - Admin actions: "Release to Seller" Button and "Refund to Buyer" Button in an AlertDialog with confirmation
8// - Release calls /api/escrow/release to capture payment and transfer to seller
9// - Refund calls /api/escrow/refund to cancel the PaymentIntent
10// - A Textarea for admin to add resolution notes
11// - Timeline showing dispute events (raised, notes added, resolved) with timestamps
12// - Both parties can add comments to the dispute via a Textarea and Button
13// - Use Separator between the dispute details and the comment thread

Pro 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

app/api/escrow/release/route.ts
1import { NextRequest, NextResponse } from 'next/server'
2import Stripe from 'stripe'
3import { createClient } from '@supabase/supabase-js'
4
5const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
6const supabase = createClient(
7 process.env.SUPABASE_URL!,
8 process.env.SUPABASE_SERVICE_ROLE_KEY!
9)
10
11export async function POST(req: NextRequest) {
12 const { contractId } = await req.json()
13
14 const { data: contract } = await supabase
15 .from('contracts')
16 .select('*')
17 .eq('id', contractId)
18 .in('status', ['funded', 'delivered'])
19 .single()
20
21 if (!contract) {
22 return NextResponse.json(
23 { error: 'Contract not found or invalid status' },
24 { status: 400 }
25 )
26 }
27
28 const paymentIntents = await stripe.paymentIntents.search({
29 query: `metadata["contractId"]:"${contractId}"`,
30 })
31
32 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 }
39
40 await stripe.paymentIntents.capture(pi.id)
41
42 await supabase
43 .from('contracts')
44 .update({
45 status: 'completed',
46 completed_at: new Date().toISOString(),
47 })
48 .eq('id', contractId)
49
50 await supabase.from('transactions').insert({
51 contract_id: contractId,
52 type: 'release',
53 amount_cents: contract.amount_cents,
54 })
55
56 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.

ChatGPT Prompt

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 Prompt

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.

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.