phpguzzle.org
enrudees
Stripe – Docs

Stripe Checkout en PHP

¿Qué es Stripe Checkout? Es el formulario de pago que Stripe sirve por ti: tú no construyes campos de tarjeta, no manejas PCI, y la integración de Stripe Checkout en PHP se reduce a crear una Checkout\Session en el backend, pasarle la URL al cliente, y escuchar el webhook cuando el dinero se acredita. La interfaz viene en tres variantes: hosted (página en el dominio de Stripe), checkout embebido o embedded (iframe dentro de tu página) y custom (primitivas de Checkout que tú organizas en tu propio HTML, GA desde principios de 2025).

Esta guía cubre el lado PHP de las tres con ejemplos funcionales de cada una. Para crear una checkout session en PHP basta con unas pocas líneas del SDK stripe-php; el código del lado del cliente es solo lo necesario para que los ejemplos funcionen.

Checkout Session vs PaymentIntent vs Payment Element

Estos tres nombres aparecen por toda la documentación, y buena parte de las preguntas en Stack Overflow vienen de confundirlos.

  • PaymentIntent es el objeto de pago de bajo nivel. Contiene monto, moneda, estado y un secreto. Todo pago en Stripe crea uno internamente.
  • Payment Element es un componente JavaScript de UI. Tú creas el PaymentIntent, montas el Element en tu página y manejas la confirmación en JS.
  • Checkout Session es un objeto de nivel superior que envuelve todo el flujo: UI, recolección de método de pago, impuestos, envío, cupones. Tú creas la Session, le pasas la URL al cliente, Stripe sirve la página. Internamente crea un PaymentIntent por ti.

Elegir uno es un balance entre control y tiempo de implementación. Si “una página con logotipo, line items, impuestos y Apple Pay” cubre lo que necesitas, usa Checkout. Si necesitas un flujo multi-paso con validación personalizada, usa Payment Element contra un PaymentIntent que manejes tú mismo. Empieza con Checkout; pasa a Elements solo cuando Checkout genuinamente no pueda hacer lo que el producto necesita.

Checkout también tiene su propio evento de webhook, checkout.session.completed, que se dispara cuando la sesión se paga. payment_intent.succeeded también se dispara, pero checkout.session.completed es el que escuchas cuando el flujo empezó desde una Session.

Los tres modos de UI

ModoDónde vive la UICómo regresa el cliente
HostedEn el dominio de Stripe (checkout.stripe.com)Redirect a success_url
EmbeddedIframe inline en tu páginareturn_url con {CHECKOUT_SESSION_ID}
CustomTu página, tu HTML, primitivas de Checkoutreturn_url con {CHECKOUT_SESSION_ID}

Hosted es el modo por defecto y el que muestran la mayoría de las guías. Embedded se hizo GA a finales de 2023 y es la opción estándar cuando quieres la UI de Checkout dentro de tu propia página sin el round-trip de redirección. Custom Checkout (ui_mode: 'custom') se hizo GA a principios de 2025 y es la elección correcta cuando el iframe embedded es demasiado rígido para el sistema de diseño que necesitas.

Session hosted: ejemplo de Stripe Checkout

Un endpoint que crea una Session y redirige el navegador a Stripe:

<?php
require __DIR__ . '/vendor/autoload.php';

\Stripe\Stripe::setApiKey(getenv('STRIPE_SECRET_KEY'));

$session = \Stripe\Checkout\Session::create([
    'mode' => 'payment',
    'line_items' => [[
        'price' => 'price_1OExampleHandle',
        'quantity' => 1,
    ]],
    'success_url' => 'https://example.com/thanks?session_id={CHECKOUT_SESSION_ID}',
    'cancel_url'  => 'https://example.com/cart',
    'metadata' => [
        'order_id' => $orderId,
    ],
]);

header('Location: ' . $session->url, true, 303);
exit;

Eso es todo el backend. El cliente no hace nada más que seguir la redirección.

