Checkout Seguro no React: Tokenização com Stripe e UX de Formulário
Fluxo correto de pagamento: Stripe Elements no frontend (dados do cartão nunca tocam seu servidor), tokenização via PaymentIntent, máscara de input, validação de bandeira e tratamento de erros de pagamento no backend.
A etapa de checkout é onde os negócios são fechados ou perdidos. Um formulário de cartão confuso, sem máscara, com feedback de erro genérico ('Pagamento falhou') pode custar 30-70% de abandono no checkout — números reais documentados em estudos de conversão. Mas o problema de UX é secundário: o problema técnico mais crítico é onde os dados do cartão passam.
A abordagem correta — e a única que mantém você fora do escopo PCI DSS nível 1 — é usar o Stripe Elements: o formulário de cartão renderiza dentro de iframes do Stripe, os dados do cartão nunca tocam seu servidor, e você recebe apenas um paymentMethodId para confirmar o pagamento no backend.
Arquitetura: O Fluxo Correto
- Backend: cria um
PaymentIntentno Stripe com o valor → recebe oclientSecret - Frontend: inicializa o Stripe Elements com o
clientSecret - Usuário: preenche o formulário (dados vão direto para o Stripe, não para o seu servidor)
- Frontend: chama
stripe.confirmCardPayment()→ Stripe processa e retorna o resultado - Backend: webhook do Stripe notifica que o pagamento foi confirmado → atualiza o banco de dados
npm install @stripe/stripe-js @stripe/react-stripe-js
npm install stripe # No backendBackend: Criando o PaymentIntent
O PaymentIntent é o objeto central do Stripe: representa uma intenção de cobrar um valor de um cliente. Criar no backend (com a secret key) garante que o valor e a moeda não possam ser alterados pelo frontend. O clientSecret retornado é seguro para enviar ao navegador — ele autoriza apenas a confirmação daquele pagamento específico, não o acesso à conta Stripe.
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
apiVersion: '2024-06-20',
});
interface CreatePaymentIntentDTO {
orderId: string;
amountCents: number; // Stripe trabalha com centavos
currency: string; // 'brl', 'usd'
customerId?: string; // ID do customer no Stripe (para salvar cartão)
}
export class CreatePaymentIntentUseCase {
async execute(dto: CreatePaymentIntentDTO) {
const paymentIntent = await stripe.paymentIntents.create({
amount: dto.amountCents,
currency: dto.currency,
customer: dto.customerId,
// Metadados para rastrear o pedido no webhook
metadata: { orderId: dto.orderId },
// automatic_payment_methods permite Pix, boleto, etc.
automatic_payment_methods: { enabled: true },
});
return {
// clientSecret é enviado ao frontend — permite confirmar o pagamento
// Não é a secret key! É seguro enviar ao cliente.
clientSecret: paymentIntent.client_secret,
paymentIntentId: paymentIntent.id,
};
}
}Frontend: Stripe Elements
O PaymentElement do Stripe é um componente que renderiza dentro de um iframe isolado controlado pelo Stripe. O número do cartão, o CVV e a data de validade são capturados nesse iframe e enviados diretamente para os servidores do Stripe — o JavaScript da sua aplicação nunca tem acesso a esses dados. Isso elimina o escopo PCI DSS mais rigoroso, pois dados de cartão nunca transitam pela sua infraestrutura.
import { Elements } from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js';
// Inicializado fora do componente — evita recriar a cada render
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY as string);
interface StripeProviderProps {
clientSecret: string;
children: React.ReactNode;
}
export function StripeProvider({ clientSecret, children }: StripeProviderProps) {
return (
<Elements
stripe={stripePromise}
options={{
clientSecret,
appearance: {
theme: 'stripe',
variables: {
colorPrimary: '#6366f1', // Adapte à sua paleta de cores
borderRadius: '8px',
},
},
locale: 'pt-BR',
}}
>
{children}
</Elements>
);
}import { useState } from 'react';
import {
PaymentElement,
useStripe,
useElements,
} from '@stripe/react-stripe-js';
export function CheckoutForm({ orderId }: { orderId: string }) {
const stripe = useStripe();
const elements = useElements();
const [isProcessing, setIsProcessing] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!stripe || !elements) return; // Stripe ainda carregando
setIsProcessing(true);
setErrorMessage(null);
// Valida os campos antes de submeter
const { error: submitError } = await elements.submit();
if (submitError) {
setErrorMessage(submitError.message ?? 'Erro ao validar formulário.');
setIsProcessing(false);
return;
}
// Confirma o pagamento — dados do cartão vão direto para o Stripe
// Seu servidor nunca vê o número do cartão
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/checkout/success?order=${orderId}`,
},
});
if (error) {
// Erros específicos do Stripe para UX melhor
if (error.type === 'card_error' || error.type === 'validation_error') {
setErrorMessage(error.message ?? 'Pagamento recusado. Verifique os dados.');
} else {
setErrorMessage('Erro inesperado. Tente novamente.');
}
}
// Se não houve erro, o Stripe redireciona para return_url
setIsProcessing(false);
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* PaymentElement renderiza dentro de iframe do Stripe
Os dados do cartão nunca chegam ao seu JavaScript */}
<PaymentElement
options={{
layout: 'tabs', // Separa cartão, Pix, etc. em abas
}}
/>
{errorMessage && (
<p className="text-red-500 text-sm" role="alert">
{errorMessage}
</p>
)}
<button
type="submit"
disabled={!stripe || isProcessing}
className="w-full bg-indigo-600 text-white py-3 px-6 rounded-lg
disabled:opacity-50 disabled:cursor-not-allowed
hover:bg-indigo-700 transition-colors"
>
{isProcessing ? 'Processando...' : 'Confirmar Pagamento'}
</button>
</form>
);
}Backend: Webhook para Confirmar o Pagamento
import type { Request, Response } from 'express';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
apiVersion: '2024-06-20',
});
export class StripeWebhookController {
// IMPORTANTE: esta rota deve receber o body como raw Buffer (não JSON parsed)
// Adicione antes das outras rotas: app.use('/webhooks/stripe', express.raw({ type: 'application/json' }))
async handle(req: Request, res: Response): Promise<void> {
const sig = req.headers['stripe-signature'] as string;
let event: Stripe.Event;
try {
// Verifica a assinatura do webhook — protege contra requisições falsas
event = stripe.webhooks.constructEvent(
req.body as Buffer,
sig,
process.env.STRIPE_WEBHOOK_SECRET as string
);
} catch (err) {
res.status(400).json({ error: 'Webhook signature verification failed' });
return;
}
switch (event.type) {
case 'payment_intent.succeeded': {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
const orderId = paymentIntent.metadata.orderId;
// Atualiza o pedido no banco de dados
await this.ordersRepo.markAsPaid(orderId, paymentIntent.id);
break;
}
case 'payment_intent.payment_failed': {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
await this.ordersRepo.markAsFailed(paymentIntent.metadata.orderId);
break;
}
}
// Sempre retorne 200 para o Stripe — caso contrário ele reenvía o webhook
res.json({ received: true });
}
}Nunca confie apenas na resposta do frontend para marcar um pedido como pago. O usuário pode fechar o browser antes do redirecionamento, ou a rede pode falhar. A fonte da verdade é sempre o webhook do Stripe — configure-o e implemente idempotência (verifique se o pedido já foi marcado como pago antes de processar novamente).
Conclusão
O Stripe Elements resolve dois problemas ao mesmo tempo: UX premium com formulário validado, máscara automática e suporte a múltiplos métodos de pagamento (cartão, Pix, boleto) — e conformidade PCI DSS, pois os dados do cartão nunca tocam seu servidor. O webhook é a peça crítica de confiabilidade: garante que pedidos sejam marcados como pagos mesmo quando a conexão do usuário cai após o pagamento.