Integración de pagos con Stripe en PHP
Qué es la integración de Stripe: el código, las claves API y los componentes de UI que convierten a Stripe en la pasarela de pago de tu aplicación PHP. Los datos de la tarjeta nunca tocan tu servidor, y el API se encarga de la parte regulatoria (3D Secure, SCA, detección de fraude). En PHP, integrar Stripe significa instalar el SDK oficial stripe/stripe-php, incrustar Stripe.js en la página de checkout y configurar un endpoint de webhook para los eventos de pago asíncronos.
El flujo paso a paso: tu servidor crea un PaymentIntent con el SDK, el navegador del cliente lo confirma a través de Stripe.js, y un webhook le avisa a tu sistema cuando el dinero se acreditó. Cuatro piezas trabajan juntas: una clave API en el servidor, un objeto PaymentIntent que describe el cobro, un widget JavaScript que recoge los datos de la tarjeta (para que el alcance PCI no cubra tu servidor), y un receptor de webhook que registra el estado final.
Dos repositorios que conviene tener a mano:
- stripe/stripe-php en GitHub – código fuente, documentación del SDK, issue tracker, y notas de release. Puedes clonar el repo o descargar el ZIP desde la página de Releases.
- stripe-samples en GitHub – integraciones de referencia mantenidas por Stripe. El ejemplo
accept-a-paymentincluye un servidor PHP con el frontend HTML correspondiente.
Instalar el SDK de Stripe para PHP
El SDK es un paquete Composer estándar. Desde la raíz de tu proyecto:
composer require stripe/stripe-php
Esto trae la librería junto con sus extensiones PHP (curl, json, mbstring), que vienen habilitadas por defecto en cualquier instalación razonable de PHP. El SDK marca PHP 7.2 como mínimo, pero todo lo anterior a PHP 8.1 ya no recibe parches de seguridad, así que en producción apunta a 8.1 o superior.
Incluye el autoloader de Composer donde tu aplicación arranca:
require __DIR__ . '/vendor/autoload.php';
Instalación sin Composer
Si tu hosting no tiene acceso por terminal o heredaste un proyecto sin composer.json, puedes descargar el SDK como ZIP desde la página de releases en GitHub e incluir su autoloader interno:
require __DIR__ . '/stripe-php/init.php';
Funciona, pero pierdes la gestión automática de dependencias. Cuando sale un parche de seguridad, tienes que descargar y reemplazar la carpeta manualmente. Composer vale los 5 minutos de configuración incluso en hosting compartido barato: la mayoría de proveedores lo exponen a través de cPanel o SSH.
Verificar la instalación
Antes de escribir lógica de pagos, confirma que el SDK se cargó:
require __DIR__ . '/vendor/autoload.php';
echo 'SDK version: ' . \Stripe\Stripe::VERSION . PHP_EOL;
echo 'cURL: ' . (extension_loaded('curl') ? 'sí' : 'no') . PHP_EOL;
echo 'PHP: ' . PHP_VERSION . PHP_EOL;
Si esto imprime sin un fatal error, estás listo para las claves.
Claves API: modo test y modo live
Cada cuenta Stripe tiene dos juegos de claves separados. Las de pruebas empiezan con sk_test_ y pk_test_, las de producción con sk_live_ y pk_live_. No son intercambiables: un objeto creado con claves de test solo existe en modo test, y usar una clave publicable de un modo con la secreta del otro produce un error resource_missing que es fácil confundir con un bug en tu código.
Consigue las claves en el panel de Stripe bajo Developers > API keys. La clave publicable se puede exponer en el frontend; la secreta tiene que quedarse en el servidor. Nunca hagas commit de claves secretas a git, ni siquiera en archivos de ejemplo como .env.example. Si publicaste una clave por accidente, rótala de inmediato en el panel.
Cárgalas desde variables de entorno:
$secret = getenv('STRIPE_SECRET_KEY');
if (false === $secret || '' === $secret) {
throw new RuntimeException('STRIPE_SECRET_KEY no está configurada');
}
\Stripe\Stripe::setApiKey($secret);
La verificación explícita te ahorra el mensaje “Invalid API Key” de Stripe cuando la variable no existe, ya que ese mensaje no apunta a la causa real. Un error frecuente: tener la clave en .env pero que no se exporte al proceso PHP (por ejemplo, tu framework carga .env para su propia configuración pero no para getenv()).
Para proyectos con más de una cuenta Stripe (habitual en setups multi-tenant), usa un cliente por petición en vez del estático global:
$stripe = new \Stripe\StripeClient([
'api_key' => getenv('STRIPE_SECRET_KEY'),
'stripe_version' => '2025-03-31.basil',
]);
Fijar la versión del API explícitamente evita sorpresas cuando Stripe despliega cambios. La referencia del API de Stripe en PHP detalla las versiones y métodos disponibles. La versión por defecto de tu cuenta se ve en Developers > API version en el panel.
Claves restringidas para producción
La clave secreta tiene acceso total a la cuenta: crear cobros, emitir reembolsos, exportar datos de clientes, modificar webhooks. Si tu aplicación solo necesita aceptar pagos desde PHP a través de PaymentIntents, usa una restricted key con permisos limitados. En Developers > API keys > Create restricted key, concede solo PaymentIntents: write. Si la clave se filtra, el radio de daño es una fracción de lo que haría una clave secreta completa.
Crear tu primer PaymentIntent
El PaymentIntent es la primitiva central de pagos de Stripe y la forma recomendada de aceptar pagos desde PHP a través del SDK. En vez de una sola llamada “cobra la tarjeta”, creas un intent que describe lo que quieres cobrar y luego lo confirmas. El siguiente ejemplo muestra la llamada mínima. Ese flujo en dos pasos es lo que permite que requerimientos regulatorios como SCA y 3D Secure funcionen sin que tu código tenga que entenderlos.
La llamada mínima:
$stripe = new \Stripe\StripeClient(getenv('STRIPE_SECRET_KEY'));
$intent = $stripe->paymentIntents->create([
'amount' => 2500,
'currency' => 'usd',
'automatic_payment_methods' => ['enabled' => true],
'metadata' => [
'order_id' => (string) $orderId,
],
]);
echo $intent->client_secret;
Stripe devuelve metadata en cada objeto y evento de webhook, lo que lo convierte en el puente más limpio entre tus IDs de dominio y los intents que has creado. Los valores tienen que ser strings (o se convierten automáticamente), así que haz el cast de cualquier valor numérico.
El parámetro amount va en la unidad menor de la moneda: 2500 significa $25.00 USD, no $2,500. Para USD, EUR y GBP eso son centavos. Para MXN, 100000 significa $1,000.00 pesos. Las monedas sin decimales como JPY se pasan tal cual sin multiplicar. La lista completa está en la documentación de Stripe.
Los códigos de moneda van en minúsculas: 'usd', no 'USD'. Stripe acepta mayúsculas en ciertos endpoints y lo rechaza en otros, y el mensaje de error no siempre menciona la capitalización.
El client_secret que devuelve el intent es sensible pero no al nivel de la clave secreta. Es un token por intent que permite al navegador completar ese pago y nada más. Pásalo al navegador a través de tu rendering normal o un endpoint JSON.
Claves de idempotencia
Los problemas de red pasan. Si tu servidor se queda esperando la respuesta de Stripe, no sabes si el PaymentIntent se creó o no. Reintentar a ciegas puede crear dos intents. La clave de idempotencia resuelve esto: Stripe recuerda la clave durante 24 horas y devuelve el resultado original en un reintento.
$intent = $stripe->paymentIntents->create(
[
'amount' => 2500,
'currency' => 'usd',
'automatic_payment_methods' => ['enabled' => true],
],
[
'idempotency_key' => 'order_' . $orderId . '_attempt',
]
);
Usa algo que se vincule con tu lógica de negocio: un ID de orden, un hash del carrito, lo que sea estable entre reintentos pero único por petición lógica. No uses uniqid() ni rand(); eso anula el propósito al generar una clave nueva en cada llamada.
El lado del cliente: Payment Element
El enfoque recomendado para el frontend es el Payment Element, que muestra todos los métodos de pago habilitados en un solo iframe: tarjeta, Apple Pay, Google Pay, Link, y métodos regionales como OXXO u OXXO Pay en México. Si prefieres delegar la interfaz completa de la pasarela de pago a Stripe, las Checkout Sessions son la alternativa sin código propio de frontend. Pero si necesitas control sobre el diseño, incluye Stripe.js una vez y monta el elemento en un <div>:
<!DOCTYPE html>
<html>
<head>
<script src="https://js.stripe.com/v3/"></script>
</head>
<body>
<form id="payment-form">
<div id="payment-element"></div>
<button type="submit" id="submit">Pagar</button>
<div id="error-message"></div>
</form>
<script>
const stripe = Stripe(<?= json_encode($publishableKey) ?>);
const clientSecret = <?= json_encode($clientSecret) ?>;
const elements = stripe.elements({ clientSecret });
const paymentElement = elements.create('payment');
paymentElement.mount('#payment-element');
document.getElementById('payment-form').addEventListener('submit', async (e) => {
e.preventDefault();
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: 'https://example.com/order/complete',
},
});
if (error) {
document.getElementById('error-message').textContent = error.message;
}
});
</script>
</body>
</html>
La return_url es donde Stripe envía al cliente después de la autenticación 3D Secure (si la tarjeta lo requiere) o tras completar cualquier otro método de pago con redirección. Tu código en esa URL necesita recuperar el PaymentIntent y verificar su estado.
Usa json_encode() en vez de htmlspecialchars() al emitir valores en JavaScript. El escape de HTML no cubre los caracteres que importan en un contexto JS (comillas, backslashes, terminadores de línea), mientras que json_encode produce un literal JS válido para cualquier cadena.
Confirmar el pago en el servidor
Después de que el navegador confirma el pago, Stripe redirige a tu return_url con dos parámetros de query: payment_intent y payment_intent_client_secret. El navegador ya vio el resultado, pero no confías en el navegador; verificas en el servidor antes de marcar la orden como pagada:
$stripe = new \Stripe\StripeClient(getenv('STRIPE_SECRET_KEY'));
$intentId = $_GET['payment_intent'] ?? null;
if (!$intentId) {
http_response_code(400);
exit('Falta el payment intent');
}
$intent = $stripe->paymentIntents->retrieve($intentId);
// El client_secret del query debe coincidir con el del intent.
// Protege contra alguien que sustituya el payment_intent ID en la URL.
if ($intent->client_secret !== ($_GET['payment_intent_client_secret'] ?? '')) {
http_response_code(400);
exit('client_secret no coincide');
}
switch ($intent->status) {
case 'succeeded':
marcarOrdenPagada($intent->metadata['order_id'], $intent);
header('Location: /order/thanks');
break;
case 'processing':
// Métodos como SPEI o transferencia bancaria liquidan de forma asíncrona.
// Avisamos al cliente que confirmaremos por email cuando se acredite.
marcarOrdenPendiente($intent->metadata['order_id']);
header('Location: /order/pending');
break;
case 'requires_payment_method':
// El intento anterior falló. Redirigir de vuelta al formulario.
header('Location: /checkout?error=1');
break;
default:
error_log("Estado inesperado del intent: {$intent->status}");
header('Location: /order/error');
}
El flujo de redirección funciona para casos simples, pero no es definitivo. Un cliente que cierra la pestaña después de que Stripe redirige pero antes de que tu handler se ejecute deja un intent pagado sin actualizar la orden. Un webhook escuchando payment_intent.succeeded cierra esa brecha: actualiza la base de datos sin importar si el cliente volvió a tu sitio. La guía de webhooks de Stripe cubre la implementación completa.
3D Secure y autenticación reforzada (SCA)
PSD2 en Europa y regulaciones equivalentes en otros mercados exigen autenticación reforzada del cliente para pagos con tarjeta. En la práctica, algunas tarjetas pedirán al cliente que verifique a través de la app de su banco o un código SMS antes de completar el pago. PaymentIntent lo maneja automáticamente: el Payment Element muestra el desafío, el cliente lo completa, Stripe redirige de vuelta. Lo mismo aplica para cobros recurrentes con la API de suscripciones, donde SCA se activa en el primer pago del ciclo.
Cuando la autenticación falla, la causa suele ser una return_url faltante. La confirmación en servidor con confirm: true necesita la URL integrada; si no, Stripe no tiene adónde enviar al cliente después del desafío 3DS. La segunda causa más común: una tarjeta de prueba que no activa 3DS. 4242 4242 4242 4242 se salta la autenticación; para probar el flujo usa 4000 0025 0000 3155, que fuerza 3DS en cada cobro.
El flujo de confirmación manual, para casos donde la confirmación automática no aplica (checkout personalizado, backends de apps móviles):
try {
$intent = $stripe->paymentIntents->create([
'amount' => 2500,
'currency' => 'usd',
'payment_method' => $paymentMethodId,
'confirm' => true,
'return_url' => 'https://example.com/return',
]);
if ('requires_action' === $intent->status) {
// El cliente necesita completar 3DS.
// Devolver client_secret para que stripe.handleNextAction() se ejecute en el navegador.
return ['requires_action' => true, 'client_secret' => $intent->client_secret];
}
if ('succeeded' === $intent->status) {
return ['success' => true];
}
return ['error' => 'Estado inesperado: ' . $intent->status];
} catch (\Stripe\Exception\CardException $e) {
return ['error' => $e->getMessage()];
}
CardException extiende ApiErrorException, así que si agregas catches más amplios después, mantén el específico de tarjeta primero. Más detalles sobre la jerarquía de excepciones en errores comunes de Stripe en PHP.
Tarjetas de prueba
Stripe proporciona una colección de tarjetas de prueba que cubren escenarios de éxito, rechazo y autenticación:
| Número | Comportamiento |
|---|---|
4242 4242 4242 4242 | Pago exitoso, sin 3DS |
4000 0025 0000 3155 | Requiere autenticación 3DS |
4000 0000 0000 0002 | Rechazo genérico |
4000 0000 0000 9995 | Fondos insuficientes |
4000 0000 0000 0069 | Tarjeta expirada |
4000 0000 0000 0127 | CVC incorrecto |
CVC: cualquier 3 dígitos. Fecha de expiración: cualquier fecha futura. Código postal: cualquier 5 dígitos.
Un error común: mezclar objetos de test con claves de live. Si creaste un PaymentIntent con sk_test_... e intentas recuperarlo con sk_live_..., Stripe devuelve “No such payment_intent”. El intent existe, solo que estás mirando a través de la ventana equivocada. El mensaje no dice “modo incorrecto”.
Errores frecuentes
Clave publicable en el servidor. Síntoma: Invalid API Key provided: pk_live_... en cualquier llamada del SDK. La publicable va en el navegador, la secreta en el servidor. Confusión sorprendentemente común al copiar entre archivos.
Clave secreta en el frontend. Peor síntoma: la clave funciona, y ahora está en los navegadores de tus usuarios. Rótala de inmediato.
Autoloader de Composer olvidado. Class 'Stripe\StripeClient' not found. Instalaste el SDK pero no cargaste el autoloader en el punto de entrada que se está ejecutando.
Montos como strings. En PHP el monto puede llegar como string desde un formulario: 'amount' => '25.00'. Stripe espera enteros en unidades menores. Convierte explícitamente: (int) round($price * 100).
Suponer conversión de moneda. Stripe no convierte entre monedas. Si tu cliente paga en EUR y tu cuenta Stripe está configurada en USD, el pago falla con invalid_currency. La moneda de liquidación y la de la transacción son cosas separadas que se configuran a nivel de cuenta.
Siguiente paso
Un PaymentIntent básico es la base. Para entender cuánto cobra Stripe por transacción, la guía de comisiones y pricing de Stripe desglosa la lógica de fees en código. Y cuando algo falle, el catálogo de excepciones y su significado está en errores comunes de Stripe en PHP.
Un hábito que vale la pena adoptar: conecta el logger del SDK en tu entorno de desarrollo. \Stripe\Stripe::setLogger($psr3Logger) acepta cualquier logger PSR-3 y registra los cuerpos completos de petición/respuesta con las claves redactadas. Cuando una llamada hace algo inesperado, el logger es el camino más corto a la causa.
¿Has visto algo inexacto en esta página?
Reportar un error