Voltar para Artigos
Front-end10 min de leitura

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.

12 de agosto de 2026

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

  1. Backend: cria um PaymentIntent no Stripe com o valor → recebe o clientSecret
  2. Frontend: inicializa o Stripe Elements com o clientSecret
  3. Usuário: preenche o formulário (dados vão direto para o Stripe, não para o seu servidor)
  4. Frontend: chama stripe.confirmCardPayment() → Stripe processa e retorna o resultado
  5. Backend: webhook do Stripe notifica que o pagamento foi confirmado → atualiza o banco de dados
bash
npm install @stripe/stripe-js @stripe/react-stripe-js
npm install stripe  # No backend

Backend: 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.

CreatePaymentIntentUseCase.ts
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.

StripeProvider.tsx
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>
  );
}
CheckoutForm.tsx
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

StripeWebhookController.ts
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.