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
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
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.
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.
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.
1import { NextRequest, NextResponse } from 'next/server'2import Stripe from 'stripe'3import { createClient } from '@supabase/supabase-js'45const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)6const supabase = createClient(7 process.env.SUPABASE_URL!,8 process.env.SUPABASE_SERVICE_ROLE_KEY!9)1011export async function POST(req: NextRequest) {12 const { amount, currency, provider, user_id, metadata } = await req.json()1314 if (!amount || amount < 50) {15 return NextResponse.json(16 { error: 'Amount must be at least 50 cents' },17 { status: 400 }18 )19 }2021 const { data: payment, error: dbError } = await supabase22 .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()3334 if (dbError) {35 return NextResponse.json({ error: dbError.message }, { status: 500 })36 }3738 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 })4445 await supabase46 .from('payments')47 .update({ provider_payment_id: intent.id })48 .eq('id', payment.id)4950 return NextResponse.json({51 client_secret: intent.client_secret,52 payment_id: payment.id,53 })54 }5556 if (provider === 'paypal') {57 const auth = Buffer.from(58 `${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_SECRET}`59 ).toString('base64')6061 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()7374 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()9495 await supabase96 .from('payments')97 .update({ provider_payment_id: order.id })98 .eq('id', payment.id)99100 return NextResponse.json({101 order_id: order.id,102 payment_id: payment.id,103 })104 }105106 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.
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.
1import { NextRequest, NextResponse } from 'next/server'2import Stripe from 'stripe'3import { createClient } from '@supabase/supabase-js'45const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)6const supabase = createClient(7 process.env.SUPABASE_URL!,8 process.env.SUPABASE_SERVICE_ROLE_KEY!9)1011export async function POST(req: NextRequest) {12 const rawBody = await req.text()13 const sig = req.headers.get('stripe-signature')!1415 let event: Stripe.Event16 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 }2324 if (event.type === 'payment_intent.succeeded') {25 const intent = event.data.object as Stripe.PaymentIntent2627 const { data: existing } = await supabase28 .from('payments')29 .select('id, status')30 .eq('provider_payment_id', intent.id)31 .single()3233 if (existing?.status === 'succeeded') {34 return NextResponse.json({ received: true, duplicate: true })35 }3637 if (existing) {38 await supabase39 .from('payments')40 .update({ status: 'succeeded' })41 .eq('id', existing.id)42 }43 }4445 if (event.type === 'payment_intent.payment_failed') {46 const intent = event.data.object as Stripe.PaymentIntent4748 await supabase49 .from('payments')50 .update({ status: 'failed' })51 .eq('provider_payment_id', intent.id)52 }5354 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.
Build the checkout UI, transaction history, and deploy
Create the checkout page with provider selection, the transaction dashboard with refund support, and deploy.
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 input6// - For PayPal: show PayPal button that redirects to PayPal checkout7// - Amount display, currency Select, 'Pay Now' Button with loading state8// - On submit: POST to /api/payments/create-intent, then confirm with Stripe.js or redirect to PayPal9// 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
1import { NextRequest, NextResponse } from 'next/server'2import Stripe from 'stripe'3import { createClient } from '@supabase/supabase-js'45const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)6const supabase = createClient(7 process.env.SUPABASE_URL!,8 process.env.SUPABASE_SERVICE_ROLE_KEY!9)1011export async function POST(req: NextRequest) {12 const rawBody = await req.text()13 const sig = req.headers.get('stripe-signature')!1415 let event: Stripe.Event16 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 }2526 if (event.type === 'payment_intent.succeeded') {27 const intent = event.data.object as Stripe.PaymentIntent2829 const { data: payment } = await supabase30 .from('payments')31 .select('id, status')32 .eq('provider_payment_id', intent.id)33 .single()3435 if (payment?.status === 'succeeded') {36 return NextResponse.json({ received: true, duplicate: true })37 }3839 if (payment) {40 await supabase41 .from('payments')42 .update({ status: 'succeeded' })43 .eq('id', payment.id)44 }45 }4647 if (event.type === 'payment_intent.payment_failed') {48 const intent = event.data.object as Stripe.PaymentIntent49 await supabase50 .from('payments')51 .update({ status: 'failed' })52 .eq('provider_payment_id', intent.id)53 }5455 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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation