Cambiar tema

Next.js e-commerce en la práctica: guía completa de carrito y pago con Stripe

Easton editorial illustration: build pipeline conveyor

Revisión número 27 del Webhook de Stripe: el usuario dice que ya le cobraron, pero el pedido sigue en «pendiente de pago». En test todo iba bien; ¿por qué en producción se rompe?

La primera vez que hice un proyecto e-commerce con Next.js, pensé que lo más difícil sería la interfaz y los estilos. Al meterme de lleno, el carrito, el pago y el flujo de pedidos escondían trampas en cada paso. Redux me parecía pesado, Context API lento, la documentación de Stripe densa y los Webhooks un misterio.

Lo peor: la mayoría de tutoriales cubren solo el carrito o solo el pago; pocos unen el flujo completo. Seguro te preguntas: «¿qué librería de estado elijo?», «¿para qué sirve el Webhook?», «¿cómo sincronizo el estado del pedido con el pago?».

En este artículo quiero aplanar esas trampas de una vez. Usaremos Zustand para el carrito (ligero y práctico), Stripe para el pago (estándar internacional) y Webhooks para los pedidos (la única vía fiable). Cada paso incluye código completo, listo para copiar y ejecutar.

¿Recuerdas la satisfacción de ver algo funcionar de verdad? Si sigues esta guía, la tendrás.

¿Por qué Zustand para gestionar el carrito?

Elección de gestión de estado en 2025: deja de darle vueltas

Elegir librería de estado puede volverte loco. La documentación de Redux es un tocho, Context API aparece en todos los debates de rendimiento y Zustand parece demasiado nuevo. Yo también oscilé entre las tres hasta ver unos datos que me convencieron.

Desde 2021, Zustand es una de las librerías de estado de React que más rápido crece en estrellas. En 2025, su enfoque está probado: funcional, basado en hooks, API clara. Y su curva de aprendizaje es suave, sin la montaña de conceptos de Redux (actions, reducers, dispatch, middleware…).

¿Cómo elegir? Te lo resumo:

  • Proyectos pequeños (<10 páginas): Context API basta; no te compliques
  • Proyectos medianos (10-50 páginas): Zustand, ligero y suficiente
  • Proyectos grandes (50+ páginas, varios equipos): Redux Toolkit, ecosistema maduro

El carrito encaja bien con Zustand porque necesita compartir estado entre componentes (listado, icono del carrito, checkout), persistencia (no perder datos al refrescar) y buen rendimiento (actualizar solo lo necesario). Zustand lo cubre con la mitad de código que Redux.

Código del carrito con Zustand

Basta de teoría; al código. Instala dependencias:

npm install zustand

Crea el Store del carrito (/store/cartStore.js):

import { create } from 'zustand'
import { persist } from 'zustand/middleware'

export const useCartStore = create(
  persist(
    (set, get) => ({
      // Estado
      items: [], // [{ id, name, price, quantity, image }]

      // Propiedades calculadas
      get total() {
        return get().items.reduce((sum, item) => sum + item.price * item.quantity, 0)
      },
      get count() {
        return get().items.reduce((sum, item) => sum + item.quantity, 0)
      },

      // Métodos
      addItem: (product) => set((state) => {
        const existing = state.items.find(item => item.id === product.id)
        if (existing) {
          // Ya existe, incrementar cantidad
          return {
            items: state.items.map(item =>
              item.id === product.id
                ? { ...item, quantity: item.quantity + 1 }
                : item
            )
          }
        } else {
          // Producto nuevo
          return { items: [...state.items, { ...product, quantity: 1 }] }
        }
      }),

      removeItem: (productId) => set((state) => ({
        items: state.items.filter(item => item.id !== productId)
      })),

      updateQuantity: (productId, quantity) => set((state) => ({
        items: state.items.map(item =>
          item.id === productId ? { ...item, quantity } : item
        )
      })),

      clearCart: () => set({ items: [] })
    }),
    {
      name: 'shopping-cart', // clave de localStorage
    }
  )
)

La lógica es simple: items guarda productos, total y count se calculan, y los métodos gestionan altas, bajas y cambios. El middleware persist guarda en localStorage; al refrescar no pierdes nada.

