Hace unas semanas necesitaba algo aparentemente simple: que alguien pudiera entrar a mi web, elegir un horario, pagar una consultoría de $60 y quedar con la reserva confirmada. Sin intervención mía.

Lo primero que hice fue mirar las opciones "fáciles": Calendly o Cal.com en su versión de pago, plataformas de reservas con cuota mensual, plugins que piden membresía anual. Todas funcionan, pero todas cobran una suscripción por algo que, si tienes una web propia, puedes resolver con Stripe y unas 150 líneas de código.
Esta guía documenta el sistema exacto que construí para la página de consultoría de farp.dev. Está en producción, cobra pagos reales, y la parte que más me sorprendió: no necesité webhooks. Te explico por qué más abajo, porque es la decisión de diseño que simplifica todo.
En pocas palabras: vas a montar un sistema de reservas con pago por Stripe en tu propia web — desde crear la cuenta hasta confirmar la reserva tras el pago. Tiempo estimado: una tarde (3-5 horas). Nivel: intermedio — necesitas saber JavaScript y tener una web con backend (los ejemplos son Next.js, pero el patrón sirve para Express, PHP o lo que uses).
Antes de empezar
Necesitas:
- Una web con servidor propio. El código de ejemplo es Next.js (App Router), pero el flujo es idéntico en cualquier tecnología que pueda ejecutar código en servidor. Si todavía no lo tienes desplegado, escribí el paso a paso en cómo desplegar Next.js en cPanel.
- Una cuenta bancaria que Stripe acepte. En mi caso uso una cuenta con IBAN europeo (una N26 sirve perfectamente). No necesitas empresa: Stripe permite operar como particular ("individual") en muchos países.
- Node.js y npm si sigues los ejemplos tal cual.
Cuándo NO te conviene este camino:
- Si tu web es WordPress y ya usas un plugin de reservas que funciona (Amelia, Bookly, etc.), no lo reemplaces por código a medida. El costo del plugin es menor que el costo de mantener código propio si no eres desarrollador.
- Si necesitas suscripciones complejas, prorrateos, o planes que suben y bajan de nivel, el patrón simple de esta guía se te queda corto. Ahí sí necesitas webhooks y bastante más infraestructura.
- Si vendes volumen alto (decenas de pagos al día), lee el paso 4 con atención: la verificación en la página de gracias tiene límites que te explico honestamente.
Paso 1: crear la cuenta de Stripe (particular sirve)
Ve a stripe.com y crea una cuenta. Dos cosas que descubrí en el proceso y que me habrían ahorrado dudas:
- No necesitas ser empresa. Al activar la cuenta, Stripe pregunta el tipo de negocio: elige "particular/individual" si no tienes sociedad. Te pedirá tus datos personales y una cuenta bancaria donde recibir los cobros. Una cuenta con IBAN europeo tipo N26 funciona sin problema.
- Activa solo lo que vas a usar. Para este sistema necesitas únicamente Checkout prediseñado (la página de pago que aloja Stripe) y pagos no recurrentes. No actives suscripciones, ni facturación, ni terminales. Menos superficie, menos configuración, menos cosas que pueden fallar.
Completa la verificación de identidad (te pedirá documento y datos fiscales). Mientras la revisan puedes trabajar en modo test con tarjetas de prueba, así que no te bloquea.
Paso 2: las llaves — y dónde NO ponerlas
Stripe te da dos llaves en su panel (Developers → API keys):
pk_...— llave publicable. Puede ir en el frontend.sk_...— llave secreta. Con ella se pueden crear cargos y reembolsos en tu nombre.
La secreta va solo en variables de entorno del servidor. Nunca en el código, nunca en el repositorio, nunca en un archivo commiteado "temporalmente":
# .env.local (y en las variables de entorno de tu hosting)
STRIPE_SECRET_KEY=sk_live_...
SITE_URL=https://tudominio.com
Verifica que .env.local está en tu .gitignore antes de pegar la llave. Y una advertencia que va en serio: si la llave secreta se filtra (la commiteaste por error, la pegaste en un chat, la dejaste en un log), rótala inmediatamente desde el panel de Stripe (Developers → API keys → Roll key). No esperes "a ver si pasa algo". Una llave secreta filtrada permite operar tu cuenta. Es el mismo criterio que aplico al resto de credenciales de una empresa, y lo resumí en seguridad web para pymes: lo mínimo.
Instala el SDK:
npm install stripe
Paso 3: el endpoint de checkout
Aquí está el corazón del sistema. Cuando el visitante elige horario y pulsa "Reservar", tu backend crea una sesión de Checkout y lo redirige a la página de pago de Stripe. Tú no tocas datos de tarjeta jamás — eso lo maneja Stripe en su dominio.
// src/app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
const { slot, nombre, email } = await req.json();
// Validaciones mínimas antes de crear nada
if (!slot || !nombre?.trim() || !email?.trim()) {
return NextResponse.json(
{ error: "Faltan datos de la reserva." },
{ status: 400 }
);
}
const origin = process.env.SITE_URL || req.nextUrl.origin;
const session = await stripe.checkout.sessions.create({
mode: "payment", // pago único, no suscripción
line_items: [
{
price_data: {
currency: "usd",
product_data: { name: "Consultoría 1:1" },
unit_amount: 6000, // $60.00 — Stripe trabaja en centavos
},
quantity: 1,
},
],
customer_email: email,
metadata: { slot, nombre, email },
success_url: origin + "/gracias?session_id={CHECKOUT_SESSION_ID}",
cancel_url: origin + "/reservar",
});
return NextResponse.json({ url: session.url });
}
Tres detalles que importan:
unit_amountva en centavos. 6000 = $60.00. Es el error más tonto y más caro de cometer.metadataguarda el horario, nombre y email dentro de la sesión de Stripe. Cuando el pago se complete, recuperas la sesión y ahí está todo lo que necesitas para crear la reserva. La sesión de Stripe actúa como "sobre" que transporta los datos de la reserva a través del pago.{CHECKOUT_SESSION_ID}es un placeholder literal — escríbelo tal cual, con llaves. Stripe lo sustituye por el ID real al redirigir de vuelta.
En el frontend solo necesitas hacer el fetch y redirigir:
const res = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slot, nombre, email }),
});
const { url } = await res.json();
window.location.href = url; // → página de pago de Stripe
Paso 4: la página de gracias — verificar ANTES de confirmar
Aquí está la decisión de diseño que me evitó los webhooks. El flujo clásico dice: configura un webhook, Stripe te avisa cuando el pago se completa, tú confirmas la reserva. Funciona, pero añade un endpoint más, verificación de firmas, y en hostings compartidos (como el mío) a veces da problemas.
La alternativa: cuando Stripe redirige al usuario a /gracias?session_id=cs_..., tu servidor consulta directamente a Stripe si esa sesión está pagada. No confías en la URL — cualquiera puede escribir /gracias?session_id=loquesea — confías en lo que Stripe te responde:
// src/app/gracias/page.tsx (Server Component)
import Stripe from "stripe";
import { guardarReserva } from "@/lib/bookings";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export default async function GraciasPage({
searchParams,
}: {
searchParams: Promise<{ session_id?: string }>;
}) {
const { session_id } = await searchParams;
if (!session_id) return <p>No encontramos tu reserva.</p>;
const session = await stripe.checkout.sessions.retrieve(session_id);
// LA verificación: si Stripe no dice "paid", no hay reserva
if (session.payment_status !== "paid") {
return <p>El pago no se completó. Tu horario no fue reservado.</p>;
}
const { slot, nombre, email } = session.metadata!;
await guardarReserva({ sessionId: session.id, slot, nombre, email });
return (
<div>
<h1>Reserva confirmada</h1>
<p>Nos vemos el {slot}, {nombre}. Te llegará un email.</p>
</div>
);
}
La línea clave es session.payment_status !== "paid". Esa consulta va de tu servidor a Stripe con tu llave secreta: es imposible de falsificar desde fuera.
La parte honesta: este patrón tiene un hueco conocido. Si el usuario paga pero cierra la pestaña antes de llegar a /gracias, el pago existe pero la reserva no se registró. Para una consultoría con pocos pagos a la semana, lo detectas en el panel de Stripe y lo resuelves con un email — a mí me parece un intercambio perfectamente aceptable. Para volumen alto o inventario que se agota, no: ahí necesitas webhooks (checkout.session.completed), que garantizan la notificación aunque el usuario desaparezca. Empieza simple; migra a webhooks cuando el volumen lo justifique.
Paso 5: guardar la reserva de forma idempotente
Hay un detalle que muerde si no lo previenes: el usuario puede recargar /gracias, o volver a la URL desde el historial. Si cada visita crea una reserva, tendrás duplicados. La solución es buscar por session_id antes de crear:
// src/lib/bookings.ts
export async function guardarReserva(datos: {
sessionId: string;
slot: string;
nombre: string;
email: string;
}) {
const reservas = await leerReservas(); // tu JSON, SQLite, lo que uses
// Idempotencia: si esta sesión ya generó reserva, no crear otra
if (reservas.some((r) => r.sessionId === datos.sessionId)) {
return; // ya existe, no pasa nada
}
reservas.push({ ...datos, creadaEn: new Date().toISOString() });
await escribirReservas(reservas);
}
El session_id de Stripe es único por intento de pago, así que es la llave natural de idempotencia. Con esto, recargar la página de gracias diez veces crea exactamente una reserva.
Paso 6: disponibilidad simple sin base de datos
Para las horas disponibles no necesitas un motor de calendario. Yo empecé con algo así:
// src/lib/slots.ts
const HORARIOS_BASE = ["10:00", "12:00", "16:00", "18:00"];
const DIAS_LABORALES = [1, 2, 3, 4, 5]; // lunes a viernes
export async function slotsDisponibles(dias = 14) {
const reservas = await leerReservas();
const ocupados = new Set(reservas.map((r) => r.slot));
const resultado: string[] = [];
for (let i = 1; i <= dias; i++) {
const fecha = new Date();
fecha.setDate(fecha.getDate() + i);
if (!DIAS_LABORALES.includes(fecha.getDay())) continue;
const dia = fecha.toISOString().slice(0, 10);
for (const hora of HORARIOS_BASE) {
const slot = `${dia} ${hora}`;
if (!ocupados.has(slot)) resultado.push(slot);
}
}
return resultado;
}
Un endpoint GET /api/slots devuelve esta lista, el frontend la pinta como botones, y listo. Cuando alguien reserva, su slot desaparece de la lista porque está en el archivo de reservas. Añade una comprobación de disponibilidad también en el endpoint de checkout (antes de crear la sesión), para el caso de dos personas mirando el mismo horario a la vez.
Problemas comunes
"El pago funciona en test pero no en producción." Revisa que en el servidor esté la llave sk_live_ y no la sk_test_. Las sesiones de test y live viven en mundos separados: una llave test no puede recuperar una sesión live.
"Stripe rechaza mi success_url." Debe ser una URL absoluta con dominio real. En local, http://localhost:3000/gracias?... funciona; lo que no funciona es una ruta relativa. Por eso uso SITE_URL como variable de entorno.
"El monto sale mal." Centavos. Siempre son centavos. $60 = 6000.
"Me llegan reservas duplicadas." Te falta la comprobación de idempotencia del paso 5, o la estás haciendo por email en vez de por session_id (una misma persona puede reservar dos horarios legítimamente).
"El usuario pagó pero no veo la reserva." El caso del que hablé en el paso 4: cerró la pestaña antes de llegar a /gracias. El pago está en el panel de Stripe con sus metadatos (horario, nombre, email en la sesión). Crea la reserva a mano y escríbele. Si esto te pasa seguido, es la señal de migrar a webhooks.
Preguntas frecuentes
¿Cuánto cobra Stripe por transacción? Depende del país y del tipo de tarjeta; en Europa las tarjetas estándar rondan el 1,5% + tarifa fija, y las de fuera del EEE cuestan más. No hay cuota mensual: si no vendes, no pagas. Revisa la página de precios de Stripe para tu país, porque cambia.
¿Necesito certificado SSL o cumplir PCI? Tu web debe servirse por HTTPS (hoy es gratis con Let's Encrypt y la mayoría de hostings lo incluyen). El cumplimiento PCI pesado no te toca: con Checkout prediseñado, los datos de tarjeta nunca pasan por tu servidor — el usuario los escribe en la página de Stripe.
¿Puedo cobrar en euros, dólares u otra moneda?
Sí: cambia currency a "eur", "usd", etc. Stripe deposita en tu cuenta bancaria haciendo la conversión si hace falta (con su comisión de cambio). Yo cobro en USD y recibo en una cuenta europea sin problema.
¿Y la factura? Stripe te deja el cobro registrado, no la factura legal de tu país. Ese paso conviene resolverlo aparte y, a poder ser, automatizado: lo cuento en automatizar facturación y cobros.
Este sistema lleva semanas cobrando consultorías reales en mi web sin que yo toque nada. Si quieres montar algo así en la tuya y prefieres hacerlo acompañado — revisamos tu caso, decidimos si te conviene este camino o un plugin, y lo dejamos funcionando — puedes reservar una consultoría 1:1 conmigo por $60. Irónicamente, la reserva la vas a hacer con el sistema que acabo de describirte.
Newsletter
¿Te fue útil este artículo?
Suscríbete y te aviso cuando publique algo que valga la pena leer. Sin correo basura y sin una frecuencia forzada.