mode decide el tipo de transacción. payment es un cobro único. subscription factura precios recurrentes. setup guarda un método de pago sin cobrar (útil para flujos de “agregar tarjeta a la cuenta”). Pasar payment_intent_data junto con 'mode' => 'subscription' devuelve invalid_request; en ese caso usa subscription_data.

line_items acepta dos formatos. El recomendado es un ID de price creado en el Dashboard (o vía API). El formato inline price_data también funciona, pero los precios en Dashboard permiten que personas no-técnicas cambien textos y montos sin tocar código:

// Precio gestionado en Dashboard (recomendado)
'line_items' => [[ 'price' => 'price_1OExampleHandle', 'quantity' => 1 ]],

// Precio inline (válido para cobros únicos, montos dinámicos)
'line_items' => [[
    'price_data' => [
        'currency' => 'mxn',
        'product_data' => ['name' => 'Plan Pro, un mes'],
        'unit_amount' => 39900,
    ],
    'quantity' => 1,
]],

Los montos van en unidades menores. 39900 equivale a $399.00 MXN. Las monedas sin decimales (JPY, KRW, HUF) reciben el entero completo sin multiplicar por 100.

success_url es obligatoria en modo hosted; cancel_url es opcional (si la omites, Checkout muestra un botón genérico de “Volver”). El token {CHECKOUT_SESSION_ID} es texto literal que Stripe sustituye al redirigir. En tu página de agradecimiento, recupera la Session por ese ID y verifica que status === 'complete' y payment_status === 'paid' antes de cumplir la orden: un usuario que guarde la URL de éxito en marcadores no debería contar como orden pagada.

metadata es un diccionario clave-valor opaco que viaja con la Session y el evento de webhook. Úsalo para guardar tus IDs internos y que el handler del webhook sepa qué orden marcar como pagada. La sección de metadata más abajo cubre dónde se propaga y dónde no.

Embedded Checkout

Con Stripe embedded checkout en PHP, la variante embedded reemplaza la redirección con un iframe inline. El cambio en el backend es mínimo: sustituye success_url/cancel_url por ui_mode y return_url, y devuelve el client_secret al frontend:

$session = \Stripe\Checkout\Session::create([
    'ui_mode' => 'embedded',
    'mode' => 'payment',
    'line_items' => [[ 'price' => 'price_1OExampleHandle', 'quantity' => 1 ]],
    'return_url' => 'https://example.com/thanks?session_id={CHECKOUT_SESSION_ID}',
    'metadata' => [ 'order_id' => $orderId ],
]);

header('Content-Type: application/json');
echo json_encode(['clientSecret' => $session->client_secret]);

En la página donde sirves el formulario, monta el iframe con Stripe.js:

<div id="checkout"></div>
<script src="https://js.stripe.com/v3/"></script>
<script>
const stripe = Stripe('pk_live_...');

fetch('/create-checkout-session.php', { method: 'POST' })
    .then(r => r.json())
    .then(({ clientSecret }) => stripe.initEmbeddedCheckout({ clientSecret }))
    .then(checkout => checkout.mount('#checkout'));
</script>

Dos cosas que suelen pillar desprevenidos. Primero, necesitas una clave publicable (pk_, no sk_) en el navegador. Segundo, el Checkout embedded sigue redirigiendo toda la página a return_url cuando el pago se completa: el iframe no es una SPA. Si quieres que el cliente se quede en la página después de pagar, diseña return_url como un landing ligero que cierre el modal o actualice la UI vía JS.

redirectToCheckout ya no existe

Muchas respuestas antiguas en Stack Overflow muestran stripe.redirectToCheckout({ sessionId }) como el flujo del lado del cliente. Ese método se eliminó de Stripe.js en la versión 2025-09-30. La recomendación actual:

  • Modo hosted: redirect del lado del servidor con Location (como en el primer ejemplo).
  • Modo embedded: stripe.initEmbeddedCheckout({ clientSecret }).

Si un tutorial que estás copiando usa redirectToCheckout, es señal de que necesitas documentación más reciente.

customer vs customer_email

Checkout puede prellenar el campo de email de dos formas:

// Opción A: adjuntar un Customer existente
'customer' => 'cus_ExistingCustomerId',