En componentes es igual de sencillo:

import { useCartStore } from '@/store/cartStore'

function ProductCard({ product }) {
  const addItem = useCartStore(state => state.addItem)

  return (
    <button onClick={() => addItem(product)}>
      Añadir al carrito
    </button>
  )
}

function CartIcon() {
  const count = useCartStore(state => state.count)

  return <div>Carrito ({count})</div>
}

Fíjate en useCartStore(state => state.addItem): es un selector. Solo se suscribe a addItem y no re-renderiza por otros cambios del carrito. Ese es el secreto del rendimiento de Zustand: suscripciones precisas.

Si vienes de Redux (useSelector, useDispatch), Zustand te parecerá mucho más directo: sin action types ni reducers, métodos en el Store y listo.

¿Y si ya usas Redux? No hace falta cambiar. Redux Toolkit también va muy bien; el proyecto open source C-Shopping usa Redux Toolkit + RTK Query. Para proyectos nuevos, yo recomiendo Zustand: menos curva y más velocidad.

Flujo completo de integración con Stripe

Entiende primero el flujo de pago de Stripe

La primera vez que leí la documentación de Stripe, me preguntaba: ¿qué es Checkout Session? ¿Payment Intent? ¿Por qué redirigir a una página de Stripe? ¿No puedo cobrar en mi sitio?

Mirando atrás, el flujo es claro:

  1. Frontend: el usuario pulsa «Ir al pago»; tu API crea Checkout Session
  2. Backend: crea la Session y devuelve session.id
  3. Frontend: con session.id, Stripe.js redirige a la página de pago alojada
  4. Usuario: introduce la tarjeta y completa el pago
  5. Stripe: tras el éxito, envía un Webhook a tu backend
  6. Backend: recibe el Webhook, crea el pedido, descuenta stock y envía email
  7. Stripe: redirige al usuario a tu sitio (success_url)

Lo crucial: nunca proceses el éxito del pago en el frontend. El usuario puede cerrar el navegador, perder la red o no completar la redirección. La única vía fiable es el Webhook; lo veremos en detalle.

Crear Stripe Checkout Session

Instala dependencias:

npm install stripe @stripe/stripe-js

Configura variables de entorno (.env.local):

STRIPE_SECRET_KEY=sk_test_xxxxx  # Backend; nunca en el frontend
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxx  # Frontend
STRIPE_WEBHOOK_SECRET=whsec_xxxxx  # Verificación de firma del Webhook

Crea la ruta API (/pages/api/create-checkout.js):

import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)

export default async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' })
  }

  try {
    const { items } = req.body // Datos del carrito

    // Crear line_items (formato requerido por Stripe)
    const lineItems = items.map(item => ({
      price_data: {
        currency: 'usd',
        product_data: {
          name: item.name,
          images: [item.image],
        },
        unit_amount: Math.round(item.price * 100), // Stripe usa centavos
      },
      quantity: item.quantity,
    }))

    // Crear Checkout Session
    const session = await stripe.checkout.sessions.create({
      payment_method_types: ['card'],
      line_items: lineItems,
      mode: 'payment', // Pago único (suscripción: 'subscription')
      success_url: `${req.headers.origin}/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${req.headers.origin}/cart`,
      metadata: {
        // Datos personalizados; el Webhook puede leerlos
        userId: req.user?.id || 'guest',
      },
    })

    res.status(200).json({ sessionId: session.id })
  } catch (err) {
    console.error('Error al crear Checkout Session:', err)
    res.status(500).json({ error: err.message })
  }
}

Detalles importantes:

  • unit_amount se multiplica por 100: Stripe usa centavos (99,99 USD = 9999)
  • {CHECKOUT_SESSION_ID} en success_url es un placeholder que Stripe sustituye
  • metadata guarda datos de negocio (userId, notas) accesibles en el Webhook

Llamada al Checkout desde el frontend

En la página de pago (/pages/checkout.js):

import { loadStripe } from '@stripe/stripe-js'
import { useCartStore } from '@/store/cartStore'

const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY)

