Next.jsReactServer ActionsTypeScript
Pagamentos em USDT no Next.js App Router: Server Actions + Webhooks
Guia completo de como integrar pagamentos cripto no Next.js 14+ usando Server Actions, Route Handlers e React Server Components.
Equipe Tech Infi Pulse
Autor
15 de janeiro de 2025
9 min read
Next.js 14+ mudou tudo com App Router. Veja como integrar pagamentos em USDT usando as novas features.
Stack
- Next.js 14+ (App Router)
- Server Actions (form handling)
- Route Handlers (webhooks)
- React Server Components
- TypeScript
- Pulse SDK
Setup
npx create-next-app@latest my-store
cd my-store
npm install @infi/sdk zod
// lib/pulse.ts
import { Pulse } from '@infi/sdk'
export const pulse = new Pulse({
apiKey: process.env.PULSE_API_KEY!,
environment: process.env.NODE_ENV === 'production' ? 'production' : 'test'
})
Exemplo 1: Checkout Simples
1. Página de Produto (Server Component)
// app/products/[id]/page.tsx
import { CheckoutForm } from './checkout-form'
async function getProduct(id: string) {
// Fetch do seu DB/API
return {
id,
name: 'Curso Full Stack',
price: 299.90,
description: 'Aprenda Next.js, TypeScript, PostgreSQL e Deploy'
}
}
export default async function ProductPage({
params
}: {
params: { id: string }
}) {
const product = await getProduct(params.id)
return (
<div className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-4">{product.name}</h1>
<p className="text-gray-600 mb-6">{product.description}</p>
<p className="text-2xl font-bold mb-8">R$ {product.price}</p>
<CheckoutForm product={product} />
</div>
)
}
2. Form com Server Action
// app/products/[id]/checkout-form.tsx
'use client'
import { useFormStatus } from 'react-dom'
import { createPayment } from './actions'
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button
type="submit"
disabled={pending}
className="w-full bg-purple-600 text-white py-3 rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{pending ? 'Criando pagamento...' : 'Pagar com PIX'}
</button>
)
}
export function CheckoutForm({ product }: { product: Product }) {
return (
<form action={createPayment} className="space-y-4">
<input type="hidden" name="productId" value={product.id} />
<input type="hidden" name="amount" value={product.price} />
<div>
<label className="block text-sm font-medium mb-2">Email</label>
<input
type="email"
name="email"
required
className="w-full border rounded-lg p-3"
placeholder="seu@email.com"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Nome completo</label>
<input
type="text"
name="name"
required
className="w-full border rounded-lg p-3"
/>
</div>
<SubmitButton />
</form>
)
}
3. Server Action
// app/products/[id]/actions.ts
'use server'
import { pulse } from '@/lib/pulse'
import { redirect } from 'next/navigation'
import { z } from 'zod'
const checkoutSchema = z.object({
productId: z.string(),
amount: z.coerce.number().positive(),
email: z.string().email(),
name: z.string().min(3)
})
export async function createPayment(formData: FormData) {
// Validar input
const data = checkoutSchema.parse({
productId: formData.get('productId'),
amount: formData.get('amount'),
email: formData.get('email'),
name: formData.get('name')
})
// Criar pagamento
const payment = await pulse.payments.create({
amount: data.amount,
currency: 'BRL',
description: `Produto ${data.productId}`,
metadata: {
productId: data.productId,
customerEmail: data.email,
customerName: data.name
}
})
// Salvar no DB (opcional)
await db.payments.create({
id: payment.id,
productId: data.productId,
customerEmail: data.email,
status: 'pending',
amount: payment.amount
})
// Redirecionar para página de pagamento
redirect(`/checkout/${payment.id}`)
}
4. Página de Checkout
// app/checkout/[id]/page.tsx
import { pulse } from '@/lib/pulse'
import { QRCode } from './qr-code'
import { PaymentStatus } from './payment-status'
export default async function CheckoutPage({
params
}: {
params: { id: string }
}) {
const payment = await pulse.payments.retrieve(params.id)
if (payment.status === 'completed') {
return <SuccessMessage payment={payment} />
}
return (
<div className="max-w-md mx-auto p-8">
<h1 className="text-2xl font-bold mb-4">Finalizar Pagamento</h1>
<div className="bg-gray-100 rounded-lg p-6 mb-6">
<p className="text-sm text-gray-600 mb-2">Valor</p>
<p className="text-3xl font-bold">R$ {payment.amount}</p>
</div>
<div className="mb-6">
<p className="text-sm text-gray-600 mb-4">
Escaneie o QR Code com app do banco:
</p>
<QRCode value={payment.qrCode} />
</div>
<div className="border-t pt-6">
<p className="text-sm text-gray-600 mb-2">
Ou copie o código PIX:
</p>
<CopyButton text={payment.qrCode} />
</div>
<PaymentStatus paymentId={payment.id} />
</div>
)
}
5. Status em Tempo Real
// app/checkout/[id]/payment-status.tsx
'use client'
import { useEffect, useState } from 'react'
export function PaymentStatus({ paymentId }: { paymentId: string }) {
const [status, setStatus] = useState<'pending' | 'processing' | 'completed'>('pending')
useEffect(() => {
// Poll status a cada 3 segundos
const interval = setInterval(async () => {
const res = await fetch(`/api/payments/${paymentId}/status`)
const data = await res.json()
setStatus(data.status)
if (data.status === 'completed') {
clearInterval(interval)
// Redirecionar para success page
window.location.href = `/success?payment=${paymentId}`
}
}, 3000)
return () => clearInterval(interval)
}, [paymentId])
return (
<div className="mt-6 p-4 bg-yellow-50 rounded-lg">
{status === 'pending' && (
<>
<p className="text-sm font-medium">⏳ Aguardando pagamento...</p>
<p className="text-xs text-gray-600 mt-1">
Não feche esta página
</p>
</>
)}
{status === 'processing' && (
<>
<p className="text-sm font-medium">⚡ PIX confirmado!</p>
<p className="text-xs text-gray-600 mt-1">
Processando conversão para USDT...
</p>
</>
)}
</div>
)
}
6. Route Handler para Status
// app/api/payments/[id]/status/route.ts
import { pulse } from '@/lib/pulse'
import { NextResponse } from 'next/server'
export async function GET(
req: Request,
{ params }: { params: { id: string } }
) {
const payment = await pulse.payments.retrieve(params.id)
return NextResponse.json({
status: payment.status
})
}
Exemplo 2: Webhooks
1. Route Handler
// app/api/webhooks/pulse/route.ts
import { pulse } from '@/lib/pulse'
import { headers } from 'next/headers'
import { NextResponse } from 'next/server'
import crypto from 'crypto'
export async function POST(req: Request) {
const body = await req.text()
const signature = headers().get('pulse-signature')
// Validar signature
const expectedSignature = crypto
.createHmac('sha256', process.env.PULSE_WEBHOOK_SECRET!)
.update(body)
.digest('hex')
if (signature !== expectedSignature) {
return new NextResponse('Invalid signature', { status: 401 })
}
const event = JSON.parse(body)
// Processar evento
await handleWebhook(event)
return new NextResponse('OK', { status: 200 })
}
async function handleWebhook(event: WebhookEvent) {
switch (event.type) {
case 'payment.completed':
await fulfillOrder(event.data)
break
case 'payment.failed':
await handleFailedPayment(event.data)
break
}
}
async function fulfillOrder(payment: Payment) {
// 1. Atualizar DB
await db.payments.update(payment.id, {
status: 'completed',
completedAt: new Date()
})
// 2. Liberar produto
const product = await db.products.findById(payment.metadata.productId)
if (product.type === 'digital') {
// Enviar email com link de download
await sendDownloadEmail({
to: payment.metadata.customerEmail,
downloadUrl: product.downloadUrl
})
} else if (product.type === 'course') {
// Dar acesso ao curso
await db.courseAccess.create({
userId: payment.metadata.userId,
courseId: product.id,
grantedAt: new Date()
})
}
// 3. Email de confirmação
await sendConfirmationEmail({
to: payment.metadata.customerEmail,
paymentId: payment.id,
amount: payment.amount
})
// 4. Analytics
await track('Purchase Completed', {
paymentId: payment.id,
amount: payment.amount,
amountUSDT: payment.amountUSDT
})
}
2. Testar Localmente
# Terminal 1: Dev server
npm run dev
# Terminal 2: ngrok
ngrok http 3000
# Dashboard Pulse > Webhooks
# URL: https://abc123.ngrok.io/api/webhooks/pulse
Exemplo 3: SaaS Subscription
1. Server Action para Criar Assinatura
// app/pricing/actions.ts
'use server'
import { pulse } from '@/lib/pulse'
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'
export async function subscribeAction(planId: string) {
const user = await auth.getUser()
if (!user) {
redirect('/login')
}
const plan = await db.plans.findById(planId)
// Criar pagamento mensal
const payment = await pulse.payments.create({
amount: plan.price,
currency: 'BRL',
description: `Assinatura ${plan.name} - ${getCurrentMonth()}`,
metadata: {
userId: user.id,
planId: plan.id,
subscriptionType: 'monthly'
}
})
// Criar subscription no DB
await db.subscriptions.create({
userId: user.id,
planId: plan.id,
status: 'pending',
paymentId: payment.id,
nextBillingDate: getNextMonth()
})
redirect(`/checkout/${payment.id}`)
}
2. Cron para Cobranças Recorrentes
// app/api/cron/billing/route.ts
import { pulse } from '@/lib/pulse'
import { NextResponse } from 'next/server'
export async function GET(req: Request) {
// Verificar Vercel Cron secret
const authHeader = req.headers.get('authorization')
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return new NextResponse('Unauthorized', { status: 401 })
}
// Buscar subscriptions que precisam ser cobradas hoje
const dueSubscriptions = await db.subscriptions.findWhere({
status: 'active',
nextBillingDate: { lte: new Date() }
})
for (const subscription of dueSubscriptions) {
const user = await db.users.findById(subscription.userId)
const plan = await db.plans.findById(subscription.planId)
// Criar novo pagamento
const payment = await pulse.payments.create({
amount: plan.price,
currency: 'BRL',
description: `Renovação ${plan.name} - ${getCurrentMonth()}`,
metadata: {
userId: user.id,
subscriptionId: subscription.id,
planId: plan.id
}
})
// Enviar email
await sendEmail({
to: user.email,
subject: 'Renovação da Assinatura',
template: 'subscription-renewal',
data: {
planName: plan.name,
amount: plan.price,
paymentUrl: payment.paymentUrl
}
})
// Atualizar subscription
await db.subscriptions.update(subscription.id, {
nextBillingDate: getNextMonth(),
lastPaymentId: payment.id
})
}
return NextResponse.json({ processed: dueSubscriptions.length })
}
3. Vercel Cron Config
// vercel.json
{
"crons": [
{
"path": "/api/cron/billing",
"schedule": "0 9 * * *"
}
]
}
Exemplo 4: Marketplace Multi-Vendor
1. Split Payment
// app/products/[id]/actions.ts
'use server'
export async function checkoutAction(formData: FormData) {
const productId = formData.get('productId') as string
const product = await db.products.findById(productId)
const vendor = await db.users.findById(product.vendorId)
// Criar pagamento
const payment = await pulse.payments.create({
amount: product.price,
currency: 'BRL',
description: product.name,
metadata: {
productId: product.id,
vendorId: vendor.id,
platformFee: calculateFee(product.price) // 10%
}
})
redirect(`/checkout/${payment.id}`)
}
2. Webhook Split Distribution
async function fulfillMarketplaceOrder(payment: Payment) {
const platformFee = parseFloat(payment.metadata.platformFee)
const vendorAmount = payment.amountUSDT - (payment.amountUSDT * 0.10)
// 1. Registrar split no DB
await db.paymentSplits.create({
paymentId: payment.id,
vendorId: payment.metadata.vendorId,
vendorAmount,
platformFee,
totalAmount: payment.amountUSDT
})
// 2. Enviar USDT para vendor (via sua lógica de payout)
await queuePayout({
vendorId: payment.metadata.vendorId,
amount: vendorAmount,
currency: 'USDT'
})
// 3. Notificar vendor
await sendVendorEmail({
vendorId: payment.metadata.vendorId,
subject: 'Nova venda!',
amount: vendorAmount
})
}
Performance Tips
1. Cache Payment Status
import { unstable_cache } from 'next/cache'
const getPayment = unstable_cache(
async (id: string) => {
return await pulse.payments.retrieve(id)
},
['payment'],
{
revalidate: 10, // Cache por 10 segundos
tags: [`payment-${id}`]
}
)
2. Revalidate no Webhook
import { revalidateTag } from 'next/cache'
async function handleWebhook(event: WebhookEvent) {
if (event.type === 'payment.completed') {
// Invalidar cache desse payment
revalidateTag(`payment-${event.data.id}`)
}
}
3. Streaming UI
import { Suspense } from 'react'
export default function CheckoutPage({ params }: { params: { id: string } }) {
return (
<div>
<Suspense fallback={<PaymentSkeleton />}>
<PaymentDetails paymentId={params.id} />
</Suspense>
</div>
)
}
async function PaymentDetails({ paymentId }: { paymentId: string }) {
const payment = await pulse.payments.retrieve(paymentId)
return <PaymentCard payment={payment} />
}
Type Safety
// types/payment.ts
import { z } from 'zod'
export const PaymentMetadataSchema = z.object({
productId: z.string(),
userId: z.string().optional(),
customerEmail: z.string().email(),
subscriptionId: z.string().optional()
})
export type PaymentMetadata = z.infer<typeof PaymentMetadataSchema>
// Uso em Server Action
export async function createPayment(formData: FormData) {
const metadata = PaymentMetadataSchema.parse({
productId: formData.get('productId'),
customerEmail: formData.get('email')
})
const payment = await pulse.payments.create({
// TypeScript sabe que metadata tem estrutura correta
metadata
})
}
Deploy na Vercel
1. Environment Variables
# .env.local
PULSE_API_KEY=live_xxx
PULSE_WEBHOOK_SECRET=whsec_xxx
DATABASE_URL=postgresql://...
2. Vercel Dashboard
Settings > Environment Variables
└─ Adicionar todas as variáveis
├─ PULSE_API_KEY
├─ PULSE_WEBHOOK_SECRET
└─ DATABASE_URL
3. Deploy
git push origin main
# Vercel auto-deploy
# Atualizar webhook URL no dashboard Pulse
https://seu-app.vercel.app/api/webhooks/pulse
Conclusão
Next.js App Router + Pulse SDK = Stack perfeita para aceitar pagamentos em cripto:
- Server Actions: Forms sem API routes
- RSC: Zero JS no cliente para páginas estáticas
- Route Handlers: Webhooks simples
- TypeScript: Type safety end-to-end
- Vercel: Deploy sem config
Código completo: github.com/pulse/examples/nextjs
Docs Next.js: docs.pulse.infinitum.com/frameworks/nextjs