// Opción B: prellenar para crear un Customer nuevo
'customer_email' => $user->email,

Si pasas customer y el Customer ya tiene email, Checkout lo prellena desde el registro del Customer y bloquea el campo: el comprador no puede editarlo. customer_email se ignora en ese caso. Si pasas customer_email sin customer, Checkout prellena un email editable y crea un Customer nuevo al completar la Session. Elegir uno de los dos conscientemente evita un hilo recurrente en el foro de Stripe sobre campos de email bloqueados.

Metadata: el puente con tu webhook

La pregunta sobre metadata aparece constantemente en Stack Overflow, normalmente como “mi metadata no está en el evento”. Dos cosas confunden:

  1. Metadata pertenece a la Session, no a los line items. La API acepta line_items[].metadata, pero se queda en el line item: no aparece en el payload del evento que la mayoría de integradores escucha, y no se propaga al PaymentIntent. Pon tu order_id, user_id y lo que necesites correlacionar en el metadata de nivel superior de la Session:

    'metadata' => [
        'order_id' => $orderId,
        'user_id'  => $userId,
    ],
  2. Metadata de la Session no llega automáticamente al PaymentIntent. Si tu webhook escucha payment_intent.succeeded y lee $event->data->object->metadata, estará vacío salvo que también hayas pasado payment_intent_data.metadata:

    'payment_intent_data' => [
        'metadata' => [ 'order_id' => $orderId ],
    ],

    El camino más simple: escucha checkout.session.completed y lee metadata directamente del objeto Session en el evento. Sin duplicación.

Reaccionar a una Session completada

Cuando el cliente termina de pagar, Stripe dispara checkout.session.completed a tu endpoint de webhook. Ese evento es la señal canónica de que la Session se completó. payment_intent.succeeded también se dispara, pero para flujos de Checkout, el evento de nivel superior es el indicado: incluye el objeto Session con metadata y customer_details en un solo lugar.

// dentro del endpoint de webhook (la guía de webhooks cubre la verificación de firma)
if ('checkout.session.completed' === $event->type) {
    $session = $event->data->object;
    $orderId = $session->metadata->order_id ?? null;
    $paymentStatus = $session->payment_status; // 'paid', 'unpaid', 'no_payment_required'

    if ('paid' === $paymentStatus && $orderId) {
        $orderRepo->markPaid($orderId, $session->id);
    }
}

La verificación de firma, captura del raw body, particularidades de Laravel/Symfony y la idempotencia están en la guía de webhooks. Si aún no has conectado el endpoint de webhook, empieza por ahí: construir Checkout sin webhook significa que te enteras de los pagos fallidos cuando los clientes te escriben.

Un detalle adicional: checkout.session.async_payment_succeeded y checkout.session.async_payment_failed se disparan para métodos de pago diferidos (SEPA debit, débitos bancarios) que liquidan horas o días después de que la Session se complete. Si aceptas algún método diferido, trata la Session como “procesando” en completed y cumple la orden solo en el evento async de éxito.

Recuperar la Session en tu página de éxito

Después de la redirección en modo hosted, tu página de agradecimiento recibe un parámetro session_id en el query. Carga la Session para decidir qué mostrar:

<?php
require __DIR__ . '/vendor/autoload.php';
\Stripe\Stripe::setApiKey(getenv('STRIPE_SECRET_KEY'));

$sessionId = $_GET['session_id'] ?? '';
if (1 !== preg_match('/^cs_[a-zA-Z0-9_]+$/', $sessionId)) {
    http_response_code(400);
    exit('ID de sesión inválido');
}

$session = \Stripe\Checkout\Session::retrieve([
    'id' => $sessionId,
    'expand' => ['payment_intent', 'customer'],
]);

if ('complete' === $session->status && 'paid' === $session->payment_status) {
    echo "Gracias, {$session->customer_details->email}";
} else {
    echo 'El pago aún se está procesando.';
}

La regex sobre session_id no es opcional. Los IDs de Checkout Session son opacos, y un usuario que sustituya el ID de otra persona debería ver un error limpio, no un stack trace. Validar el formato antes de la llamada al API es la defensa barata.

