El 20% de los clientes de Julián reservaba y no llegaba. Desde que la reserva exige un anticipo de $10.000 COP, la inasistencia bajó a casi cero: quien paga, llega. Pero hay un detalle que lo cambia todo: la mitad de sus clientes no tiene tarjeta de crédito — pagan con Nequi o PSE.
En Latinoamérica, aceptar pagos no es "poner Stripe": es aceptar los métodos locales con los que tu cliente paga de verdad.
1.1. Los métodos de pago que importan en la región
Cada país tiene sus métodos dominantes. Una pasarela sirve en tu país si soporta los de tu cliente:
| País | Métodos locales clave | Pasarelas fuertes |
|---|---|---|
| Colombia | PSE, Nequi, Daviplata, tarjetas, efectivo (Efecty) | Wompi, Mercado Pago, PayU |
| México | SPEI, OXXO (efectivo), tarjetas | Mercado Pago, Stripe, Conekta |
| Argentina | Mercado Pago (dominante), transferencia, Rapipago | Mercado Pago, PayU |
| Perú | Yape, Plin, PagoEfectivo, tarjetas | Mercado Pago, Culqi, PayU |
| Chile | Webpay, tarjetas, transferencia | Transbank, Mercado Pago |
| Brasil | Pix (dominante), boleto, tarjetas | Mercado Pago, Stripe, PagSeguro |
1.2. Las 4 pasarelas del curso, comparadas
Toda integración de pagos moderna sigue 3 pasos: 1) creas una transacción con referencia única y rediriges al checkout de la pasarela, 2) la pasarela te notifica el resultado por webhook, 3) tu backend actualiza el estado del pedido/cita. Aprende el patrón con Wompi y sabrás integrarlas todas.
2.1. Llaves y modo sandbox
Crea tu cuenta en comercios.wompi.co. En el modo sandbox obtienes llaves de prueba: puedes simular pagos aprobados y rechazados con tarjetas de test, sin dinero real ni papeleo.
# .env.local — agrega las llaves de Wompi (sandbox)
NEXT_PUBLIC_WOMPI_PUBLIC_KEY=pub_test_xxxxxxxxxxxxx
WOMPI_INTEGRITY_SECRET=test_integrity_xxxxxxxxxxxxx
WOMPI_EVENTS_SECRET=test_events_xxxxxxxxxxxxx
Las variables con prefijo NEXT_PUBLIC_ se incrustan en el JavaScript del navegador — cualquiera las ve. Los secretos de integridad y eventos nunca llevan ese prefijo: solo viven en el servidor.
2.2. La tabla de pagos y la referencia única
Cada intento de pago se registra antes de enviar al cliente al checkout. La referencia conecta tu mundo con el de la pasarela:
-- SQL Editor: la tabla que registra cada intento de pago
create table public.pagos (
id uuid primary key default gen_random_uuid(),
cita_id uuid not null references public.citas(id),
referencia text not null unique, -- ej: RSV-a1b2c3d4
monto_cop integer not null check (monto_cop > 0),
estado text not null default 'PENDIENTE'
check (estado in ('PENDIENTE','APROBADO','RECHAZADO','ANULADO','ERROR')),
wompi_transaccion_id text, -- id que asigna Wompi
creado_en timestamptz not null default now(),
actualizado_en timestamptz not null default now()
);
alter table public.pagos enable row level security;
create policy "mis pagos" on public.pagos for select
using (exists (select 1 from public.citas c
where c.id = cita_id and c.cliente_id = auth.uid()));
2.3. Crear el link de pago con firma de integridad
Wompi exige una firma SHA-256 de referencia + monto + moneda + secreto. Así nadie puede alterar el monto en el camino:
// app/pagar/actions.ts
"use server";
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
import crypto from "crypto";
const ANTICIPO_COP = 10_000; // regla de negocio: anticipo fijo de $10.000
export async function iniciarPago(citaId: string) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect("/login");
// La cita debe existir, ser del usuario y estar pendiente de pago
const { data: cita } = await supabase
.from("citas")
.select("id, estado")
.eq("id", citaId)
.eq("cliente_id", user.id)
.single();
if (!cita || cita.estado !== "pendiente_pago") redirect("/mis-citas");
// Referencia única: conecta el pago de Wompi con nuestra cita
const referencia = `RSV-${crypto.randomUUID().slice(0, 8)}-${Date.now()}`;
const montoEnCentavos = ANTICIPO_COP * 100;
await supabase.from("pagos").insert({
cita_id: cita.id,
referencia,
monto_cop: ANTICIPO_COP,
});
// Firma de integridad: SHA256(referencia + monto + moneda + secreto)
const firma = crypto
.createHash("sha256")
.update(
`${referencia}${montoEnCentavos}COP${process.env.WOMPI_INTEGRITY_SECRET}`
)
.digest("hex");
// Checkout redirect: el cliente paga en la página segura de Wompi
const checkout = new URL("https://checkout.wompi.co/p/");
checkout.searchParams.set("public-key", process.env.NEXT_PUBLIC_WOMPI_PUBLIC_KEY!);
checkout.searchParams.set("currency", "COP");
checkout.searchParams.set("amount-in-cents", String(montoEnCentavos));
checkout.searchParams.set("reference", referencia);
checkout.searchParams.set("signature:integrity", firma);
checkout.searchParams.set("redirect-url", "http://localhost:3000/pagar/resultado");
redirect(checkout.toString());
}
Al redirigir a la página de la pasarela, los datos de la tarjeta nunca tocan tu servidor — la certificación de seguridad (PCI-DSS) es problema de Wompi, no tuyo. Para un negocio pequeño o mediano, esta es siempre la opción correcta. Los formularios de tarjeta embebidos son para empresas con equipo de seguridad dedicado.
Cuando el cliente termina de pagar, Wompi lo devuelve a tu redirect-url. Pero ese regreso no confirma nada: el cliente puede cerrar la pestaña antes, perder la conexión, o fabricar la URL de resultado. La confirmación real llega por otro canal: el webhook, una petición que Wompi hace directamente de servidor a servidor.
El cliente cierra la pestaña → nunca marcas el pago
El cliente edita la URL → marcas pagado sin cobrar
Se cae el WiFi del cliente → estado inconsistente
Wompi te avisa servidor a servidor, pase lo que pase en el navegador
El evento viene firmado: imposible de falsificar
Si tu servidor estaba caído, Wompi reintenta
3.1. El endpoint del webhook
// app/api/webhooks/wompi/route.ts
import { NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";
import crypto from "crypto";
// Cliente con service_role: el webhook no tiene sesión de usuario
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // solo en servidor, jamás NEXT_PUBLIC
);
export async function POST(request: Request) {
const evento = await request.json();
// 1. VERIFICAR LA FIRMA: ¿esto realmente lo envió Wompi?
const tx = evento.data?.transaction;
const props: string[] = evento.signature?.properties ?? [];
const concatenado =
props.map((p) => p.split(".").reduce((o: any, k: string) => o?.[k], evento.data)).join("") +
evento.timestamp +
process.env.WOMPI_EVENTS_SECRET;
const checksum = crypto.createHash("sha256").update(concatenado).digest("hex");
if (checksum !== evento.signature?.checksum) {
return NextResponse.json({ error: "firma inválida" }, { status: 401 });
}
// 2. IDEMPOTENCIA: si ya procesamos esta transacción, respondemos OK y listo
const { data: pago } = await supabaseAdmin
.from("pagos")
.select("id, cita_id, estado, monto_cop")
.eq("referencia", tx.reference)
.single();
if (!pago) return NextResponse.json({ ok: true }); // referencia ajena
if (pago.estado !== "PENDIENTE") return NextResponse.json({ ok: true }); // ya procesado
// 3. VALIDAR EL MONTO: ¿pagaron lo que era?
if (tx.amount_in_cents !== pago.monto_cop * 100) {
await supabaseAdmin.from("pagos")
.update({ estado: "ERROR", wompi_transaccion_id: tx.id })
.eq("id", pago.id);
return NextResponse.json({ ok: true });
}
// 4. APLICAR LA LÓGICA DE NEGOCIO según el estado de Wompi
const mapa: Record<string, string> = {
APPROVED: "APROBADO", DECLINED: "RECHAZADO",
VOIDED: "ANULADO", ERROR: "ERROR",
};
const nuevoEstado = mapa[tx.status] ?? "ERROR";
await supabaseAdmin.from("pagos")
.update({
estado: nuevoEstado,
wompi_transaccion_id: tx.id,
actualizado_en: new Date().toISOString(),
})
.eq("id", pago.id);
// Pago aprobado → la cita queda confirmada
if (nuevoEstado === "APROBADO") {
await supabaseAdmin.from("citas")
.update({ estado: "confirmada" })
.eq("id", pago.cita_id);
}
// 5. Responder 200 rápido: Wompi reintenta si no respondes
return NextResponse.json({ ok: true });
}
1. Verifica la firma — cualquiera puede hacer POST a tu URL. 2. Sé idempotente — la pasarela puede enviarte el mismo evento dos veces; procesarlo dos veces no debe duplicar nada. 3. Valida el monto — que el pago recibido corresponda a lo que debía cobrarse.
Wompi no puede llamar a localhost:3000. Para probar en desarrollo, expón tu servidor con un túnel: npx ngrok http 3000 (o Cloudflare Tunnel) y registra la URL pública en el dashboard de Wompi → Eventos. En el módulo 6 la reemplazarás por tu dominio real.
Un pago no es "pagado o no pagado": es una máquina de estados. Entenderla evita los dos errores caros: dar servicio sin cobrar, y cobrar sin dar servicio.
4.1. Los casos borde que separan aficionados de profesionales
| Caso borde | Qué debe hacer tu lógica de negocio |
|---|---|
| Cliente paga pero cierra la pestaña antes del redirect | Nada especial: el webhook confirma igual. La pantalla "mis citas" muestra el estado real. |
| PSE queda "pendiente" 10 minutos (transferencia bancaria) | La cita se sostiene en pendiente_pago con el horario bloqueado un tiempo límite (ej: 15 min). |
| Cliente reserva y nunca paga | Un job (módulo 5) libera las citas pendiente_pago con más de 15 minutos. |
| Llega webhook de una referencia desconocida | Responde 200 y regístralo en logs. Nunca proceses lo que no originaste. |
| El mismo webhook llega dos veces | La verificación de idempotencia (estado ≠ PENDIENTE) lo ignora sin efectos. |
4.2. El patrón universal: Mercado Pago en 20 líneas
Para demostrar que el patrón se repite, así se ve el paso 1 con Mercado Pago (México, Argentina y el resto de la región):
// Mismo patrón: crear preferencia → redirigir → esperar webhook
import { MercadoPagoConfig, Preference } from "mercadopago";
const mp = new MercadoPagoConfig({
accessToken: process.env.MP_ACCESS_TOKEN!, // secreto de servidor
});
export async function crearPreferenciaMP(referencia: string, montoMXN: number) {
const preferencia = await new Preference(mp).create({
body: {
items: [{
id: referencia,
title: "Anticipo de reserva",
quantity: 1,
unit_price: montoMXN,
}],
external_reference: referencia, // = nuestra referencia única
notification_url: "https://tudominio.com/api/webhooks/mercadopago",
back_urls: { success: "https://tudominio.com/pagar/resultado" },
},
});
return preferencia.init_point; // URL del checkout, igual que en Wompi
}
Cambian los nombres (external_reference en vez de reference, notification_url en vez de registrar la URL en el dashboard), pero el esqueleto — referencia única, checkout externo, webhook firmado, máquina de estados — es idéntico. Con PayU y Stripe pasa lo mismo.
- Confirmar el pago en la página de "gracias". Solución: la página de resultado solo muestra; quien decide es el webhook verificado.
- No verificar la firma del webhook.
Solución: sin verificación, cualquiera con tu URL confirma citas gratis con un
curl. La firma es obligatoria, no opcional. - Procesar el mismo evento dos veces. Solución: idempotencia — revisa el estado actual antes de aplicar cambios. Las pasarelas reintentan y duplican eventos por diseño.
- Calcular el monto en el frontend. Solución: el monto sale de la base de datos en el servidor y viaja firmado (firma de integridad). El navegador solo ve el resultado.
- Usar las llaves de producción para desarrollar. Solución: sandbox hasta el módulo 6. Las llaves productivas entran solo como variables de entorno del hosting.
"La Sazón de la Abuela" (el restaurante del módulo 2) ahora quiere cobrar pedidos a domicilio en línea. Atiende en Barranquilla, pero la dueña planea abrir sede en Ciudad de México el próximo año.
- Con la tabla de la sección 1, decide: ¿qué pasarela usarías hoy y cuál agregarías para México? Justifica con los métodos de pago de cada país.
- Diseña la máquina de estados del pedido:
carrito → pendiente_pago → pagado → en_cocina → despachado → entregado. ¿Qué evento dispara cada transición? ¿Cuáles dispara el webhook y cuáles el personal? - Escribe la regla de negocio para: "si el pago no se aprueba en 20 minutos, el pedido se cancela y el stock se libera".
La guía de pasarelas de la región está en los recursos descargables del módulo.
4.3. Mini-proyecto del módulo
Tu app ahora cobra: tabla pagos con referencia única, botón "Pagar anticipo" que redirige al checkout sandbox de Wompi con firma de integridad, webhook en /api/webhooks/wompi que verifica firma + idempotencia + monto, y la cita pasa a confirmada cuando el pago se aprueba. Prueba el ciclo completo con la tarjeta de test de Wompi (4242 4242 4242 4242, aprobada) y verifica en la tabla que el estado cambió por el webhook, no por el redirect.
- En LatAm mandan los métodos locales: PSE, Nequi, OXXO, Pix, Yape. La pasarela correcta es la que los soporta en tu país.
- El patrón universal de pagos: referencia única → checkout externo → webhook firmado → máquina de estados. Igual en Wompi, Mercado Pago, PayU y Stripe.
- El webhook es la fuente de verdad: firma verificada, idempotencia y validación de monto. El redirect del navegador solo decora.
- Los pagos son máquinas de estados: modelar PENDIENTE/APROBADO/RECHAZADO/ANULADO y sus casos borde es lo que hace tu negocio confiable.