export default function CheckoutPage() {
  const { items, total } = useCartStore()

  const handleCheckout = async () => {
    try {
      // Llamar API para crear Session
      const response = await fetch('/api/create-checkout', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ items }),
      })

      const { sessionId } = await response.json()

      // Redirigir a la página de pago de Stripe
      const stripe = await stripePromise
      const { error } = await stripe.redirectToCheckout({ sessionId })

      if (error) {
        console.error('Error al redirigir al pago:', error)
        alert(error.message)
      }
    } catch (err) {
      console.error('Error al iniciar el pago:', err)
      alert('Pago fallido, inténtalo de nuevo')
    }
  }

  return (
    <div>
      <h1>Finalizar compra</h1>
      {items.map(item => (
        <div key={item.id}>
          {item.name} x {item.quantity} = ${item.price * item.quantity}
        </div>
      ))}
      <div>Total: ${total}</div>
      <button onClick={handleCheckout}>Ir al pago</button>
    </div>
  )
}

Tras pulsar «Ir al pago», el usuario va a la página alojada por Stripe: formulario, validación de tarjeta y antifraude sin que tú lo implementes.

¿Personalizar el estilo? Sí: colores, logo y fuentes. El layout base es de Stripe. Para control total del UI están Stripe Elements, pero es más complejo; no lo recomiendo al empezar.

Redirección tras el pago

Stripe redirige a tu success_url. Ahí puedes mostrar el pedido:

// /pages/success.js
import { useEffect, useState } from 'react'
import { useRouter } from 'next/router'

export default function SuccessPage() {
  const router = useRouter()
  const { session_id } = router.query
  const [order, setOrder] = useState(null)

  useEffect(() => {
    if (session_id) {
      // Obtener pedido del backend
      fetch(`/api/order?session_id=${session_id}`)
        .then(res => res.json())
        .then(data => setOrder(data))
    }
  }, [session_id])

  if (!order) return <div>Cargando...</div>

  return (
    <div>
      <h1>¡Pago exitoso!</h1>
      <p>Número de pedido: {order.id}</p>
      <p>Importe: ${order.total}</p>
    </div>
  )
}

Recuerda: esta página es solo informativa. La creación real del pedido va en el Webhook. Vamos con eso.

Webhook: pedidos y sincronización de estado

¿Por qué importan tanto los Webhooks?

La primera vez pensé que volver a la página success bastaba y metí ahí toda la lógica. En pruebas, usuarios cerraban el navegador tras pagar: sin pedido y sin saber si el cobro quedó colgado.

La documentación oficial de Stripe lo deja claro: el Webhook es la única forma fiable de procesar pedidos.

  • La redirección no es fiable: cierre del navegador, red caída, botón «completar» ignorado
  • Seguridad: crear pedidos, descontar stock o enviar no puede depender del frontend
  • Recomendación de Stripe: la lógica crítica va en el Webhook

Un Webhook es Stripe llamando a tu servidor: «este pago se completó» o «esta suscripción se canceló». Tú reaccionas en consecuencia.

Crear el endpoint Webhook

En Next.js, /pages/api/stripe-webhook.js:

import Stripe from 'stripe'
import { buffer } from 'micro'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET

// Clave: desactivar el parseo por defecto del body
export const config = {
  api: {
    bodyParser: false,
  },
}

export default async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).send('Method not allowed')
  }

  const buf = await buffer(req)
  const sig = req.headers['stripe-signature']

  let event

  try {
    // Verificar firma del Webhook (¡muy importante!)
    event = stripe.webhooks.constructEvent(buf, sig, webhookSecret)
  } catch (err) {
    console.error('Fallo en verificación de firma del Webhook:', err.message)
    return res.status(400).send(`Webhook Error: ${err.message}`)
  }

  // Manejar tipos de evento
  switch (event.type) {
    case 'checkout.session.completed':
      await handleCheckoutSessionCompleted(event.data.object)
      break
    case 'payment_intent.succeeded':
      await handlePaymentIntentSucceeded(event.data.object)
      break
    case 'invoice.payment_failed':
      await handleInvoicePaymentFailed(event.data.object)
      break
    default:
      console.log(`Tipo de evento no manejado: ${event.type}`)
  }

  res.status(200).json({ received: true })
}