No uses esta página como fuente de verdad para “orden pagada”. El webhook es la fuente de verdad. La página de éxito es solo interfaz.

Expiración

Las Checkout Sessions expiran 24 horas después de crearse. Es un tope fijo: no se puede extender. Después de expirar, la URL devuelve un error y la Session pasa a estado expired. Stripe dispara checkout.session.expired para que puedas liberar inventario reservado, cancelar un borrador de orden o enviar un recordatorio.

Puedes acortar el tiempo pasando expires_at como timestamp Unix (mínimo 30 minutos desde la creación):

'expires_at' => time() + 30 * 60, // 30 minutos

Una expiración corta es el patrón adecuado para reservas de inventario: creas la Session, reservas el stock, y lo liberas en checkout.session.expired si el cliente se va. Para un enlace que necesite vivir más de 24 horas, usa otra primitiva: un PaymentLink (URL compartible, sin expiración) para páginas de pago inmediato, o un Invoice para pago diferido.

También puedes expirar una Session programáticamente, lo cual es práctico cuando un usuario navega a otra parte y quieres liberar inventario sin esperar el timeout:

\Stripe\Checkout\Session::expire($session->id);

Solución de problemas

La página de Checkout está en blanco

Lo primero a revisar: tu Content Security Policy. El iframe embedded de Stripe carga scripts desde https://js.stripe.com y abre frames en https://checkout.stripe.com. Si tu CSP no los permite, la página monta un iframe vacío y la consola del navegador se llena de violaciones CSP:

frame-src https://js.stripe.com https://checkout.stripe.com;
script-src https://js.stripe.com;
connect-src https://api.stripe.com;

Para modo hosted, la otra causa común es una regla de AdBlock que coincide con checkout.stripe.com. No hay nada que hacer del lado del servidor; la mitigación es monitorear la tasa de checkout.session.completed contra la tasa de creación de Sessions y alertar si hay una brecha.

”No such checkout_session”

Estás consultando Stripe en modo live con un ID de Session creado en modo test, o al revés. Verifica qué clave (sk_test_ vs sk_live_) usaste para inicializar el SDK tanto en el endpoint de creación como en la página de éxito. Más detalles sobre errores de modo en la guía de errores de Stripe en PHP.

La metadata no aparece en el evento

Consulta la sección de metadata arriba: o la metadata está en line_items[].metadata (no aparece en el evento), o el webhook escucha payment_intent.succeeded sin haber configurado payment_intent_data.metadata en la Session. Cambia el listener a checkout.session.completed y el problema desaparece.

Los métodos de pago no aparecen

Los métodos habilitados dependen de la moneda, el país y la configuración de tu cuenta Stripe. Revisa la configuración de Payment Methods en el Dashboard para la cuenta (no solo para la Session individual). Google Pay y Apple Pay requieren verificación de dominio; el Dashboard tiene una interfaz para eso, y mientras el dominio no esté verificado simplemente no se renderizan.

Suscripciones con Checkout

Para cobros recurrentes, cambia mode de payment a subscription y apunta line_items a un price con recurrencia. Las particularidades de suscripciones (períodos de prueba, prorrateo, cancelación, portal de facturación) están en la guía de suscripciones. La parte de Checkout es solo esto:

$session = \Stripe\Checkout\Session::create([
    'mode' => 'subscription',
    'line_items' => [[ 'price' => 'price_monthly_handle', 'quantity' => 1 ]],
    'success_url' => 'https://example.com/welcome?session_id={CHECKOUT_SESSION_ID}',
    'cancel_url'  => 'https://example.com/pricing',
]);

Tarjetas de prueba

Para probar pagos en Checkout usa las mismas tarjetas de prueba del SDK: 4242424242424242 (pago exitoso), 4000000000000002 (rechazo genérico), 4000002500003155 (3DS requerido). CVC: cualquier 3 dígitos. Expiración: cualquier fecha futura.

Qué sigue

¿Has visto algo inexacto en esta página?

Reportar un error