Skip to main content
RapidDev - Software Development Agency

How to Build Payment gateway integration with V0

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.

What you'll build

  • Unified checkout form with RadioGroup provider selection between Stripe and PayPal
  • Server-side payment intent creation for Stripe and PayPal order creation via API routes
  • Idempotent webhook handlers that prevent duplicate processing with unique constraint checks
  • Transaction history Table with provider Badge, amount, status Badge, and date columns
  • Refund management with AlertDialog confirmation and server-side Stripe Refunds API calls
  • Payment method management with saved cards and default selection
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate10 min read1-2 hoursV0 Premium or higherApril 2026RapidDev Engineering Team
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.

What you're building

Every SaaS and e-commerce app needs payments, but relying on a single provider creates risk. A unified payment gateway that routes to Stripe or PayPal behind a single checkout interface gives customers choice and the business resilience.

V0 generates the checkout form, payment status pages, and transaction dashboard from prompts. Stripe via the Vercel Marketplace provides auto-provisioned keys. Supabase stores payment records with idempotency tracking.

The architecture uses API routes for server-side payment creation (never expose secret keys to the client), webhook handlers for asynchronous payment confirmation, a unique constraint on provider_payment_id for idempotent processing, and Server Actions for refund initiation.

Final result

A multi-provider payment gateway with unified checkout, idempotent webhook processing, transaction history, and refund management.

Tech stack

V0AI Code Generator
Next.jsFull-Stack Framework
Tailwind CSSStyling
shadcn/uiComponent Library
SupabaseDatabase
StripePrimary Payment Provider

Prerequisites

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

Build steps

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.

prompt.txt
1// Paste this prompt into V0's AI chat:
2// Build a payment gateway integration. Create a Supabase schema:
3// 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())
4// 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)
5// 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)
6// The UNIQUE constraint on provider_payment_id prevents duplicate webhook processing.
7// 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.

app/api/payments/create-intent/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 { amount, currency, provider, user_id, metadata } = await req.json()
13
14 if (!amount || amount < 50) {
15 return NextResponse.json(
16 { error: 'Amount must be at least 50 cents' },
17 { status: 400 }
18 )
19 }
20
21 const { data: payment, error: dbError } = await supabase
22 .from('payments')
23 .insert({
24 user_id,
25 amount,
26 currency: currency || 'usd',
27 provider,
28 status: 'processing',
29 metadata: metadata || {},
30 })
31 .select('id')
32 .single()
33
34 if (dbError) {
35 return NextResponse.json({ error: dbError.message }, { status: 500 })
36 }
37
38 if (provider === 'stripe') {
39 const intent = await stripe.paymentIntents.create({
40 amount,
41 currency: currency || 'usd',
42 metadata: { payment_id: payment.id, user_id },
43 })
44
45 await supabase
46 .from('payments')
47 .update({ provider_payment_id: intent.id })
48 .eq('id', payment.id)
49
50 return NextResponse.json({
51 client_secret: intent.client_secret,
52 payment_id: payment.id,
53 })
54 }
55
56 if (provider === 'paypal') {
57 const auth = Buffer.from(
58 `${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_SECRET}`
59 ).toString('base64')
60
61 const tokenRes = await fetch(
62 'https://api-m.sandbox.paypal.com/v1/oauth2/token',
63 {
64 method: 'POST',
65 headers: {
66 Authorization: `Basic ${auth}`,
67 'Content-Type': 'application/x-www-form-urlencoded',
68 },
69 body: 'grant_type=client_credentials',
70 }
71 )
72 const { access_token } = await tokenRes.json()
73
74 const orderRes = await fetch(
75 'https://api-m.sandbox.paypal.com/v2/checkout/orders',
76 {
77 method: 'POST',
78 headers: {
79 Authorization: `Bearer ${access_token}`,
80 'Content-Type': 'application/json',
81 },
82 body: JSON.stringify({
83 intent: 'CAPTURE',
84 purchase_units: [{
85 amount: {
86 currency_code: (currency || 'usd').toUpperCase(),
87 value: (amount / 100).toFixed(2),
88 },
89 }],
90 }),
91 }
92 )
93 const order = await orderRes.json()
94
95 await supabase
96 .from('payments')
97 .update({ provider_payment_id: order.id })
98 .eq('id', payment.id)
99
100 return NextResponse.json({
101 order_id: order.id,
102 payment_id: payment.id,
103 })
104 }
105
106 return NextResponse.json({ error: 'Invalid provider' }, { status: 400 })
107}

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.

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 rawBody = 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 rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET!
19 )
20 } catch {
21 return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
22 }
23
24 if (event.type === 'payment_intent.succeeded') {
25 const intent = event.data.object as Stripe.PaymentIntent
26
27 const { data: existing } = await supabase
28 .from('payments')
29 .select('id, status')
30 .eq('provider_payment_id', intent.id)
31 .single()
32
33 if (existing?.status === 'succeeded') {
34 return NextResponse.json({ received: true, duplicate: true })
35 }
36
37 if (existing) {
38 await supabase
39 .from('payments')
40 .update({ status: 'succeeded' })
41 .eq('id', existing.id)
42 }
43 }
44
45 if (event.type === 'payment_intent.payment_failed') {
46 const intent = event.data.object as Stripe.PaymentIntent
47
48 await supabase
49 .from('payments')
50 .update({ status: 'failed' })
51 .eq('provider_payment_id', intent.id)
52 }
53
54 return NextResponse.json({ received: true })
55}

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.

