Guía práctica · Guías / Tips
Cómo integré Stripe en mi web sin plugins ni Shopify (Cloudflare Workers)
Arquitectura real para integrar Stripe Checkout sin plugins: precio y stock en servidor, pedidos, webhooks, idempotencia, expiración y pruebas.
Actualizado: Flujo técnico revisado con documentación oficial de Stripe y tarifas separadas del contenido evergreen.

Cuando decidí vender desde mi propia web, sin Shopify, WooCommerce ni un plugin que resolviera todo por mí, apareció una pregunta bastante seria:
¿Cómo acepto pagos sin convertir mi tienda en un problema de seguridad?
Podía construir el catálogo, el carrito y las fichas de producto con HTML, CSS y JavaScript. También podía guardar productos y pedidos en una base de datos. Pero cobrar es otra categoría de problema.
Un fallo visual puede provocar que un botón se vea mal.
Un fallo en los pagos puede provocar que:
- Cobres un importe incorrecto.
- Vendas una unidad que no existe.
- Marqués como pagado un pedido sin cobrar.
- Descuentes dos veces el mismo producto.
- Expongas una clave privada.
- Envíes dos pedidos por un único pago.
- Pierdas la relación entre Stripe y tu base de datos.
Por eso elegí Stripe Checkout.
No construí un formulario para introducir tarjetas dentro de mi web. Cuando el cliente decide pagar, mi servidor crea una sesión y lo redirige a una página segura alojada por Stripe. Stripe se ocupa de recoger los datos del método de pago, procesar la operación y aplicar sistemas como 3D Secure cuando corresponda.
Mi web sigue controlando los productos, los precios, los pedidos y el stock. Stripe controla la parte sensible del pago.
Este artículo no es un tutorial para copiar código sin entenderlo. Es una explicación de la arquitectura, las decisiones y los errores que intenté evitar.
Qué construí realmente
Mi web no utiliza un backend tradicional ejecutándose en un servidor privado que administro durante todo el día.
La estructura es esta:
- HTML, CSS y JavaScript vanilla en el frontend.
- Cloudflare Pages para publicar la web.
- Cloudflare Pages Functions para la lógica del servidor.
- Cloudflare D1 como base de datos.
- Cloudflare R2 para imágenes y archivos.
- Stripe Checkout para procesar los pagos.
Pages Functions ejecuta código del lado del servidor sobre la red de Cloudflare utilizando el entorno de Cloudflare Workers. Esto permite crear funciones dinámicas sin mantener una máquina o servidor dedicado.
Por eso el título habla de Cloudflare Workers, aunque técnicamente mi proyecto utiliza Pages Functions sobre el runtime de Workers.
El flujo se puede resumir así:
Navegador
↓
Cloudflare Pages Function
↓
D1 comprueba productos, precios y stock
↓
Stripe crea una Checkout Session
↓
El cliente paga en Stripe
↓
Stripe envía un webhook
↓
D1 actualiza el pedido
La parte importante es que el navegador no tiene autoridad sobre la operación.
El navegador puede solicitar una compra. El servidor decide si esa compra es válida.
Por qué elegí Stripe Checkout
Stripe ofrece distintas formas de aceptar pagos.
Podía utilizar:
- Un enlace de pago.
- Payment Links.
- Stripe Elements.
- Payment Element.
- Checkout integrado.
- Checkout alojado por Stripe.
Para mi caso, Checkout alojado ofrecía un equilibrio razonable.
Me permitía:
- Crear la sesión desde mi backend.
- Controlar los productos y los importes.
- Redirigir al cliente a una página de pago preparada.
- Recoger información de envío.
- Activar distintos métodos de pago.
- Recibir confirmaciones mediante webhooks.
- Evitar que los datos completos de la tarjeta pasaran por mi servidor.
Stripe recomienda conservar en el servidor la información sensible del inventario, incluidos los precios y la disponibilidad, para evitar que el cliente pueda manipularla.
Además, Stripe Checkout puede mostrar tarjetas y otros métodos compatibles según la configuración de la cuenta, el país, la moneda, el dispositivo y la elegibilidad del comprador. Apple Pay o Google Pay no aparecen obligatoriamente en todos los casos; dependen de esas condiciones y de la configuración del Dashboard.
Por qué no utilicé Shopify
No elegí Stripe directamente porque Shopify sea malo.
Shopify resuelve mucho más que el cobro:
- Catálogo.
- Panel de administración.
- Stock.
- Temas.
- Carrito.
- Checkout.
- Pedidos.
- Correos.
- Descuentos.
- Integraciones.
- Aplicaciones.
- Hosting.
- Actualizaciones.
- Parte de la seguridad.
Cuando construyes una tienda propia, muchas de esas responsabilidades pasan a ser tuyas.
No pagar una plataforma no significa que todo salga gratis. Significa que sustituyes parte de la cuota por:
- Tiempo de desarrollo.
- Pruebas.
- Mantenimiento.
- Monitorización.
- Resolución de errores.
- Responsabilidad técnica.
Para un negocio que quiere empezar rápidamente y no tiene interés técnico, Shopify puede ser la opción correcta.
Yo elegí otro camino porque quería:
- Controlar la experiencia completa.
- Mantener un frontend ligero.
- Gestionar mis propios datos.
- Evitar depender de aplicaciones para cada función.
- Aprender cómo funciona una tienda por dentro.
- Utilizar mi propia web como caso de estudio.
- Poder ofrecer después este tipo de infraestructura a otros negocios.
La tienda es mía en el sentido de que controlo el código, la base de datos y la estructura. Pero sigo dependiendo de proveedores externos: Cloudflare para ejecutar la aplicación y Stripe para procesar los pagos.
No existe una independencia absoluta.
Cuánto cuesta cobrar directamente con Stripe
Integrar Stripe no elimina las comisiones por pago.
En su tarifa estándar para España, Stripe publica actualmente:
- 1,5 % + 0,25 € para tarjetas estándar del Espacio Económico Europeo.
- 1,9 % + 0,25 € para tarjetas prémium del EEE.
- Tarifas superiores para tarjetas internacionales y conversiones de moneda.
La modalidad estándar no tiene una comisión mensual ni una cuota de configuración publicada.
Por ejemplo, sobre un pago de 39,90 € con una tarjeta estándar del EEE:
39,90 € × 1,5 % = 0,60 €
0,60 € + 0,25 € = 0,85 €
La comisión aproximada sería de 0,85 €.
Esta cantidad debe formar parte del cálculo del margen. No es un coste que pueda ignorarse porque solo aparezca después de vender.
Qué gestiona Stripe y qué gestiona mi web
Integrar Stripe no convierte automáticamente una web en una tienda completa.
Stripe se ocupa del pago. Mi sistema debe ocuparse del pedido.
| Stripe | Mi web |
|---|---|
| Página segura de pago | Catálogo |
| Procesamiento de la tarjeta | Variantes y tallas |
| Métodos de pago | Precio comercial |
| Autenticación 3D Secure | Stock |
| Estado del pago | Gastos de envío |
| Prevención de fraude integrada | Creación del pedido |
| Confirmación mediante eventos | Preparación y envío |
| Reembolsos desde Stripe | Estado interno del pedido |
| Disputas | Atención al cliente |
Esta separación es importante.
Stripe puede confirmar que se han cobrado 44,90 €, pero necesita que mi aplicación sepa qué pedido corresponde a ese pago.
Por eso cada Checkout Session debe quedar relacionada con un pedido interno.
La regla principal: el navegador nunca decide el precio
El error más peligroso sería construir el carrito así:
El navegador envía:
Producto: camiseta
Precio: 39,90 €
Cantidad: 1
Después, el servidor acepta esa información y crea el pago por 39,90 €.
Puede parecer correcto, pero no lo es.
Todo lo que ocurre en el navegador puede manipularse. Una persona puede abrir las herramientas de desarrollo, modificar una solicitud y enviar:
Producto: camiseta
Precio: 1 €
Cantidad: 1
También podría intentar cambiar:
- El descuento.
- El coste del envío.
- La moneda.
- La variante.
- La cantidad.
- El impuesto.
- El total final.
Por eso, mi frontend solo debería enviar datos como:
- Identificador del producto.
- Identificador de la variante.
- Cantidad solicitada.
Nada más.
Después, el backend:
- Comprueba que el producto existe.
- Comprueba que está activo.
- Comprueba que la variante existe.
- Comprueba que la cantidad es válida.
- Consulta el precio guardado en D1.
- Comprueba el stock disponible.
- Calcula el subtotal.
- Calcula el envío.
- Aplica los descuentos válidos.
- Calcula el total final.
- Crea la sesión de Stripe con ese importe.
El usuario elige qué quiere comprar.
El servidor decide cuánto cuesta realmente.
Esto se conoce como una arquitectura server-authoritative: el servidor es la fuente de verdad.
No basta con ocultar el precio en JavaScript
A veces se piensa que basta con ofuscar el código, desactivar el clic derecho o no mostrar una variable.
Eso no protege nada.
Una solicitud enviada desde el navegador se puede:
- Inspeccionar.
- Repetir.
- Alterar.
- Automatizar.
- Enviar sin utilizar la interfaz.
La seguridad no consiste en impedir que alguien vea cómo funciona el frontend.
Consiste en asumir que el frontend es manipulable y validar absolutamente todo en el servidor.
El flujo de compra completo
El proceso que busqué construir funciona así.
1. El cliente prepara el carrito
El carrito puede mantenerse temporalmente en el navegador para que la experiencia sea rápida.
Contiene información como:
- Producto.
- Variante.
- Talla.
- Cantidad.
Los precios que aparecen visualmente sirven para informar al usuario, pero no son la fuente definitiva del cobro.
2. El navegador solicita iniciar el pago
Al pulsar el botón, el frontend envía al backend los identificadores y cantidades.
El backend no debe aceptar cualquier estructura. Tiene que validar:
- Que el cuerpo sea válido.
- Que el carrito no esté vacío.
- Que no contenga demasiadas líneas.
- Que cada cantidad sea un número entero.
- Que las cantidades estén dentro de límites razonables.
- Que no haya productos repetidos de forma maliciosa.
- Que los identificadores tengan el formato esperado.
Incluso una solicitud aparentemente sencilla necesita límites.
Sin ellos, alguien podría intentar enviar miles de productos en una sola petición o cantidades absurdas para forzar consultas y cálculos innecesarios.
3. El servidor consulta D1
El backend busca los productos y variantes en la base de datos.
De ahí obtiene:
- Nombre.
- Precio.
- Moneda.
- Estado.
- Stock.
- Imagen.
- Peso.
- Configuración de envío.
En este punto se reconstruye el carrito desde cero con datos fiables.
No se corrige el carrito del navegador.
Se reemplaza por una versión calculada desde la base de datos.
4. Se crea un pedido pendiente
Antes de enviar al cliente a Stripe, resulta útil crear un pedido interno.
Por ejemplo:
Estado: pending_payment
Total: 44,90 €
Moneda: EUR
Cliente: todavía incompleto
Stripe Session: todavía pendiente
El pedido recibe un identificador propio.
Ese identificador puede enviarse a Stripe mediante campos como:
clientreferenceid.metadata.
Cuando Stripe envíe posteriormente el webhook, mi aplicación podrá saber qué registro debe actualizar.
No utilizaría como referencia principal el correo del cliente. El correo puede cambiar, repetirse o contener errores.
La relación correcta es mediante identificadores.
5. Se reserva el stock
Aquí aparece uno de los problemas más delicados.
Imagina que solo queda una camiseta.
Dos personas pulsan pagar casi al mismo tiempo.
Si el servidor hace esto:
- Consulta el stock.
- Ve que queda una.
- Espera.
- Resta una.
Las dos solicitudes podrían leer el mismo stock antes de que ninguna lo actualice.
Ambas creerían que pueden comprar.
Este problema se conoce como condición de carrera.
La reserva debe ser condicional
No basta con leer el stock y actualizarlo después.
La operación debería expresar algo equivalente a:
Reserva una unidad únicamente si sigue existiendo una unidad disponible en el momento exacto de actualizar.
La consulta de actualización debe incluir la condición de disponibilidad.
Después, el sistema comprueba si realmente se modificó alguna fila.
Si no se modificó, significa que otra compra se adelantó o que el stock ya no era suficiente.
Cloudflare D1 permite enviar varias sentencias mediante batch(). La documentación indica que las operaciones se ejecutan secuencialmente dentro de una transacción y que, si una falla, se revierte la secuencia completa.
Esto resulta útil para combinar acciones relacionadas como:
- Crear el pedido.
- Crear las líneas.
- Actualizar las reservas.
- Guardar el total.
Pero una transacción no soluciona automáticamente una consulta mal diseñada. La comprobación del stock también debe realizarse de forma condicional.
Reservar no significa descontar dos veces
Mi borrador inicial decía:
- Reservar el stock antes del pago.
- Descontarlo al recibir el webhook.
Eso necesita una precisión importante.
Si al reservar ya reduces el stock disponible, no debes volver a reducirlo cuando Stripe confirme el pago.
Un modelo más claro separa tres valores:
stock_total
stock_reservado
stock_vendido
O utiliza estados equivalentes.
Cuando se inicia el pago:
Disponible: 10
Reservado: 0
→ Reserva de 1
Disponible: 9
Reservado: 1
Cuando se confirma:
Disponible: 9
Reservado: 0
Vendido: +1
La unidad ya había dejado de estar disponible durante la reserva. El webhook solo convierte esa reserva en una venta confirmada.
Si el pago expira:
Disponible: 10
Reservado: 0
La unidad vuelve al catálogo.
Sin esta separación es fácil restar dos veces.
6. El backend crea la Checkout Session
Cuando el pedido y la reserva están preparados, el backend realiza una solicitud a Stripe.
La sesión contiene información como:
- Líneas del pedido.
- Cantidades.
- Importes.
- Moneda.
- Dirección de éxito.
- Dirección de cancelación.
- Opciones de envío.
- Identificador interno.
- Metadatos.
- Fecha de expiración.
Stripe devuelve una URL de Checkout.
El backend la devuelve al navegador y el usuario es redirigido.
Una sesión de Stripe no debería crearse directamente desde el navegador
La clave secreta de Stripe nunca debe estar expuesta en JavaScript público.
Las claves privadas y el secreto del webhook pertenecen al entorno del servidor.
Cloudflare permite almacenar API keys y tokens como secretos cifrados asociados al Worker. También advierte que los secretos no deben guardarse como variables públicas ni subirse al repositorio.
En producción, claves como estas deben permanecer en Cloudflare:
STRIPE_SECRET_KEY
STRIPE_WEBHOOK_SECRET
No deberían aparecer en:
- El repositorio.
- Un archivo JavaScript público.
- El HTML.
- Capturas.
- Logs.
- Mensajes de error.
- Documentación pública.
7. El cliente paga en Stripe
En la página alojada por Stripe, el cliente introduce los datos necesarios.
Una integración de bajo riesgo permite que la información sensible del pago se transmita directamente a Stripe sin pasar por los servidores del negocio, reduciendo considerablemente las obligaciones técnicas relacionadas con PCI. Aun así, Stripe aclara que el cumplimiento es una responsabilidad compartida y que el comercio sigue teniendo obligaciones.
Por tanto, la frase correcta no es:
Al utilizar Stripe ya no tengo ninguna responsabilidad de seguridad.
La frase correcta es:
Stripe evita que tenga que manejar directamente los datos completos de la tarjeta y reduce de forma importante la superficie de riesgo.
Mi web todavía debe proteger:
- Datos personales.
- Direcciones.
- Pedidos.
- Claves.
- Sesiones administrativas.
- Formularios.
- Webhooks.
- Base de datos.
8. Stripe redirige al cliente
Después del pago, Stripe puede devolver al usuario a una página de éxito.
Esa página sirve para mostrar:
- Gracias por tu compra.
- Número de pedido.
- Resumen.
- Próximos pasos.
- Información de envío.
Pero la redirección no es la confirmación definitiva del pedido.
La página de éxito no confirma la venta
El navegador no es un canal fiable para confirmar un pago.
Pueden ocurrir varias cosas:
- El cliente paga y cierra la pestaña.
- Pierde la conexión.
- No vuelve a la web.
- Manipula la URL.
- Recarga la página.
- Abre manualmente la página de éxito.
Si la aplicación marca un pedido como pagado solo porque alguien visita /success, se puede engañar fácilmente.
Stripe recomienda esperar a que el pago haya tenido éxito antes de cumplir el pedido, enviarlo o ejecutar acciones posteriores.
La página de éxito informa al usuario.
El webhook actualiza el sistema.
9. Stripe envía un webhook
Un webhook es una petición que Stripe realiza directamente contra una URL de mi backend.
Conceptualmente, el mensaje dice:
Esta Checkout Session ha cambiado de estado.
El evento más conocido es:
checkout.session.completed
Pero no debería procesarse cualquier JSON que llegue a esa dirección.
Cualquiera puede descubrir una URL pública e intentar enviarle una petición falsa.
Verificar la firma del webhook
Stripe incluye una cabecera Stripe-Signature.
El backend debe verificarla utilizando:
- El cuerpo original de la petición.
- La cabecera de firma.
- El secreto del endpoint.
Stripe recomienda realizar esta verificación y señala que necesita el cuerpo sin modificar de la petición. Si el framework transforma primero el JSON, la verificación puede fallar.
Esto es especialmente importante en Cloudflare Workers.
El flujo correcto es:
- Leer el cuerpo como texto o bytes originales.
- Obtener
Stripe-Signature. - Verificar la firma.
- Rechazar la petición si no es válida.
- Interpretar el evento únicamente después.
No se debe hacer primero:
JSON.parse()
y reconstruir posteriormente el contenido para verificarlo.
La firma corresponde al cuerpo exacto recibido.
Una firma válida no significa ejecutar todo sin comprobar
Incluso después de verificar que el evento procede de Stripe, mi backend debe confirmar:
- Qué tipo de evento es.
- Qué sesión contiene.
- Qué pedido interno representa.
- Qué moneda se utilizó.
- Qué importe se pagó.
- Si el pedido ya está procesado.
- Si el estado permite la transición.
- Si el modo es prueba o producción.
No asumiría que cualquier evento firmado puede modificar cualquier pedido.
Idempotencia: procesar una vez aunque Stripe avise varias
Stripe puede reenviar un evento.
No es una anomalía. Forma parte del diseño de los webhooks.
Si mi endpoint no responde correctamente o existe un problema temporal, Stripe puede volver a intentarlo. En producción, Stripe indica que puede reintentar la entrega durante un máximo de tres días con espera exponencial. También advierte que los eventos no siempre llegan en el mismo orden en el que se generaron.
Además, un mismo evento puede recibirse más de una vez. Stripe recomienda guardar los identificadores de los eventos procesados y evitar repetir el trabajo.
Sin idempotencia podría ocurrir:
Primer webhook:
Pedido marcado como pagado.
Correo enviado.
Reserva convertida en venta.
Segundo webhook:
Pedido marcado otra vez.
Segundo correo.
Segundo descuento.
Segundo aviso de preparación.
Un webhook robusto debe poder ejecutarse dos, cinco o diez veces y producir el mismo resultado final.
Una forma de conseguirlo es almacenar:
stripeeventid.stripesessionid.- Estado actual del pedido.
- Fecha de procesamiento.
Antes de actuar:
- Comprueba si el evento ya se procesó.
- Comprueba si el pedido ya está pagado.
- Aplica el cambio solo si la transición es válida.
- Guarda el evento como procesado.
Idempotencia al crear la sesión
Los webhooks no son el único punto donde puede aparecer duplicación.
Imagina que el backend solicita crear una Checkout Session, pero la conexión se corta antes de recibir la respuesta.
No sabe si Stripe la creó.
Si repite la solicitud sin control, podría crear una segunda sesión.
Stripe admite claves de idempotencia para solicitudes POST. Al reutilizar la misma clave en un reintento, Stripe devuelve el mismo resultado en lugar de ejecutar otra vez la operación.
Una clave puede relacionarse con el pedido interno:
checkout_order_12345
No debería contener correos, teléfonos ni datos personales.
Los pagos no siempre se confirman inmediatamente
Con una tarjeta, el resultado suele llegar rápidamente.
Pero algunos métodos de pago pueden ser asíncronos.
Stripe contempla eventos como:
checkout.session.completedcheckout.session.asyncpaymentsucceededcheckout.session.asyncpaymentfailedcheckout.session.expired
Los eventos asíncronos indican que un método pendiente ha terminado confirmándose o fallando.
Por eso no siempre basta con interpretar checkout.session.completed como dinero disponible.
También conviene comprobar:
payment_status.- El método de pago.
- Los eventos asíncronos activados.
- La configuración de Checkout.
Un pedido podría necesitar estados como:
pending_payment
processing_payment
paid
payment_failed
expired
cancelled
refunded
Cuanto más explícitos sean los estados, menos lógica ambigua habrá.
Cómo gestioné la expiración del pago
Una Checkout Session no permanece abierta indefinidamente.
Stripe permite configurar su expiración entre 30 minutos y 24 horas después de crearla. Si no se configura, el valor predeterminado es 24 horas.
Para un drop pequeño con stock limitado, una reserva de 24 horas puede ser excesiva.
Una persona podría bloquear la última unidad durante todo el día sin pagar.
Por eso, en una implementación con stock reservado, tiene sentido utilizar el mínimo de 30 minutos o un periodo cercano, dependiendo del negocio.
El flujo sería:
- El cliente inicia Checkout.
- La unidad queda reservada.
- La sesión permanece disponible durante 30 minutos.
- Si paga, la reserva se confirma como venta.
- Si la sesión expira, Stripe envía
checkout.session.expired. - La aplicación libera la reserva.
El evento de expiración existe específicamente para informar de que una Checkout Session ha caducado.
El stock no debería liberarse solo con un temporizador local
Podría crear un proceso que revisara cada minuto las reservas antiguas.
Pero si Stripe ya conoce el estado de la sesión, resulta más consistente escuchar su evento de expiración.
Aun así, no dependería de un único mecanismo.
Un sistema robusto puede combinar:
- Webhook
checkout.session.expired. - Fecha
reserved_untilen D1. - Tarea periódica de limpieza.
- Comprobación antes de crear nuevas reservas.
Así, si un webhook falla, una reserva no queda bloqueada para siempre.
El problema entre D1 y Stripe
Existe una dificultad que no desaparece por usar transacciones.
D1 y Stripe son dos sistemas diferentes.
No puedo crear una única transacción que incluya simultáneamente:
- Reservar stock en D1.
- Crear una Checkout Session en Stripe.
Puede ocurrir esto:
- D1 reserva el stock.
- La llamada a Stripe falla.
- El cliente nunca recibe una sesión.
- La unidad queda bloqueada.
Por eso se necesita una acción compensatoria.
Si Stripe no crea la sesión:
- El pedido se marca como fallido.
- La reserva se libera.
- El cliente recibe un error controlado.
También puede hacerse en otro orden, pero aparece el problema inverso: crear una sesión y después no conseguir reservar.
No existe un orden perfecto sin gestionar fallos.
La solución es diseñar explícitamente qué ocurre cuando una parte funciona y la otra no.
Estados del pedido que evitan el caos
No utilizaría solamente dos estados:
pagado
no pagado
Un pedido puede atravesar más situaciones.
Por ejemplo:
| Estado | Significado |
|---|---|
| creating | Se está preparando la operación |
| pending_payment | Existe sesión y se espera pago |
| payment_processing | El método todavía se está procesando |
| paid | Stripe confirmó el pago |
| expired | La sesión caducó |
| payment_failed | El pago falló |
| cancelled | El pedido se canceló |
| preparing | Se está preparando |
| shipped | Fue enviado |
| refunded | El dinero se devolvió |
Las transiciones también deberían tener reglas.
Por ejemplo:
pending_payment → paid
pending_payment → expired
payment_processing → paid
payment_processing → payment_failed
paid → refunded
No tendría sentido permitir:
refunded → preparing
expired → paid
salvo que exista una recuperación específicamente diseñada.
Información que guardaría en el pedido
Como mínimo:
- ID interno.
- Estado.
- Moneda.
- Subtotal.
- Envío.
- Impuestos.
- Total.
- Fecha de creación.
- Fecha de pago.
- Stripe Session ID.
- Payment Intent ID cuando exista.
- Email.
- Dirección.
- Líneas de pedido.
- Precio unitario capturado.
- Cantidades.
- Identificadores de producto y variante.
Guardar una copia del precio de cada línea es importante.
Si mañana cambio una camiseta de 39,90 € a 44,90 €, los pedidos antiguos deben seguir mostrando el importe que realmente pagó el cliente.
No deberían recalcularse consultando el precio actual del catálogo.
Los errores que más quería evitar
Confiar en el total del carrito
El servidor debe reconstruirlo.
Confirmar mediante la página de éxito
El webhook debe confirmar el pago.
Guardar la clave secreta en el frontend
Solo debe existir en el servidor.
No comprobar la firma
Un endpoint público puede recibir peticiones falsas.
Descontar la reserva dos veces
Reservar y confirmar son operaciones diferentes.
No liberar carritos abandonados
El stock quedaría bloqueado.
Crear sesiones duplicadas
Se necesitan controles e idempotencia.
Procesar dos veces el webhook
El pedido y el stock se descuadrarían.
Suponer que los eventos llegan en orden
Stripe advierte que el orden no está garantizado.
No contemplar pagos asíncronos
Un Checkout completado no siempre equivale inmediatamente a un pago definitivo.
No comprobar el importe del evento
El webhook debe coincidir con el pedido interno.
Registrar demasiados datos
Los logs no deberían guardar direcciones completas, secretos o información innecesaria.
Qué ocurre cuando alguien pulsa dos veces el botón
El doble clic es un caso real.
Puede producir:
- Dos peticiones.
- Dos pedidos.
- Dos reservas.
- Dos sesiones.
La interfaz puede desactivar temporalmente el botón, pero eso solo mejora la experiencia.
No es una medida de seguridad.
El backend también necesita reconocer que una solicitud ya está en curso.
Puede hacerlo mediante:
- Un identificador de intento.
- Una clave de idempotencia.
- Un pedido reutilizable.
- Una restricción temporal.
- Comprobaciones del estado.
La seguridad nunca debería depender únicamente de que el botón se bloquee visualmente.
Qué pasa si el webhook tarda
La página de éxito puede abrirse antes de que el webhook haya terminado de procesarse.
En ese caso no mostraría inmediatamente:
Pedido confirmado y en preparación.
Mostraría algo como:
Hemos recibido el proceso de pago. Estamos confirmando tu pedido.
La página puede consultar el estado interno durante unos segundos.
Cuando el webhook actualice D1, mostrará la confirmación final.
Esto evita que la interfaz prometa algo que el backend todavía no ha registrado.
Qué pasa si el webhook falla durante varias horas
Stripe reintenta, pero mi sistema también debería permitir reconciliar pedidos.
Una revisión de conciliación puede buscar:
- Pedidos pendientes demasiado antiguos.
- Sesiones de Stripe ya completadas.
- Pagos sin pedido confirmado.
- Reservas caducadas.
- Importes que no coinciden.
Una tienda seria no debería depender de que cada evento funcione perfectamente a la primera.
Debe poder detectar y reparar inconsistencias.
Los correos se envían después de confirmar
No enviaría el correo definitivo de pedido al crear la sesión.
En ese momento todavía no existe una venta.
Podría enviarse un correo de “pago pendiente” en casos concretos, pero la confirmación debería salir después de que el backend registre el pago.
También aplicaría idempotencia al correo.
No basta con que el pedido sea idempotente si cada reintento del webhook manda un mensaje nuevo.
Los reembolsos también forman parte de la integración
Cobrar es solo el principio.
Una tienda debe contemplar:
- Reembolso total.
- Reembolso parcial.
- Cancelación antes del envío.
- Devolución.
- Disputa.
- Contracargo.
- Reposición del stock.
El reembolso realizado en Stripe debería reflejarse en el estado interno.
Y devolver el dinero no significa necesariamente devolver stock.
Si el producto ya se envió y todavía no ha regresado, no debería reaparecer automáticamente como disponible.
La lógica comercial y la lógica financiera están relacionadas, pero no son idénticas.
Cómo probé la integración
Antes de activar pagos reales, una integración debería probarse en modo de prueba.
No solo con el caso perfecto.
La matriz mínima debería cubrir:
Pago correcto
- Se crea una sola sesión.
- El importe coincide.
- El pedido queda pagado.
- El stock queda correcto.
- Se envía una sola confirmación.
Tarjeta rechazada
- El pedido no se marca como pagado.
- El stock no queda vendido.
- El usuario recibe un mensaje comprensible.
Autenticación 3D Secure
- La redirección funciona.
- La confirmación llega correctamente.
Pago abandonado
- La reserva expira.
- El stock se libera.
Webhook repetido
- No se descuenta nada dos veces.
- No se duplica el correo.
- No se genera otro envío.
Eventos fuera de orden
- El sistema consulta el estado real cuando lo necesita.
- No depende de una secuencia perfecta.
Doble clic
- Solo se genera una operación válida.
Error de Stripe después de reservar
- La reserva se libera.
Error de D1
- No se crea una sesión sin pedido controlable.
Importe manipulado
- El backend ignora el precio del navegador.
Cantidad superior al stock
- La solicitud se rechaza.
Producto desactivado
- No puede comprarse aunque permanezca en un carrito antiguo.
Entorno equivocado
- Los eventos de prueba no modifican pedidos reales.
Cobrar no admite un “parece que funciona”.
Tiene que funcionar también cuando algo sale mal.
Lo que Stripe no resuelve por mí
Aunque Stripe Checkout sea sólido, todavía tengo que ocuparme de:
- IVA.
- Facturación.
- Envíos.
- Devoluciones.
- Protección de datos.
- Condiciones de compra.
- Atención al cliente.
- Control de stock.
- Correos.
- Conciliación.
- Copias de seguridad.
- Seguridad del panel.
- Acceso administrativo.
Stripe procesa dinero.
No dirige el negocio.
Lo que aprendí construyéndolo
1. La seguridad suele ser invisible
El comprador no ve:
- La validación del precio.
- La firma.
- La idempotencia.
- Las condiciones de stock.
- Los estados.
- La conciliación.
Pero esas son las partes que evitan los problemas más caros.
2. El frontend no es una fuente fiable
Puede mostrar información y recoger decisiones.
No debería tener autoridad sobre precios, permisos, stock ni estados financieros.
3. Los pagos son eventos, no una única pantalla
La compra no termina al pulsar pagar.
Puede haber:
- Inicio.
- Autenticación.
- Procesamiento.
- Confirmación.
- Expiración.
- Reembolso.
- Disputa.
El modelo de datos debe reflejarlo.
4. Las plataformas no cobran solo por un formulario
Cuando una plataforma cobra una cuota, también está cobrando por haber resuelto cientos de estos casos.
Construirlo personalmente da control, pero también transfiere la responsabilidad.
5. Una tienda propia no significa cero proveedores
Mi código es propio, pero Cloudflare y Stripe siguen siendo piezas fundamentales.
La ventaja está en controlar la arquitectura y poder sustituir partes, no en fingir que no existen dependencias.
6. El coste técnico también existe
Puedo reducir las cuotas mensuales, pero tengo que valorar:
- Horas de desarrollo.
- Mantenimiento.
- Pruebas.
- Incidencias.
- Evolución.
La integración directa tiene sentido porque también forma parte de mi trabajo y de lo que quiero aprender y ofrecer.
¿Deberías integrar Stripe tú mismo?
Depende de tu situación.
Utilizaría una plataforma cuando:
- Necesitas vender inmediatamente.
- No quieres mantener código.
- Tienes muchos productos.
- Necesitas aplicaciones estándar.
- Prefieres pagar una cuota previsible.
- No te interesa la infraestructura.
- El tiempo de salida es prioritario.
Consideraría una integración directa cuando:
- Necesitas una experiencia propia.
- Quieres controlar el código.
- Tienes conocimientos técnicos.
- El catálogo es manejable.
- Quieres reducir dependencias.
- Necesitas funciones específicas.
- Estás dispuesto a probar y mantener el sistema.
- Comprendes que el pago no es la única parte de una tienda.
No la construiría desde cero cuando:
- No puedes explicar cómo se confirma un pago.
- No sabes proteger claves.
- No vas a utilizar webhooks.
- No puedes gestionar condiciones de carrera.
- No tienes forma de revisar errores.
- No quieres mantener el sistema después.
En ese caso, una plataforma administrada es una decisión más sensata.
Una nota sobre precios y cumplimiento
Stripe publica sus tarifas por país y tipo de tarjeta, y pueden cambiar. En España, la página oficial distingue actualmente entre tarjetas estándar del Espacio Económico Europeo, tarjetas prémium y otros tipos. Para un cálculo real hay que consultar la tarifa vigente y considerar reembolsos, disputas, conversión de moneda e impuestos aplicables.
Checkout reduce el alcance de la interfaz de pago que debes construir, pero no sustituye tus obligaciones comerciales, fiscales o de protección de datos. Esta guía describe mi arquitectura técnica, no asesoramiento legal o contable.
Mi conclusión
No necesitas Shopify para aceptar pagos de forma profesional.
Pero tampoco basta con conectar un botón a Stripe y decir que ya tienes una tienda.
Una integración fiable necesita como mínimo:
- Precios calculados en el servidor.
- Productos y variantes validados.
- Stock controlado.
- Pedido interno.
- Relación entre pedido y Checkout Session.
- Claves protegidas.
- Firma del webhook verificada.
- Procesamiento idempotente.
- Gestión de expiraciones.
- Control de errores.
- Pruebas.
- Conciliación.
La parte visual del checkout fue relativamente sencilla.
La parte importante fue construir todo lo que ocurre alrededor.
Ese es el trabajo que el cliente no ve, pero que evita que el dinero, los pedidos y el stock terminen contando historias diferentes.
No elegí esta arquitectura para poder decir que mi web utiliza Stripe o Cloudflare.
La elegí porque quería entender qué ocurre desde que una persona pulsa pagar hasta que el pedido queda realmente confirmado.
Y porque esa es también la base del servicio que quiero ofrecer: ayudar a pequeños negocios a pasar de una idea a una marca y una tienda preparada para vender, sin añadir tecnología porque sí, pero sin tratar los pagos como un detalle menor.


