# How to Build an Escrow Service with Lovable

- Tool: How to Build with Lovable
- Difficulty: Advanced
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## TL;DR

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.

## Before you start

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

## Step-by-step guide

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

```
Create an escrow service schema in Supabase:

Tables:
- 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_at

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

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

RLS:
- escrow_transactions: buyer and seller can SELECT their own transactions. Service role full access.
- escrow_audit: buyer and seller can SELECT audit entries for their transactions. No direct INSERT/UPDATE for users.
- escrow_disputes: buyer and seller can SELECT/UPDATE their evidence fields. Service role full access.

Create a check constraint on escrow_transactions: state must be one of ('pending','funded','released','disputed','refunded','expired')

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

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

```
Create two Supabase Edge Functions:

1. supabase/functions/create-escrow/index.ts
- Accept: { sellerId, title, description, amountCents }
- Verify the caller is authenticated (buyer)
- Create a Stripe PaymentIntent with: amount=amountCents, currency='usd', capture_method='manual', metadata={transactionId: will update}
- 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+6days
- Update the PaymentIntent metadata with the transaction ID: PATCH /v1/payment_intents/{id} body: metadata[transactionId]=transaction.id
- Return: { transactionId, clientSecret: pi.client_secret }

2. supabase/functions/fund-escrow/index.ts  
- Called after the frontend confirms the PaymentIntent (buyer entered card details)
- Accept: { transactionId }
- Verify the caller is the buyer for this transaction
- Fetch the transaction and verify state='pending'
- The PaymentIntent should be in 'requires_capture' state after frontend confirmation
- Fetch the PaymentIntent from Stripe to verify status='requires_capture'
- Update state to 'funded' in Supabase
- Insert audit record: from_state='pending', to_state='funded'
- 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.

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

```
Create four more Supabase Edge Functions:

1. supabase/functions/release-escrow/index.ts
- Called by the buyer to approve delivery and release funds
- Verify caller is buyer, transaction state='funded'
- Call Stripe POST /v1/payment_intents/{id}/capture
- Update state='released', stripe_charge_id from capture response
- Insert audit: from_state='funded', to_state='released'

2. supabase/functions/dispute-escrow/index.ts
- Called by buyer or seller to open a dispute
- Verify transaction state='funded'
- Update state='disputed'
- Insert into escrow_disputes: opened_by, reason
- Insert audit: from_state='funded', to_state='disputed'

3. supabase/functions/resolve-dispute/index.ts (admin only)
- Accept: { transactionId, decision: 'release'|'refund', adminReason }
- Verify caller has admin role
- If decision='release': call /v1/payment_intents/{id}/capture
- If decision='refund': call /v1/payment_intents/{id}/cancel
- Update state='released' or 'refunded'
- Update escrow_disputes: admin_decision, resolved_at
- Insert audit

4. supabase/functions/expire-escrow/index.ts (cron job)
- Query all transactions where state='funded' AND hold_expires_at < now()
- For each: call Stripe /v1/payment_intents/{id}/cancel
- Update state='expired'
- 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.

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

```
Build an escrow dashboard at src/pages/EscrowDashboard.tsx.

Requirements:
- Fetch all transactions where buyer_id OR seller_id = current user
- Show two Tabs: 'As Buyer' and 'As Seller'
- Each transaction as a Card with:
  - Title and description
  - Amount formatted as currency
  - State Badge: pending=gray, funded=blue, released=green, disputed=red, refunded=orange, expired=gray
  - Hold expires countdown if state='funded': 'Expires in X days' with a yellow badge if < 48 hours
  - Contextual action Buttons:
    - Buyer + funded: 'Release Funds' Button (opens Confirmation Dialog) + 'Open Dispute' Button
    - Buyer + pending: 'Fund Escrow' Button (shows Stripe Elements form)
    - Seller + funded: 'View Transaction' (read-only)
    - Disputed: 'Submit Evidence' Button for the non-opener party
- Clicking 'Submit Evidence' opens a Dialog with a Textarea for the evidence text and a Submit button
- Empty state if no transactions: 'No escrow transactions yet. Start one to protect your next deal.'
- At the top, summary metrics: Active Held Amount, Released (30 days), Disputed Count
```

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

### 5. Build the audit trail and transaction detail page

Ask Lovable to create the transaction detail page showing the complete audit trail as a timeline.

```
Build a transaction detail page at src/pages/EscrowDetail.tsx (route: /escrow/:id).