prompt.txt
1// Paste this prompt into V0's AI chat:
2// Create payment pages:
3// 1. app/checkout/page.tsx — 'use client' checkout form:
4// - RadioGroup for provider selection: Stripe (credit card icon) or PayPal (PayPal icon)
5// - For Stripe: embed Stripe Elements CardElement for card input
6// - For PayPal: show PayPal button that redirects to PayPal checkout
7// - Amount display, currency Select, 'Pay Now' Button with loading state
8// - On submit: POST to /api/payments/create-intent, then confirm with Stripe.js or redirect to PayPal
9// 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.
10// - 'Refund' in DropdownMenu opens AlertDialog with reason Textarea and amount Input (partial refund support). Server Action calls Stripe Refunds API or PayPal Refund API.
11// 3. app/payments/[id]/page.tsx — payment detail Card: full metadata, timeline of status changes, refund history if any.
12// 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

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 rawBody = 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 rawBody,
19 sig,
20 process.env.STRIPE_WEBHOOK_SECRET!
21 )
22 } catch {
23 return NextResponse.json({ error: 'Invalid sig' }, { status: 400 })
24 }
25
26 if (event.type === 'payment_intent.succeeded') {
27 const intent = event.data.object as Stripe.PaymentIntent
28
29 const { data: payment } = await supabase
30 .from('payments')
31 .select('id, status')
32 .eq('provider_payment_id', intent.id)
33 .single()
34
35 if (payment?.status === 'succeeded') {
36 return NextResponse.json({ received: true, duplicate: true })
37 }
38
39 if (payment) {
40 await supabase
41 .from('payments')
42 .update({ status: 'succeeded' })
43 .eq('id', payment.id)
44 }
45 }
46
47 if (event.type === 'payment_intent.payment_failed') {
48 const intent = event.data.object as Stripe.PaymentIntent
49 await supabase
50 .from('payments')
51 .update({ status: 'failed' })
52 .eq('provider_payment_id', intent.id)
53 }
54
55 return NextResponse.json({ received: true })
56}

Customization ideas

Subscription billing support

Add recurring payment support with Stripe Billing for subscription plans alongside one-time payments through the same gateway.

Regional payment providers

Add support for regional payment methods like iDEAL (Netherlands), Bancontact (Belgium), or PIX (Brazil) through Stripe's payment method types.

Payment analytics dashboard

Build a dashboard showing revenue by provider, success rates, average transaction value, and refund rates with Recharts line and bar charts.

Retry failed payments

Add automatic retry logic for failed payments with exponential backoff, notifying users via email when a retry succeeds or when manual action is needed.

Common pitfalls

Pitfall: Processing webhook events without idempotency checks

How to avoid: 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.

Pitfall: Using request.json() for Stripe webhook body

How to avoid: Use request.text() to get the raw body string and pass it directly to stripe.webhooks.constructEvent().

Pitfall: Exposing STRIPE_SECRET_KEY or PAYPAL_SECRET with NEXT_PUBLIC_ prefix

How to avoid: 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

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a multi-provider payment gateway with Next.js App Router and Supabase. I need an API route at app/api/payments/create-intent/route.ts that accepts amount, currency, provider (stripe or paypal), and user_id. For Stripe, it should create a PaymentIntent and return the client_secret. For PayPal, it should create an Order via the REST API and return the order_id. Both should store the payment in Supabase with the provider_payment_id for idempotent webhook processing.

Build Prompt

Create a payment provider selector component. It shows two Card options side by side: Stripe (with credit card icon) and PayPal (with PayPal icon). Use RadioGroup for selection with each Card as a RadioGroupItem. The selected Card gets a blue ring border. Below the selector, show provider-specific content: Stripe shows a placeholder for Elements, PayPal shows a 'Continue to PayPal' Button. Include an amount display and currency Badge.

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.

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.