# How to Build Payment gateway integration with V0

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

## TL;DR

Build a multi-provider payment gateway integration with V0 using Next.js, Supabase, Stripe, and shadcn/ui. Features a unified checkout supporting Stripe and PayPal, idempotent webhook handlers to prevent duplicate processing, transaction history, and refund management — all in about 1-2 hours.

## Before you start

- A V0 account (Premium or higher recommended)
- A Supabase project (free tier works — connect via V0's Connect panel)
- A Stripe account (test mode works — connect via Vercel Marketplace)
- A PayPal Developer account (sandbox works for testing)
- Understanding of payment flows (intents, webhooks, refunds)

## Step-by-step guide

### 1. Set up the database schema for payments and refunds

Create the Supabase schema for tracking payments across multiple providers, saved payment methods, and refund records with idempotency protection.

```
// Paste this prompt into V0's AI chat:
// Build a payment gateway integration. Create a Supabase schema:
// 1. payments: id (uuid PK), user_id (uuid FK to auth.users), amount (int — in cents), currency (text DEFAULT 'usd'), provider (text CHECK IN 'stripe','paypal'), provider_payment_id (text UNIQUE), status (text DEFAULT 'pending' CHECK IN 'pending','processing','succeeded','failed','refunded'), metadata (jsonb DEFAULT '{}'), created_at (timestamptz DEFAULT now())
// 2. payment_methods: id (uuid PK), user_id (uuid FK to auth.users), provider (text), provider_method_id (text), last_four (text), is_default (boolean DEFAULT false), created_at (timestamptz)
// 3. refunds: id (uuid PK), payment_id (uuid FK to payments), amount (int), reason (text), status (text DEFAULT 'pending' CHECK IN 'pending','succeeded','failed'), provider_refund_id (text), created_at (timestamptz)
// The UNIQUE constraint on provider_payment_id prevents duplicate webhook processing.
// Add RLS so users see only their own payments. Generate SQL and types.
```

> Pro tip: Use V0's Stripe integration via Vercel Marketplace for auto-provisioned STRIPE_SECRET_KEY and NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY. Only the publishable key gets the NEXT_PUBLIC_ prefix.

**Expected result:** All tables created with the unique constraint on provider_payment_id for idempotent webhook processing and RLS policies for user-scoped access.

### 2. Build the unified checkout form and payment creation

Create the checkout page with provider selection and the server-side payment creation routes for both Stripe and PayPal.

```
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 { amount, currency, provider, user_id, metadata } = await req.json()

  if (!amount || amount < 50) {
    return NextResponse.json(
      { error: 'Amount must be at least 50 cents' },
      { status: 400 }
    )
  }

  const { data: payment, error: dbError } = await supabase
    .from('payments')
    .insert({
      user_id,
      amount,
      currency: currency || 'usd',
      provider,
      status: 'processing',
      metadata: metadata || {},
    })
    .select('id')
    .single()

  if (dbError) {
    return NextResponse.json({ error: dbError.message }, { status: 500 })
  }

  if (provider === 'stripe') {
    const intent = await stripe.paymentIntents.create({
      amount,
      currency: currency || 'usd',
      metadata: { payment_id: payment.id, user_id },
    })

    await supabase
      .from('payments')
      .update({ provider_payment_id: intent.id })
      .eq('id', payment.id)

    return NextResponse.json({
      client_secret: intent.client_secret,
      payment_id: payment.id,
    })
  }

  if (provider === 'paypal') {
    const auth = Buffer.from(
      `${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_SECRET}`
    ).toString('base64')

    const tokenRes = await fetch(
      'https://api-m.sandbox.paypal.com/v1/oauth2/token',
      {
        method: 'POST',
        headers: {
          Authorization: `Basic ${auth}`,
          'Content-Type': 'application/x-www-form-urlencoded',
        },
        body: 'grant_type=client_credentials',
      }
    )
    const { access_token } = await tokenRes.json()

    const orderRes = await fetch(
      'https://api-m.sandbox.paypal.com/v2/checkout/orders',
      {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${access_token}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          intent: 'CAPTURE',
          purchase_units: [{
            amount: {
              currency_code: (currency || 'usd').toUpperCase(),
              value: (amount / 100).toFixed(2),
            },
          }],
        }),
      }
    )
    const order = await orderRes.json()

    await supabase
      .from('payments')
      .update({ provider_payment_id: order.id })
      .eq('id', payment.id)

    return NextResponse.json({
      order_id: order.id,
      payment_id: payment.id,
    })
  }

  return NextResponse.json({ error: 'Invalid provider' }, { status: 400 })
}
```

**Expected result:** The API route creates either a Stripe PaymentIntent or PayPal Order and stores the provider payment ID in the database for idempotent tracking.

### 3. Build the webhook handlers with idempotency

Create webhook handlers for both Stripe and PayPal that confirm payments idempotently by checking the unique provider_payment_id before processing.

```
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 rawBody = await req.text()
  const sig = req.headers.get('stripe-signature')!

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

  if (event.type === 'payment_intent.succeeded') {
    const intent = event.data.object as Stripe.PaymentIntent

    const { data: existing } = await supabase
      .from('payments')
      .select('id, status')
      .eq('provider_payment_id', intent.id)
      .single()

    if (existing?.status === 'succeeded') {
      return NextResponse.json({ received: true, duplicate: true })
    }

    if (existing) {
      await supabase
        .from('payments')
        .update({ status: 'succeeded' })
        .eq('id', existing.id)
    }
  }

  if (event.type === 'payment_intent.payment_failed') {
    const intent = event.data.object as Stripe.PaymentIntent

    await supabase
      .from('payments')
      .update({ status: 'failed' })
      .eq('provider_payment_id', intent.id)
  }

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

**Expected result:** The Stripe webhook verifies signatures with the raw body, checks for duplicate processing, and updates payment status idempotently.

### 4. Build the checkout UI, transaction history, and deploy

Create the checkout page with provider selection, the transaction dashboard with refund support, and deploy.

```
// Paste this prompt into V0's AI chat:
// Create payment pages:
// 1. app/checkout/page.tsx — 'use client' checkout form:
//    - RadioGroup for provider selection: Stripe (credit card icon) or PayPal (PayPal icon)
//    - For Stripe: embed Stripe Elements CardElement for card input
//    - For PayPal: show PayPal button that redirects to PayPal checkout
//    - Amount display, currency Select, 'Pay Now' Button with loading state
//    - On submit: POST to /api/payments/create-intent, then confirm with Stripe.js or redirect to PayPal
// 2. app/payments/page.tsx — transaction history Table: date, amount, provider Badge (stripe=purple, paypal=blue), status Badge (succeeded=green, pending=yellow, failed=red, refunded=gray), actions DropdownMenu.
//    - 'Refund' in DropdownMenu opens AlertDialog with reason Textarea and amount Input (partial refund support). Server Action calls Stripe Refunds API or PayPal Refund API.
// 3. app/payments/[id]/page.tsx — payment detail Card: full metadata, timeline of status changes, refund history if any.
// Use shadcn/ui RadioGroup, Card, Badge, Table, AlertDialog, DropdownMenu, Input, Button, Separator.
```

> Pro tip: Enable Stripe via Vercel Marketplace for auto-provisioned keys. Set PAYPAL_CLIENT_ID and PAYPAL_SECRET in Vars without NEXT_PUBLIC_ prefix since PayPal calls are server-only. Set the webhook tolerance to 300 seconds for serverless cold starts.

**Expected result:** The checkout page supports both Stripe and PayPal. Transaction history shows all payments with refund support. The app is deployed.

## Complete code example

File: `app/api/webhooks/stripe/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 rawBody = await req.text()
  const sig = req.headers.get('stripe-signature')!

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

  if (event.type === 'payment_intent.succeeded') {
    const intent = event.data.object as Stripe.PaymentIntent

    const { data: payment } = await supabase
      .from('payments')
      .select('id, status')
      .eq('provider_payment_id', intent.id)
      .single()

    if (payment?.status === 'succeeded') {
      return NextResponse.json({ received: true, duplicate: true })
    }

    if (payment) {
      await supabase
        .from('payments')
        .update({ status: 'succeeded' })
        .eq('id', payment.id)
    }
  }

  if (event.type === 'payment_intent.payment_failed') {
    const intent = event.data.object as Stripe.PaymentIntent
    await supabase
      .from('payments')
      .update({ status: 'failed' })
      .eq('provider_payment_id', intent.id)
  }

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

## Common mistakes

- **Processing webhook events without idempotency checks** — Stripe and PayPal can deliver the same webhook event multiple times. Without idempotency, you may credit a user twice, ship an order twice, or create duplicate records. Fix: Add a UNIQUE constraint on provider_payment_id. Before processing a webhook event, check if the payment already has the expected status. If it does, return 200 without processing again.
- **Using request.json() for Stripe webhook body** — Stripe signature verification requires the raw request body. Parsing it as JSON changes the format and the signature check fails every time. Fix: Use request.text() to get the raw body string and pass it directly to stripe.webhooks.constructEvent().
- **Exposing STRIPE_SECRET_KEY or PAYPAL_SECRET with NEXT_PUBLIC_ prefix** — Secret keys with NEXT_PUBLIC_ prefix are included in the client JavaScript bundle. Anyone can extract them from the browser and make unauthorized charges or refunds. Fix: Only use the NEXT_PUBLIC_ prefix for publishable keys (NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY). Keep STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, PAYPAL_CLIENT_ID, and PAYPAL_SECRET in Vars without the prefix.

## Best practices

- Add a UNIQUE constraint on provider_payment_id and check payment status before processing webhooks for idempotency
- Always use request.text() for Stripe webhook signature verification — never request.json()
- Only NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY gets the NEXT_PUBLIC_ prefix — all other keys are server-only
- Store the payment record in Supabase before calling the payment provider for audit trail continuity
- Use V0's Design Mode (Option+D) to adjust checkout Card layout and provider RadioGroup styling for free
- Set Stripe webhook tolerance to 300 seconds to account for serverless cold starts on Vercel
- Support partial refunds by accepting a refund amount less than or equal to the original payment amount
- Register webhook endpoints with your production Vercel URL — never use localhost for webhook registration

## Frequently asked questions

### Why use multiple payment providers?

Relying on a single provider creates risk. If Stripe has an outage, you lose all revenue during that time. Supporting both Stripe and PayPal gives customers their preferred payment method and provides business continuity. PayPal also has higher adoption in some markets.

### How does idempotent webhook processing work?

The payments table has a UNIQUE constraint on provider_payment_id. Before processing a webhook event, the handler checks if the payment already has status 'succeeded'. If it does, it returns 200 without processing again. This prevents duplicate credits even if Stripe sends the same event multiple times.

### Can I add more payment providers later?

Yes. The architecture is provider-agnostic — the payments table stores a provider field and provider_payment_id. Add a new provider by creating an API route for payment creation and a webhook handler for confirmation, following the same pattern as Stripe and PayPal.

### How do refunds work?

The refund Server Action checks the payment provider, calls the appropriate refund API (Stripe Refunds API or PayPal Refund API), and stores the result in the refunds table. Partial refunds are supported by accepting an amount less than the original payment.

### Do I need a paid V0 plan?

Premium ($20/month) is recommended. The payment gateway has multiple pages and two provider integrations that benefit from extra credits, though a minimal version could be built on the free tier.

### How do I deploy the payment gateway?

Click Share in V0, then Publish to Production. Set STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, PAYPAL_CLIENT_ID, and PAYPAL_SECRET in the Vars tab. Register webhook URLs in both Stripe and PayPal dashboards pointing to the deployed URL.

### Can RapidDev help build a custom payment system?

Yes. RapidDev has built over 600 apps including payment platforms with multi-provider routing, subscription management, and marketplace split payments. Book a free consultation to discuss your payment integration needs.

---

Source: https://www.rapidevelopers.com/how-to-build-v0/payment-gateway-integration
© RapidDev — https://www.rapidevelopers.com/how-to-build-v0/payment-gateway-integration
