Payment Intents (flujo de pago personalizado)
Receta
Crea un PaymentIntent en el servidor para obtener un client secret, pásalo al proveedor Elements, renderiza un PaymentElement para la entrada de tarjeta y confirma el pago en el cliente.
Crea el PaymentIntent en el servidor:
// app/actions/payment.ts
"use server";
import { stripe } from "@/lib/stripe";
export async function createPaymentIntent(amount: number) {
const paymentIntent = await stripe.paymentIntents.create({
amount, // en centavos
currency: "usd",
automatic_payment_methods: { enabled: true },
});
return { clientSecret: paymentIntent.client_secret! };
}Envuelve el formulario de checkout con Elements usando el client secret:
// app/checkout/page.tsx
"use client";
import { useEffect, useState } from "react";
import { Elements } from "@stripe/react-stripe-js";
import { stripePromise } from "@/lib/stripe-client";
import { createPaymentIntent } from "@/app/actions/payment";
import { CheckoutForm } from "./checkout-form";
export default function CheckoutPage() {
const [clientSecret, setClientSecret] = useState<string | null>(null);
useEffect(() => {
createPaymentIntent(2999).then(({ clientSecret }) => {
setClientSecret(clientSecret);
});
}, []);
if (!clientSecret) return <div>Cargando...</div>;
return (
<Elements stripe={stripePromise} options={{ clientSecret }}>
<CheckoutForm />
</Elements>
);
}Construye el formulario de checkout:
// app/checkout/checkout-form.tsx
"use client";
import { useState, type FormEvent } from "react";
import {
useStripe,
useElements,
PaymentElement,
} from "@stripe/react-stripe-js";
export function CheckoutForm() {
const stripe = useStripe();
const elements = useElements();
const [error, setError] = useState<string | null>(null);
const [processing, setProcessing] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!stripe || !elements) return;
setProcessing(true);
setError(null);
const { error: submitError } = await elements.submit();
if (submitError) {
setError(submitError.message ?? "Error de validación.");
setProcessing(false);
return;
}
const { error: confirmError } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/success`,
},
});
if (confirmError) {
setError(confirmError.message ?? "Pago fallido.");
setProcessing(false);
}
// Si tiene éxito, Stripe redirige a return_url
}
return (
<form onSubmit={handleSubmit} className="max-w-md mx-auto p-6">
<PaymentElement />
{error && <p className="text-red-500 mt-4">{error}</p>}
<button
type="submit"
disabled={!stripe || processing}
className="w-full mt-6 bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{processing ? "Procesando..." : "Pagar $29.99"}
</button>
</form>
);
}Ejemplo funcional
// app/donate/page.tsx
"use client";
import { useEffect, useState } from "react";
import { Elements } from "@stripe/react-stripe-js";
import { stripePromise } from "@/lib/stripe-client";
import { createPaymentIntent } from "@/app/actions/payment";
import { CheckoutForm } from "@/app/checkout/checkout-form";
const DONATION_AMOUNTS = [500, 1000, 2500, 5000];
export default function DonatePage() {
const [amount, setAmount] = useState(1000);
const [clientSecret, setClientSecret] = useState<string | null>(null);
useEffect(() => {
setClientSecret(null);
createPaymentIntent(amount).then(({ clientSecret }) => {
setClientSecret(clientSecret);
});
}, [amount]);
return (
<div className="max-w-md mx-auto p-8">
<h1 className="text-2xl font-bold mb-6">Haz una donación</h1>
<div className="flex gap-2 mb-6">
{DONATION_AMOUNTS.map((amt) => (
<button
key={amt}
onClick={() => setAmount(amt)}
className={`px-4 py-2 rounded ${
amount === amt
? "bg-blue-600 text-white"
: "bg-gray-200 text-gray-800"
}`}
>
${(amt / 100).toFixed(0)}
</button>
))}
</div>
{clientSecret ? (
<Elements
stripe={stripePromise}
options={{ clientSecret }}
key={clientSecret}
>
<CheckoutForm />
</Elements>
) : (
<div>Cargando formulario de pago...</div>
)}
</div>
);
}Profundización
Cómo funciona
- Un PaymentIntent representa un único intento de pago en los servidores de Stripe. Rastrea el ciclo de vida desde la creación hasta la confirmación y la finalización.
- El
client_secretes un token que permite al código del cliente confirmar el pago sin exponer tu clave secreta. Nunca debe registrarse ni almacenarse. automatic_payment_methods: { enabled: true }indica a Stripe que muestre dinámicamente los mejores métodos de pago según la ubicación del cliente y la moneda de la transacción.elements.submit()valida todos los campos del formulario antes de la confirmación. Llámalo siempre antes deconfirmPaymentpara detectar errores de validación de forma temprana.stripe.confirmPaymentenvía los datos de pago directamente desde el navegador a Stripe. Los datos de la tarjeta nunca pasan por tu servidor.- Tras una confirmación exitosa, Stripe redirige a
return_url. Para 3D Secure, Stripe gestiona automáticamente el flujo de autenticación.
Variaciones
Confirmar sin redirección (permanecer en la página):
const { error, paymentIntent } = await stripe.confirmPayment({
elements,
redirect: "if_required",
});
if (error) {
setError(error.message ?? "Pago fallido.");
} else if (paymentIntent?.status === "succeeded") {
// Mostrar mensaje de éxito sin redirigir
setSuccess(true);
}Añadir metadata al PaymentIntent:
const paymentIntent = await stripe.paymentIntents.create({
amount: 2999,
currency: "usd",
automatic_payment_methods: { enabled: true },
metadata: {
userId: user.id,
productId: product.id,
},
});Notas de TypeScript
stripe.paymentIntents.createdevuelvePromise<Stripe.PaymentIntent>.- El tipo de retorno de
confirmPaymentincluye{ error?: StripeError; paymentIntent?: PaymentIntent }. - Cuando uses
redirect: "if_required", comprueba siempre tantoerrorcomopaymentIntenten el resultado.
import type { StripeError } from "@stripe/stripe-js";
function handleError(error: StripeError) {
switch (error.type) {
case "card_error":
return error.message;
case "validation_error":
return "Revisa los datos de tu tarjeta.";
default:
return "Ocurrió un error inesperado.";
}
}Errores comunes
- Cada PaymentIntent debe crearse una vez por intento de checkout. No crees uno nuevo cada vez que el componente se re-renderiza: usa
useEffectcon dependencias estables. - El
client_secretcontiene el ID del PaymentIntent. Trátalo como sensible y no lo expongas en URLs ni en logs. - Cuando cambia el importe, necesitas un PaymentIntent nuevo. Usa la prop
keyenElementspara forzar un remontaje completo cuando cambie elclientSecret. confirmPaymentconredirect: "always"(el valor por defecto) siempre redirige, incluso para pagos con tarjeta. Usaredirect: "if_required"si quieres permanecer en la página.- El importe del PaymentIntent está en la unidad más pequeña de la moneda. Para USD, 2999 significa $29.99. Para JPY (moneda sin decimales), 2999 significa 2999 yenes.
Alternativas
| Enfoque | Ventajas | Desventajas |
|---|---|---|
| PaymentIntent + PaymentElement | Control total de la UI, admite todos los métodos de pago | Más código que Checkout |
| Stripe Checkout | Código mínimo, UI alojada | Personalización limitada |
| SetupIntent | Guarda la tarjeta sin cobrar | Requiere un paso de cobro separado más adelante |
| PaymentIntent + CardElement | Control granular a nivel de campo de tarjeta | Solo admite pagos con tarjeta |
FAQs
¿Qué es un PaymentIntent y qué papel desempeña el client_secret?
- Un PaymentIntent representa un único intento de pago rastreado en los servidores de Stripe
- El
client_secretes un token que permite al código del cliente confirmar el pago sin exponer tu clave API secreta - Nunca debe registrarse, almacenarse en una base de datos ni exponerse en URLs
¿Por qué debes llamar a elements.submit() antes de stripe.confirmPayment()?
elements.submit() valida primero todos los campos del formulario. Llamarlo antes de confirmPayment detecta errores de validación (campos faltantes, formato de tarjeta inválido) de forma temprana, lo que ofrece una mejor experiencia de usuario antes de intentar el cobro real.
¿Qué hace automatic_payment_methods: { enabled: true }?
Indica a Stripe que seleccione y muestre dinámicamente los mejores métodos de pago según la ubicación del cliente, la moneda de la transacción y la configuración de tu Stripe Dashboard, sin que tengas que codificar métodos específicos de forma rígida.
Trampa: ¿Qué ocurre si creas un PaymentIntent nuevo en cada re-renderizado del componente?
Terminas con PaymentIntents huérfanos en los servidores de Stripe y un comportamiento potencialmente confuso. Crea siempre el PaymentIntent en un useEffect con dependencias estables para que se ejecute una vez por intento de checkout.
¿Cómo permaneces en la página tras el pago en lugar de redirigir?
const { error, paymentIntent } = await stripe.confirmPayment({
elements,
redirect: "if_required",
});
if (paymentIntent?.status === "succeeded") {
setSuccess(true);
}Usa redirect: "if_required": solo redirige cuando el método de pago lo requiere (p. ej., 3D Secure).
¿Por qué se necesita la prop key en el componente Elements cuando cambia el clientSecret?
Cambiar solo la prop clientSecret no restablece el estado interno de Stripe Elements. Usar key={clientSecret} obliga a React a desmontar y volver a montar el componente <Elements>, creando una instancia nueva vinculada al PaymentIntent nuevo.
¿Cómo adjuntas metadata a un PaymentIntent?
const paymentIntent = await stripe.paymentIntents.create({
amount: 2999,
currency: "usd",
automatic_payment_methods: { enabled: true },
metadata: { userId: user.id, productId: product.id },
});La metadata es útil para correlacionar pagos con los datos de tu app en los manejadores de webhooks.
¿Cuál es la diferencia entre los importes de PaymentIntent para USD frente a JPY?
Los importes están en la unidad más pequeña de la moneda. Para USD, 2999 = $29.99 (centavos). Para JPY (una moneda sin decimales), 2999 = 2999 yenes. Comprueba siempre si la moneda tiene decimales.
TypeScript: ¿Cómo está estructurado el tipo de retorno de confirmPayment?
// Devuelve { error?: StripeError; paymentIntent?: PaymentIntent }
// Cuando uses redirect: "if_required", comprueba siempre ambos campos
const { error, paymentIntent } = await stripe.confirmPayment({
elements,
redirect: "if_required",
});TypeScript: ¿Cómo gestionas distintos tipos de StripeError con type narrowing?
import type { StripeError } from "@stripe/stripe-js";
function handleError(error: StripeError) {
switch (error.type) {
case "card_error":
return error.message;
case "validation_error":
return "Revisa los datos de tu tarjeta.";
default:
return "Ocurrió un error inesperado.";
}
}Trampa: ¿Cuál es el comportamiento de redirección por defecto de confirmPayment?
El valor por defecto es redirect: "always", que redirige al usuario incluso para pagos con tarjeta simples que no requieren 3D Secure. Si quieres gestionar el resultado en el cliente, debes establecer explícitamente redirect: "if_required".