# How to Build Escrow service with V0

- Tool: v0
- Difficulty: Advanced
- Compatibility: V0 Premium or higher
- Last updated: April 2026

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

## Before you start

- 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

## Step-by-step guide

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

```
// Paste this prompt into V0's AI chat:
// Build an escrow service platform. Create a Supabase schema with:
// 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)
// 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)
// 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)
// 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)
// 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)
// 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.

```
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
import { createClient } from '@/lib/supabase/server'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(req: NextRequest) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()

  if (!user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const { data: profile } = await supabase
    .from('users_profiles')
    .select('stripe_account_id')
    .eq('id', user.id)
    .single()

  let accountId = profile?.stripe_account_id

  if (!accountId) {
    const account = await stripe.accounts.create({
      type: 'express',
      email: user.email,
      metadata: { userId: user.id },
    })
    accountId = account.id

    await supabase
      .from('users_profiles')
      .update({ stripe_account_id: accountId })
      .eq('id', user.id)
  }

  const accountLink = await stripe.accountLinks.create({
    account: accountId,
    refresh_url: `${req.nextUrl.origin}/dashboard`,
    return_url: `${req.nextUrl.origin}/dashboard?onboarded=true`,
    type: 'account_onboarding',
  })

  return NextResponse.json({ url: accountLink.url })
}
```

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

```
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
import { createClient } from '@supabase/supabase-js'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function POST(req: NextRequest) {
  const { contractId } = await req.json()

  const { data: contract } = await supabase
    .from('contracts')
    .select('*, users_profiles!contracts_seller_id_fkey(stripe_account_id)')
    .eq('id', contractId)
    .single()

  if (!contract || contract.status !== 'draft') {
    return NextResponse.json({ error: 'Invalid contract' }, { status: 400 })
  }

  const paymentIntent = await stripe.paymentIntents.create({
    amount: contract.amount_cents,
    currency: contract.currency,
    capture_method: 'manual',
    metadata: { contractId: contract.id },
    transfer_data: {
      destination: contract.users_profiles.stripe_account_id,
    },
  })

  await supabase
    .from('contracts')
    .update({ status: 'funded', funded_at: new Date().toISOString() })
    .eq('id', contractId)

  await supabase.from('transactions').insert({
    contract_id: contractId,
    type: 'fund',
    amount_cents: contract.amount_cents,
  })

  return NextResponse.json({ clientSecret: paymentIntent.client_secret })
}
```

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

```
// Paste this prompt into V0's AI chat:
// Build a contract detail page at app/contracts/[id]/page.tsx.
// Requirements:
// - Server Component that fetches the contract with milestones, transactions, and disputes
// - Show contract status as a Stepper/progress component with stages: Draft, Funded, In Progress, Delivered, Completed
// - Display contract details in a Card: title, description, amount, buyer name, seller name
// - Show milestones as a list of Card components with title, amount, and status Badge (pending/funded/released)
// - Action buttons change based on status and user role:
//   - Buyer on 'draft': "Fund Contract" Button
//   - Seller on 'funded': "Mark as Delivered" Button
//   - Buyer on 'delivered': "Release Payment" Button (green) and "Dispute" Button (red)
//   - Both users: "Raise Dispute" Button when status is funded or delivered
// - Use AlertDialog for confirming release and refund actions
// - Show transaction history in a timeline below the contract using custom vertical timeline with Badge for type
// - 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.

```
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
import { createClient } from '@supabase/supabase-js'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function POST(req: NextRequest) {
  const body = await req.text()
  const sig = req.headers.get('stripe-signature')!

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
  }

  if (event.type === 'payment_intent.amount_capturable_updated') {
    const pi = event.data.object as Stripe.PaymentIntent
    const contractId = pi.metadata.contractId

    await supabase
      .from('contracts')
      .update({ status: 'funded', funded_at: new Date().toISOString() })
      .eq('id', contractId)
  }

  if (event.type === 'transfer.created') {
    const transfer = event.data.object as Stripe.Transfer
    const contractId = transfer.metadata?.contractId

    if (contractId) {
      await supabase.from('transactions').insert({
        contract_id: contractId,
        type: 'release',
        amount_cents: transfer.amount,
        stripe_transfer_id: transfer.id,
      })

      await supabase
        .from('contracts')
        .update({ status: 'completed', completed_at: new Date().toISOString() })
        .eq('id', contractId)
    }
  }

  return NextResponse.json({ received: true })
}
```

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

```
// Paste this prompt into V0's AI chat:
// Build a dispute resolution page at app/disputes/[id]/page.tsx.
// Requirements:
// - Server Component that fetches the dispute with its contract details and both parties' info
// - Show dispute status Badge (open/resolved), reason text, raised by user with Avatar
// - Display the contract summary Card with amount, title, and current status
// - Admin actions: "Release to Seller" Button and "Refund to Buyer" Button in an AlertDialog with confirmation
// - Release calls /api/escrow/release to capture payment and transfer to seller
// - Refund calls /api/escrow/refund to cancel the PaymentIntent
// - A Textarea for admin to add resolution notes
// - Timeline showing dispute events (raised, notes added, resolved) with timestamps
// - Both parties can add comments to the dispute via a Textarea and Button
// - 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 example

File: `app/api/escrow/release/route.ts`

```typescript
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
import { createClient } from '@supabase/supabase-js'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function POST(req: NextRequest) {
  const { contractId } = await req.json()

  const { data: contract } = await supabase
    .from('contracts')
    .select('*')
    .eq('id', contractId)
    .in('status', ['funded', 'delivered'])
    .single()

  if (!contract) {
    return NextResponse.json(
      { error: 'Contract not found or invalid status' },
      { status: 400 }
    )
  }

  const paymentIntents = await stripe.paymentIntents.search({
    query: `metadata["contractId"]:"${contractId}"`,
  })

  const pi = paymentIntents.data[0]
  if (!pi || pi.status !== 'requires_capture') {
    return NextResponse.json(
      { error: 'No capturable payment found' },
      { status: 400 }
    )
  }

  await stripe.paymentIntents.capture(pi.id)

  await supabase
    .from('contracts')
    .update({
      status: 'completed',
      completed_at: new Date().toISOString(),
    })
    .eq('id', contractId)

  await supabase.from('transactions').insert({
    contract_id: contractId,
    type: 'release',
    amount_cents: contract.amount_cents,
  })

  return NextResponse.json({ success: true })
}
```

## Common mistakes

- **Using capture_method: automatic instead of manual for escrow payments** — Automatic capture immediately charges the buyer's card. For escrow, you need to authorize and hold funds, then capture only when the seller delivers. Fix: Always set capture_method: 'manual' when creating the PaymentIntent. Call stripe.paymentIntents.capture() only when the buyer confirms delivery.
- **Not handling the 7-day authorization window for held funds** — Stripe's authorization on most cards expires after 7 days. If you don't capture within that window, the authorization is voided. Fix: For escrow periods longer than 7 days, store the payment method and re-authorize when approaching the expiry. Alert users about the timeline.
- **Exposing STRIPE_SECRET_KEY with NEXT_PUBLIC_ prefix** — The secret key allows full Stripe account access. Exposing it on the client enables anyone to issue refunds, create charges, or read customer data. Fix: 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.
- **Not validating contract status before performing actions** — Without status validation, a buyer could release funds on a draft contract or refund a completed one, causing inconsistent state. Fix: 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.

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

---

Source: https://www.rapidevelopers.com/how-to-build-v0/escrow-service
© RapidDev — https://www.rapidevelopers.com/how-to-build-v0/escrow-service