async function handleCheckoutSessionCompleted(session) {
  console.log('¡Pago exitoso!', session.id)

  const userId = session.metadata.userId
  const sessionId = session.id
  const total = session.amount_total / 100 // Volver a dólares

  // Idempotencia: ¿ya existe el pedido?
  const existingOrder = await db.order.findUnique({
    where: { stripeSessionId: sessionId }
  })

  if (existingOrder) {
    console.log('Pedido ya existe, omitiendo creación')
    return
  }

  const order = await db.order.create({
    data: {
      userId,
      stripeSessionId: sessionId,
      status: 'paid',
      total,
      // ... otros campos
    }
  })

  await updateInventory(order.items)
  await sendOrderConfirmationEmail(userId, order)

  console.log('Pedido creado correctamente:', order.id)
}

async function handlePaymentIntentSucceeded(paymentIntent) {
  console.log('Pago confirmado:', paymentIntent.id)
}

async function handleInvoicePaymentFailed(invoice) {
  console.log('Pago fallido:', invoice.id)
  // Email de aviso, suspender servicio, etc.
}

Puntos clave:

  1. Desactiva bodyParser: Stripe necesita el body raw para la firma; si Next.js lo parsea antes, falla la verificación
  2. Verifica la firma: constructEvent confirma que la petición viene de Stripe
  3. Idempotencia: Stripe puede reenviar eventos; stripeSessionId único evita pedidos duplicados

Probar Webhooks en local

Stripe no llama a localhost; usa Stripe CLI:

# Mac
brew install stripe/stripe-cli/stripe

# Windows (Scoop)
scoop install stripe

# O descarga oficial
# https://stripe.com/docs/stripe-cli

Inicia sesión y escucha:

stripe login
stripe listen --forward-to localhost:3000/api/stripe-webhook

Copia el secret temporal (whsec_xxxxx) a .env.local:

STRIPE_WEBHOOK_SECRET=whsec_xxxxx

En otra terminal, dispara un evento de prueba:

stripe trigger checkout.session.completed

Verás logs en CLI y en Next.js; ya puedes depurar la creación de pedidos.

Aquí me atascé mucho rato con fallos de firma: no había desactivado bodyParser. No olvides el export const config.

Gestión del estado del pedido

Flujo típico:

Pendiente de pago → Pagado → Preparando → Enviado → Completado

              Cancelado / Reembolsado

En base de datos, enum:

// schema.prisma
model Order {
  id               String   @id @default(cuid())
  stripeSessionId  String   @unique  // Idempotencia
  userId           String
  status           OrderStatus @default(PENDING)
  total            Float
  createdAt        DateTime @default(now())
  updatedAt        DateTime @updatedAt
}

enum OrderStatus {
  PENDING      // Pendiente de pago
  PAID         // Pagado
  PREPARING    // Preparando envío
  SHIPPED      // Enviado
  COMPLETED    // Completado
  CANCELLED    // Cancelado
  REFUNDED     // Reembolsado
}

Con checkout.session.completed, pasa a PAID. Envío y cierre los gestiona tu backoffice.

Manejo de errores

El Webhook puede fallar (BD caída, servicio externo, etc.). Stripe reintenta, pero conviene registrar fallos:

async function handleCheckoutSessionCompleted(session) {
  try {
    // Lógica de negocio
  } catch (error) {
    console.error('Error al procesar pedido:', error)
    await logError({
      type: 'webhook_error',
      event: 'checkout.session.completed',
      sessionId: session.id,
      error: error.message,
    })
    throw error // Stripe reintentará
  }
}

Si falla, Stripe reintenta durante 3 días. En el Dashboard puedes ver Webhooks fallidos y reenviarlos.

Flujo completo de pedido en la práctica

Con todas las piezas, así encaja un pedido de punta a punta.