Requirements:
- Fetch the transaction with its audit trail and dispute (if exists) from Supabase
- Transaction summary Card at the top: title, description, amount, current state Badge, parties (buyer name/email, seller name/email)
- Below the summary: Timeline component showing the audit trail
  - Each escrow_audit row as a timeline item with: actor name, from_state → to_state (with Badges), reason, formatted timestamp
  - Color-code timeline dots by transition type: green for release, red for dispute/refund, blue for fund, gray for expire
- If state='disputed': show the Dispute section
  - Dispute reason, opened by, creation date
  - Two evidence panels side by side: 'Buyer Evidence' and 'Seller Evidence'
  - If evidence is empty for the current user's side and dispute is unresolved, show the Submit Evidence form inline
  - Admin decision section (visible to admin role only)
- If state='funded': show the Stripe hold information Card with hold_expires_at and a countdown
- Action buttons matching the dashboard contextual buttons
```

**Expected 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 example

File: `supabase/functions/release-escrow/index.ts`

```typescript
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
  'Content-Type': 'application/json',
}

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })

  try {
    const authHeader = req.headers.get('Authorization') ?? ''
    const userClient = createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_ANON_KEY') ?? '', {
      global: { headers: { Authorization: authHeader } },
    })
    const { data: { user } } = await userClient.auth.getUser()
    if (!user) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: corsHeaders })

    const { transactionId } = await req.json()
    const supabase = createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '')

    const { data: tx } = await supabase
      .from('escrow_transactions')
      .select('id, buyer_id, state, stripe_payment_intent_id, amount_cents')
      .eq('id', transactionId)
      .single()

    if (!tx) return new Response(JSON.stringify({ error: 'Transaction not found' }), { status: 404, headers: corsHeaders })
    if (tx.buyer_id !== user.id) return new Response(JSON.stringify({ error: 'Only the buyer can release funds' }), { status: 403, headers: corsHeaders })
    if (tx.state !== 'funded') return new Response(JSON.stringify({ error: `Cannot release from state: ${tx.state}` }), { status: 400, headers: corsHeaders })

    const stripeKey = Deno.env.get('STRIPE_SECRET_KEY') ?? ''
    const captureRes = await fetch(`https://api.stripe.com/v1/payment_intents/${tx.stripe_payment_intent_id}/capture`, {
      method: 'POST',
      headers: { Authorization: `Basic ${btoa(stripeKey + ':')}` },
    })
    const captured = await captureRes.json()
    if (captured.error) throw new Error(captured.error.message)

    await supabase.from('escrow_transactions').update({
      state: 'released',
      stripe_charge_id: captured.latest_charge,
      updated_at: new Date().toISOString(),
    }).eq('id', transactionId)

    await supabase.from('escrow_audit').insert({
      transaction_id: transactionId,
      actor_id: user.id,
      from_state: 'funded',
      to_state: 'released',
      reason: 'Buyer approved delivery and released funds',
      metadata: { stripe_charge_id: captured.latest_charge },
    })

    return new Response(JSON.stringify({ success: true }), { headers: corsHeaders })
  } catch (err) {
    const message = err instanceof Error ? err.message : 'Internal error'
    return new Response(JSON.stringify({ error: message }), { status: 500, headers: corsHeaders })
  }
})
```

## Common mistakes

- **Using automatic capture instead of manual capture for the PaymentIntent** — If you create a PaymentIntent with the default capture_method (automatic), Stripe charges the card immediately — there is no hold period. You cannot return the funds via cancellation; you would need to issue a refund (which may have fees and processing delays). Fix: 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).
- **Not enforcing state machine transitions in the Edge Function** — Without validation, a seller could call the release-escrow function on a disputed transaction, bypassing the dispute resolution process. Fix: 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.
- **Forgetting that Stripe authorization holds expire after 7 days** — If the hold_expires_at passes without capturing or canceling, Stripe automatically releases the hold. Your Supabase state will still show funded, but the PaymentIntent is no longer capturable, causing the release to fail. Fix: 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.
- **Allowing both parties to initiate release independently** — In a two-sided escrow, only one party should authorize release to prevent confusion. If both the buyer and seller can call the release function, it becomes unclear who approved the delivery. Fix: 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.

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

---

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