Integración de Stripe Checkout para el comercio electrónico internacional: casos excepcionales y lecciones
La documentación de Stripe es excelente y la integración inicial suele funcionar al primer intento. Sin embargo, el comercio electrónico internacional plantea problemas que la guía de inicio rápido no cubre: la fiabilidad de la entrega de webhooks durante reinicios de infraestructura, los matices de los reembolsos en monedas distintas de la de liquidación y la carga operativa de gestionar claves de API en varios entornos. Este artículo documenta los casos excepcionales que encontramos y los cambios que hicimos a raíz de ellos.
El modelo de Checkout Session y sus supuestos
Stripe Checkout es una página de pago alojada por Stripe. Se crea una sesión en el servidor, se redirige al cliente y Stripe llama al webhook cuando se completa el pago. El flujo es sencillo hasta que se opera con varias monedas o se necesita inspeccionar el objeto de pago completo al preparar y enviar el pedido.
Por defecto, una respuesta de Checkout Session contiene una representación mínima del pago. El campo payment_intent es un identificador de texto, no el objeto expandido. Esto importa cuando la lógica de preparación y envío de pedidos necesita leer payment_intent.latest_charge.payment_method_details ; por ejemplo, para registrar el país de la tarjeta en la lógica de determinación del IVA. (En las versiones de API anteriores a 2022-11-15, la ruta era payment_intent.charges.data[0]; Stripe eliminó la propiedad charges de PaymentIntents en esa versión). Los eventos de webhook nunca incluyen objetos expandidos, por lo que obtener estos datos al preparar y enviar el pedido exige una segunda llamada a la API por evento.
El parámetro expand acepta un arreglo de rutas separadas por puntos. Solo se aplica a la respuesta de la llamada a la API que lo solicita: establecerlo al crear la sesión no modifica el objeto de sesión que Stripe envía después en el webhook, porque Stripe siempre envía los objetos de los eventos en su forma mínima. En el manejador del webhook, recupere la sesión expandiendo ["payment_intent.latest_charge"]. A cambio, se requiere una llamada adicional a la API por evento y la respuesta es mayor, algo relevante si se guardan las respuestas sin procesar en un almacén de registros.
Fiabilidad de los webhooks
La entrega de webhooks de Stripe se basa en reintentos: si el endpoint no devuelve un estado 2xx o se agota el tiempo de espera, Stripe reintenta la entrega con espera exponencial durante un máximo de tres días en modo de producción; en el entorno de pruebas aislado (sandbox), reintenta los eventos tres veces a lo largo de unas horas. Es un buen diseño, pero plantea un problema de corrección si el manejador no es idempotente. La creación de pedidos, la reserva de inventario y el envío de correos electrónicos son efectos secundarios que deben ejecutarse exactamente una vez.
La solución habitual es registrar los identificadores de los eventos procesados antes de ejecutar los efectos secundarios. Utilizamos una tabla sencilla en la base de datos, con el identificador de evento de Stripe como clave primaria. El manejador lee el campo id recibido, intenta insertar un registro y solo continúa si la inserción tiene éxito. Un identificador duplicado provoca un conflicto y el manejador devuelve 200 de inmediato. Para que esto sea seguro, la inserción debe confirmarse en la misma transacción de base de datos que los efectos secundarios (o el registro debe marcarse como procesado solo después de que estos se completen). De lo contrario, un fallo después de la inserción hará que el reintento de Stripe parezca un duplicado y el pedido nunca se preparará ni se enviará.
La documentación no destaca suficientemente la diferencia temporal entre recibir un evento checkout.session.completed y que el pago se capture realmente en el proceso de liquidación de Stripe. Para algunos métodos de pago —en particular las transferencias bancarias y ciertos instrumentos de compra ahora y pago después (BNPL) disponibles en Alemania y los Países Bajos—, la sesión de pago se completa antes de confirmar los fondos. En esos casos, la sesión se completa con payment_status igual a unpaid, y los eventos correctos son checkout.session.async_payment_succeeded para iniciar la preparación y el envío del pedido y checkout.session.async_payment_failed para gestionar el fallo. Preparar y enviar todos los pedidos al recibir checkout.session.completed sin comprobar payment_status hará que se envíen pedidos cuyo pago falle después.
También encontramos un problema con el ciclo de reinicio de nuestros contenedores. Durante un despliegue, las solicitudes de webhook en curso pueden llegar justo cuando el contenedor anterior se está deteniendo y el nuevo está arrancando. Nginx mantiene las conexiones durante la transición del servicio de destino, pero observamos pérdida de eventos durante un breve intervalo. La solución fue añadir ajustes explícitos de keepalive_timeout y proxy_read_timeout a nuestra configuración de Nginx y confiar en el mecanismo de reintentos de Stripe, en lugar de intentar eliminar todo tiempo de inactividad del despliegue en el receptor de webhooks.
Gestión de claves de producción y de pruebas
El error más común que vemos en las integraciones de Stripe es no separar completamente las claves de producción y las de pruebas. El caso evidente es utilizar por accidente una clave de producción en un entorno de pruebas y generar cargos reales. El menos evidente es más sutil: utilizar claves de modo de prueba en un entorno de preproducción que comparte una base de datos con datos similares a los de producción.
Nuestra convención es tener tres entornos, cada uno con su propio conjunto completo de credenciales de Stripe: development, stagingy production. El desarrollo utiliza el modo de prueba de Stripe. La preproducción también utiliza el modo de prueba, pero con un conjunto distinto de claves restringidas y un endpoint de webhook independiente registrado en el panel de Stripe. La producción utiliza el modo real. Todas las claves se inyectan en tiempo de ejecución como variables de entorno: nunca se guardan en el control de versiones ni se incorporan a las imágenes Docker durante la compilación.
El secreto de firma del webhook es una credencial independiente de la clave de API. Cada endpoint registrado en el panel de Stripe tiene su propio secreto de firma. Si hay tres entornos, hay tres secretos. Es fácil copiar el equivocado, sobre todo al configurar un nuevo despliegue. Documentamos explícitamente la correspondencia en nuestro manual operativo interno: cuenta de Stripe, endpoint, secreto de firma y prefijo de la clave. Parece trabajo adicional, pero la alternativa es una sesión de depuración en la que los eventos de producción fallan en la verificación de firma sin que se advierta.
La función de claves restringidas de Stripe se utiliza poco. En lugar de entregar a cada servicio la clave secreta completa, se pueden crear claves limitadas a las operaciones que ese servicio necesita. Un worker de gestión logística de pedidos que solo necesita leer intenciones de pago y crear reembolsos no necesita permisos de escritura sobre productos o suscripciones. Emitimos claves restringidas por servicio y las rotamos independientemente de la clave secreta principal.
Gestión de monedas y el problema de los reembolsos
Stripe admite más de 135 monedas de presentación, es decir, monedas en las que paga el cliente, pero la moneda de liquidación depende del país de la cuenta de Stripe. Si la cuenta liquida en EUR y un cliente paga en SGD, Stripe convierte la moneda al realizar el cargo. Esto resulta transparente hasta que se emite un reembolso.
Los reembolsos se procesan en la moneda de presentación: la moneda en la que pagó el cliente. Sin embargo, el importe que se descuenta del saldo de Stripe se calcula con el tipo de cambio vigente al emitir el reembolso, no con el del cargo original. Si el tipo de cambio ha evolucionado desfavorablemente entre el cargo y el reembolso, se puede devolver más de lo recibido en la moneda de liquidación. No es un fallo, sino el comportamiento previsto. Está documentado en la guía de Stripe sobre reembolsos y disputas, pero es fácil pasarlo por alto y tiene consecuencias reales sobre los costos cuando se trabaja con márgenes estrechos y varias monedas. (A octubre de 2026, Adaptive Pricing de Stripe es una excepción: sus reembolsos usan el tipo de cambio de la transacción original).
La mitigación práctica consiste en limitar las monedas aceptadas a aquellas cuya exposición cambiaria se comprende o mantener una reserva de fondos que contemple las variaciones del tipo de cambio. Para operaciones de gran volumen, las cuentas de liquidación en varias monedas de Stripe permiten mantener saldos en monedas concretas y liquidar cada una por separado. Esto elimina la conversión al reembolsar en esas monedas. A cambio, la gestión de tesorería se vuelve más compleja.
También encontramos un caso excepcional con reembolsos parciales de pedidos cobrados en una moneda distinta de nuestra moneda base interna. (Una sola Checkout Session utiliza una única moneda, compartida por todas las partidas del pedido). Los reembolsos parciales requieren especificar un importe en la moneda del cargo original. Si el sistema de gestión de pedidos almacena importes en la moneda base interna, hay que guardar también el importe y la moneda de presentación originales en el registro del pedido, no solo el importe convertido. Añadimos ambos campos a nuestra tabla de pedidos después de que el primer intento de reembolso parcial fallara con un error de validación confuso.
Disponibilidad de métodos de pago por país
La función de métodos de pago dinámicos de Stripe Checkout permite que el formulario muestre métodos pertinentes según la ubicación del cliente. Es cómoda, pero requiere cuidado. Cuando se deja que Stripe elija los métodos según la configuración del panel en lugar de pasar una lista explícita, el conjunto de métodos disponibles no es completamente predecible al crear la sesión: depende de factores que Stripe evalúa al renderizar el formulario.
Esto afecta a la suite de pruebas. Si se comprueba que aparecen métodos de pago concretos, las pruebas se comportarán de forma distinta según la región. Escribimos nuestras pruebas de integración con el parámetro explícito payment_method_types en lugar de utilizar métodos de pago dinámicos, lo que nos da un conjunto determinista. Checkout en producción utiliza métodos de pago dinámicos. Esta diferencia es deliberada y está documentada.
SEPA Direct Debit, iDEAL, Bancontact y Przelewy24 tienen requisitos que no se aplican a los pagos con tarjeta. Checkout alojado recopila por sí mismo los datos específicos de cada método —el IBAN y la aceptación del mandato para SEPA, por ejemplo—, pero existen otras restricciones: SEPA Direct Debit en Checkout exige que todas las partidas tengan precios en EUR y es un método con notificación diferida, por lo que se aplican los eventos asíncronos anteriores. Probamos cada método habilitado en el proceso de pago completo, no solo en la confirmación del pago.
Qué cambiamos tras los primeros problemas en producción
Después de los primeros meses de transacciones reales, hicimos tres cambios estructurales. Primero, trasladamos el almacenamiento de claves de idempotencia desde la memoria a la base de datos, porque la memoria no conserva los datos tras reiniciar contenedores. Segundo, añadimos registros explícitos del identificador de evento de Stripe, su tipo y el resultado de cada invocación del manejador de webhooks, no solo de los errores. Esto permitió reconstruir la secuencia de eventos al diagnosticar discrepancias en el estado de los pedidos. Tercero, dejamos de utilizar el registro de eventos del panel de Stripe como principal herramienta de depuración y empezamos a considerar nuestros propios registros como fuente de referencia.
El panel de Stripe es útil para explorar eventos de forma interactiva, pero muestra la perspectiva de Stripe sobre la entrega, no la de la aplicación sobre la recepción. Una entrega que el panel marca como correcta solo significa que nuestro endpoint devolvió un estado 2xx; no permite saber si el trabajo posterior a esa respuesta se completó. Nuestros registros mostraban toda la secuencia; el panel ofrecía una versión incompleta.