Ruta completa del usuario

  1. Página de producto: «Añadir al carrito»; Zustand actualiza el contador del icono
  2. Carrito: revisa cantidades y pulsa «Finalizar compra»
  3. Checkout: resumen y «Ir al pago»
  4. Frontend: llama a /api/create-checkout con los items
  5. Backend: crea Session y devuelve sessionId
  6. Frontend: redirige a Stripe
  7. Usuario: tarjeta y «Pay»
  8. Stripe: cobra y envía Webhook a /api/stripe-webhook
  9. Webhook: verifica firma → crea pedido → descuenta stock → envía email
  10. Stripe: redirige a /success?session_id=xxx
  11. Frontend: success llama a /api/order?session_id=xxx y muestra detalles

Parece largo, pero cada paso es claro. El paso 9 debe ocurrir en el Webhook, no en el 11.

Diseño de base de datos

model Order {
  id               String      @id @default(cuid())
  stripeSessionId  String      @unique
  userId           String
  status           OrderStatus @default(PENDING)
  total            Float
  items            OrderItem[]
  createdAt        DateTime    @default(now())
  updatedAt        DateTime    @updatedAt

  user User @relation(fields: [userId], references: [id])
}

model OrderItem {
  id        String @id @default(cuid())
  orderId   String
  productId String
  quantity  Int
  price     Float  // Precio al momento del pedido

  order   Order   @relation(fields: [orderId], references: [id])
  product Product @relation(fields: [productId], references: [id])
}

OrderItem.price guarda el precio en el momento del pedido, no el actual del producto. Si sube el precio después, el histórico no cambia.

Casos límite

1. ¿Stock insuficiente?

Comprueba antes de crear la Session:

// /pages/api/create-checkout.js
const { items } = req.body

for (const item of items) {
  const product = await db.product.findUnique({ where: { id: item.id } })
  if (product.stock < item.quantity) {
    return res.status(400).json({ error: `${product.name} sin stock suficiente` })
  }
}

// Stock OK, seguir con la Session...

2. ¿Pago OK pero Webhook falló?

Stripe reintenta 3 días. También puedes reenviar desde el Dashboard o un job que detecte sesiones pagadas sin pedido.

3. ¿Pagó pero no hay stock al enviar?

Vuelve a comprobar stock antes de enviar; contacta para reembolso o cambio.

Despliegue en producción

Que funcione en test no basta. Varios detalles evitan sustos.

Variables de entorno

Las claves de producción no son las de test:

# .env.production
STRIPE_SECRET_KEY=sk_live_xxxxx  # live, no test
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxx  # Secret de producción

En Vercel u otra plataforma, configúralas en el panel. Nunca subas la Secret Key a Git.

Endpoint Webhook

En test usas Stripe CLI; en producción configura en el Dashboard:

  1. Stripe Dashboard
  2. Developers → Webhooks
  3. Add endpoint
  4. URL: https://yourdomain.com/api/stripe-webhook
  5. Eventos: checkout.session.completed, payment_intent.succeeded, etc.
  6. Copia el Signing secret a las variables de entorno

La primera vez olvidé esto: en producción no llegaba ningún Webhook y los pedidos no se creaban.

Checklist de seguridad

Antes de publicar:

  • ✅ Toda la lógica de pago en el backend (el frontend solo redirige)
  • ✅ Webhook con verificación de firma (constructEvent)
  • ✅ Monto del pago = monto del pedido (anti-manipulación)
  • ✅ Idempotencia (stripeSessionId único)
  • ✅ Logs de pagos (Sentry, Datadog, etc.)
  • ✅ Alertas (fallos de Webhook, tasa de éxito)

El tercer punto importa aunque el precio se fije al crear la Session: en el Webhook vuelve a validar.

Monitoreo y alertas

// /pages/api/stripe-webhook.js
import * as Sentry from '@sentry/nextjs'

export default async function handler(req, res) {
  try {
    // ... lógica del Webhook
  } catch (error) {
    Sentry.captureException(error, {
      tags: {
        type: 'stripe_webhook',
        event: event.type,
      },
    })
    throw error
  }
}

Métricas a vigilar:

  • Tasa de fallo del Webhook (alerta si >5%)
  • Tasa de éxito de pagos (caídas bruscas = Stripe o config)
  • Tiempo de creación de pedido (>3 s, investigar)

Resumen: de cero a producción

Pasos clave:

  1. Carrito: Zustand (ligero) o Redux Toolkit (grandes), con persist
  2. Pago: Checkout Session y página alojada de Stripe
  3. Pedidos: Webhook para crear, descontar stock y enviar email
  4. Producción: variables, endpoint Webhook y monitoreo

Tres principios:

  • El pago vive en el backend: el frontend no es de fiar
  • El Webhook es la fuente fiable: la redirección del usuario no lo es
  • Seguridad primero: firma, anti-replay, logs

Si es tu primer e-commerce, prueba todo en entorno test de Stripe. Tarjeta: 4242 4242 4242 4242 (fecha y CVV cualquiera). Cuando esté sólido, pasa a live.

Recursos para profundizar:

  • Documentación oficial de Stripe: https://stripe.com/docs
  • Tutorial Next.js + Stripe: guía 2025 de Pedro Alonso (busca «Stripe Next.js 15 complete guide»)
  • Referencia open source: C-Shopping (Redux Toolkit + Stripe)

Ojalá esto te ahorre tropiezos. Cuando veas el pedido crearse solo, el stock bajar bien y llegar el email de confirmación, la sensación compensa el esfuerzo. ¡Ánimo!

Flujo completo de carrito e-commerce Next.js y pago con Stripe

Pasos detallados para construir un carrito y sistema de pago desde cero, incluyendo gestión de estado, integración de pago y procesamiento de pedidos

⏱️ Estimated time: 2 hr

  1. 1

    Step 1: Instalar dependencias y configurar el carrito con Zustand

    Instala la librería de gestión de estado Zustand:
    • npm install zustand

    Crea el Store del carrito (/store/cartStore.js):
    • Define un array items para almacenar productos
    • Añade propiedades calculadas total y count
    • Implementa addItem, removeItem, updateQuantity, clearCart
    • Usa el middleware persist para guardar en localStorage

    Configuración clave:
    • persist persiste automáticamente; no pierdes datos al refrescar
    • Usa selectores (useCartStore(state => state.addItem)) para evitar re-renderizados innecesarios

    Casos de uso: proyectos pequeños y medianos (10-50 páginas) que necesitan gestión de estado ligera
  2. 2

    Step 2: Crear la API de Stripe Checkout Session

    Instala dependencias de Stripe:
    • npm install stripe @stripe/stripe-js

    Configura variables de entorno (.env.local):
    • STRIPE_SECRET_KEY=sk_test_xxxxx (backend, no exponer)
    • NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxx (frontend)
    • STRIPE_WEBHOOK_SECRET=whsec_xxxxx (verificación de firma del Webhook)

    Crea la ruta API (/pages/api/create-checkout.js):
    • Recibe los items del carrito
    • Convierte al formato line_items de Stripe (unit_amount debe multiplicarse por 100)
    • Crea checkout.sessions (configura success_url y cancel_url)
    • Guarda datos personalizados en metadata (userId, etc.)
    • Devuelve sessionId al frontend

    Detalles clave:
    • Stripe usa centavos; multiplica el precio por 100
    • success_url usa el placeholder {CHECKOUT_SESSION_ID}
    • metadata almacena datos de negocio accesibles en el Webhook
  3. 3

    Step 3: Llamada al Checkout de Stripe desde el frontend

    Implementa la página de pago (/pages/checkout.js):
    • Usa loadStripe para cargar Stripe.js
    • Llama a /api/create-checkout para crear la Session
    • Usa stripe.redirectToCheckout() para redirigir a la página de pago

    Manejo de errores:
    • catch para errores de red
    • Verifica el error devuelto por stripe.redirectToCheckout
    • Muestra mensajes amigables al usuario

    Sobre la página de pago:
    • Stripe aloja la página; no necesitas escribir el formulario
    • Maneja validación de tarjeta y detección de fraude automáticamente
    • Puedes personalizar colores, logo y fuentes

    Importante:
    • Nunca proceses la lógica de pago exitoso en el frontend
    • El usuario puede cerrar el navegador o no pulsar completar
    • La creación real del pedido debe ocurrir en el Webhook
  4. 4

    Step 4: Configurar el endpoint Webhook para procesar pedidos

    Crea la API Webhook (/pages/api/stripe-webhook.js):

    Configuración obligatoria:
    • export const config = { api: { bodyParser: false } } (desactivar parseo del body)
    • Usa buffer(req) para obtener el cuerpo raw
    • stripe.webhooks.constructEvent() para verificar la firma

    Tipos de eventos:
    • checkout.session.completed: pago exitoso, crear pedido
    • payment_intent.succeeded: confirmar recepción del pago
    • invoice.payment_failed: fallo de pago de suscripción

    Idempotencia:
    • Verifica si stripeSessionId ya existe
    • Añade índice unique en la base de datos
    • Evita pedidos duplicados por reintentos del Webhook

    Lógica de negocio:
    • Crear registro de pedido (status: 'paid')
    • Descontar inventario (updateInventory)
    • Enviar email de confirmación (sendOrderConfirmationEmail)
    • Registrar logs y errores

    Prueba local:
    • stripe login
    • stripe listen --forward-to localhost:3000/api/stripe-webhook
    • stripe trigger checkout.session.completed

    Crítico:
    • Debes desactivar bodyParser o fallará la verificación de firma
    • Debes verificar la firma para evitar peticiones falsas
    • Stripe reintenta Webhooks fallidos durante 3 días
  5. 5

    Step 5: Despliegue en producción y configuración de seguridad

    Variables de entorno:
    • Usa claves de producción (sk_live_xxxxx y pk_live_xxxxx)
    • Configúralas en la plataforma (Vercel/Netlify)
    • Nunca subas la Secret Key a Git

    Stripe Dashboard:
    • Developers → Webhooks
    • Añade endpoint de producción (https://yourdomain.com/api/stripe-webhook)
    • Selecciona eventos (checkout.session.completed, etc.)
    • Copia el Signing secret a las variables de entorno

    Checklist de seguridad:
    • ✅ Toda la lógica de pago en el backend
    • ✅ Webhook verifica firma
    • ✅ Validar que el monto del pago coincide con el pedido
    • ✅ Idempotencia (índice único en stripeSessionId)
    • ✅ Registrar todos los logs de pago
    • ✅ Alertas de monitoreo (tasa de fallo Webhook >5%)

    Métricas:
    • Tasa de fallo del Webhook
    • Tasa de éxito de pagos
    • Tiempo de creación de pedidos (>3s requiere investigación)

    Monitoreo:
    • Sentry/LogRocket para errores
    • Reglas de alerta
    • Revisar logs de Webhook en Stripe Dashboard

    Pruebas:
    • Tarjeta de prueba 4242 4242 4242 4242
    • Flujo completo (carrito → pago → Webhook → pedido)
    • Escenarios de fallo (stock insuficiente, Webhook fallido, etc.)

FAQ

¿Cómo elijo entre Redux y Zustand? ¿Cuál conviene para mi proyecto?
La elección depende sobre todo del tamaño del proyecto y del equipo:

• Proyectos pequeños (<10 páginas): Context API basta; no necesitas librería extra
• Proyectos medianos (10-50 páginas): Zustand encaja mejor — ligero, curva de aprendizaje baja, menos código
• Proyectos grandes (50+ páginas, varios equipos): Redux Toolkit — herramientas maduras, depuración potente, comunidad consolidada

Escenarios concretos:
• Proyecto nuevo, iteración rápida: Zustand, rápido de aprender y de entregar
• Proyecto ya en Redux: no hace falta cambiar; Redux Toolkit funciona muy bien
• Equipo sin experiencia en gestión de estado: Zustand tiene curva más suave

Para carritos recomiendo Zustand: compartir entre componentes, persistencia y rendimiento.
¿Por qué no puedo procesar la lógica de pago exitoso en el frontend?
Procesar el éxito del pago en el frontend tiene problemas graves:

Fiabilidad:
• El usuario puede cerrar el navegador tras pagar
• Una caída de red puede impedir la redirección
• Puede no pulsar completar a propósito

Seguridad:
• El código frontend se puede manipular
• Crear pedidos o descontar stock no puede exponerse en el cliente
• No puedes evitar que alguien falsifique un estado de pago exitoso

Enfoque correcto:
• Toda la lógica crítica en el Webhook
• Stripe notifica directamente a tu backend (sin pasar por el navegador)
• El Webhook tiene verificación de firma
• Recomendación oficial de Stripe: el Webhook es la única fuente fiable para pedidos

La página success del frontend solo muestra información; no ejecuta lógica de negocio.
La verificación de firma del Webhook falla siempre — ¿qué hago?
Causas habituales y soluciones:

Causa más común (90%):
• bodyParser de Next.js no está desactivado
• Solución: añade export const config = { api: { bodyParser: false } } en la ruta API

Otras causas:
• Webhook Secret mal configurado (revisa STRIPE_WEBHOOK_SECRET en .env.local)
• Clave de entorno incorrecta (test vs producción)
• El body fue modificado por middleware global

Pasos de depuración:
1. Confirma bodyParser: false
2. Imprime req.headers['stripe-signature'] para verificar que existe
3. Prueba con Stripe CLI: stripe listen --forward-to localhost:3000/api/stripe-webhook
4. Revisa el error detallado en la salida del CLI
5. Confirma que usas el webhook secret temporal del CLI

En local:
• Debes usar Stripe CLI para reenviar
• El CLI da un secret temporal (whsec_xxxxx)
• Cada reinicio del CLI genera un secret nuevo; actualiza .env.local
¿Cómo evito que llamadas duplicadas del Webhook creen varios pedidos?
La clave es implementar idempotencia:

A nivel de base de datos:
• Índice unique en stripeSessionId
• Ejemplo Prisma: stripeSessionId String @unique
• La base de datos rechaza inserciones duplicadas

A nivel de código:
• Consulta si el pedido ya existe antes de crear
• Usa findUnique({ where: { stripeSessionId } })
• Si existe, devuelve sin crear otro

Código de ejemplo:
```javascript
const existingOrder = await db.order.findUnique({
where: { stripeSessionId: sessionId }
})

if (existingOrder) {
console.log('Pedido ya existe, omitiendo creación')
return
}

// Solo crear si no existe
const order = await db.order.create({ ... })
```

Por qué hace falta idempotencia:
• Stripe puede reenviar Webhooks (red, reintentos)
• Tu código debe manejar llamadas repetidas con seguridad
• Evita varios pedidos y descuentos de stock duplicados

Otras recomendaciones:
• Registra cada llamada al Webhook
• Monitoriza la frecuencia de duplicados
• Configura alertas
Tras desplegar en producción no se crean pedidos — ¿cómo lo investigo?
Investiga en este orden:

1. ¿Llega el Webhook?
• Stripe Dashboard → Developers → Webhooks
• Revisa historial y estado (éxito/fallo)
• Sin registros = problema de configuración del endpoint

2. Configuración del endpoint:
• ¿URL correcta? (https://yourdomain.com/api/stripe-webhook)
• ¿Evento checkout.session.completed seleccionado?
• ¿Endpoint activado?

3. Variables de entorno:
• ¿STRIPE_WEBHOOK_SECRET correcto?
• ¿Secret de producción (no de test)?
• ¿Configurado en Vercel/Netlify?

4. Código del endpoint:
• ¿bodyParser desactivado?
• ¿Verificación de firma correcta?
• ¿Hay logs de error?

5. Logs de la aplicación:
• Vercel Logs/CloudWatch, etc.
• Busca stack traces
• ¿Se ejecuta el handler del Webhook?

6. Prueba manual:
• Webhook fallido en Stripe Dashboard → Resend
• Observa éxito o mensaje de error

Errores frecuentes:
• Olvidar añadir el endpoint en producción
• Usar secret de test en producción
• Firewall de la plataforma bloqueando a Stripe

Tras corregir:
• Prueba el flujo completo con tarjeta de test
• Confirma creación de pedido, descuento de stock y envío de email

14 min de lectura · Publicado el: 7 ene 2026 · Actualizado el: 21 ago 2026

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog