Saltar al contenido

Marketing · Intermedio

Cobra con Mercado Pago directo en tu webque nadie se vaya de tu página justo cuando ya decidió comprar

Cuando vendes un taller, un curso o un boleto desde una landing, lo más rápido es pegar un link de pago. Funciona, pero pagas con la única cosa que no se recupera: la persona con la tarjeta en la mano se va a otro dominio, con otro diseño, y tú te quedas ciego. Montar el checkout adentro no es mucho más trabajo que un link, siempre que respetes tres reglas: el navegador nunca decide el monto, el webhook nunca se cree su propio cuerpo, y el estado del pago vive en tu base de datos. Esta guía es esa implementación completa, con el código que quedó en producción en una landing de talleres presenciales.

Publicada 28 de agosto de 2026Lectura 30 minNecesitas una web propia

De un vistazo

01

El cobro ocurre dentro de tu página, sin mandar a nadie fuera

02

El webhook firmado es el que decide si el pago fue real

03

El estado vive en tu tabla, no en el proveedor

01 el punto de partida

Por qué un checkout embebido y no un link

Un link de pago es una landing ajena. La persona sale de tu página, ve un logo que no es el tuyo, un monto sin contexto y un formulario que no puedes tocar. Si le rechazan la tarjeta, la disculpa la da otro. Si abandona, no te enteras. Y cuando paga, el aviso te llega por correo o por un panel que alguien tiene que revisar a mano para dar acceso.

Cero salida de tu dominio

El brick se pinta dentro de tu página, con tu copy alrededor. La persona que ya leyó la oferta no vuelve a decidir en otro sitio.

Sabes qué pasó con cada intento

Cada pago queda en tu tabla con estado y detalle. Un rechazo por fondos insuficientes es un lead al que puedes escribir; un link externo se lo traga.

Acceso automático

Pago aprobado dispara lo que sigue: alta en tu plataforma de cursos, correo, mensaje. Nadie revisa un panel a las 11 de la noche.

Cupones y precios tuyos

El descuento se calcula en tu servidor con tu tabla. Sabes qué cupón vendió, cosa que los cupones nativos de la plataforma no te dicen.

Mercado Pago ofrece cuatro bricks: Payment (varios medios en un módulo), Card Payment (solo tarjeta), Wallet (botón de cuenta Mercado Pago) y Status Screen. Esta guía usa Payment Brick porque en México quieres tarjeta y transferencia en el mismo formulario. Si solo vendes con tarjeta, Card Payment Brick es el mismo flujo con menos opciones.

02 antes de escribir código

Credenciales: cuáles, dónde y cuáles ignorar

En el panel de integraciones creas una aplicación y de ahí salen dos pares de llaves: las de prueba (prefijo TEST-) y las de producción (prefijo APP_USR-). Cada par tiene una public key, que va al navegador, y un access token, que jamás sale del servidor. Dos cosas que me hicieron perder tiempo:

  • El medidor de 'etapa X de 6' del panel no bloquea las credenciales de producción. Es un indicador de calidad de integración que afecta la tasa de aprobación, no un requisito. Las de producción solo piden industria, sitio web, términos y un captcha.
  • Client ID y Client Secret no se usan en este flujo. Son para OAuth cuando cobras a nombre de terceros. Si cobras a tu propia cuenta, ignóralos.
  • El endpoint /users/me te dice si puedes vender y cobrar, pero no dice nada de retirar. La única prueba de que el dinero sale de la cuenta es retirarlo. Haz un cobro real chico y retíralo antes de lanzar.
  • Una cuenta personal (no de negocios) cobra igual pero puede limitar facturación. Decídelo antes de la campaña, no después.
Atajo: clonar el repo de esta guía (terminal)
git clone https://github.com/davidiriza-lab/checkout-mercadopago-nextjs.git
Repo público con licencia MIT: github.com/davidiriza-lab/checkout-mercadopago-nextjs. Trae los archivos de abajo listos para copiar a tu proyecto.
.env.local (las de producción, sin prefijo NEXT_PUBLIC_ para las secretas)
# Va al navegador: solo tokeniza tarjetas, no puede cobrar
NEXT_PUBLIC_MP_PUBLIC_KEY=APP_USR-...

# Solo servidor: crea y consulta pagos
MP_ACCESS_TOKEN=APP_USR-...

# Solo servidor: se genera al guardar el webhook en el panel
MP_WEBHOOK_SECRET=...

# Supabase, solo servidor
SUPABASE_URL=https://xxxx.supabase.co
SUPABASE_SERVICE_ROLE_KEY=...

La regla de siempre: cualquier variable con NEXT_PUBLIC_ termina en el bundle del navegador. Access token, secreto del webhook y service role van sin prefijo, y todas las escrituras a tu base pasan por API routes.

03 el cliente

El brick en tu landing

El brick se construye con el SDK de navegador (https://sdk.mercadopago.com/js/v2) y la public key. Tú le das un contenedor, el monto a mostrar, el correo del comprador y un callback onSubmit que recibe formData: el token de la tarjeta, payment_method_id, issuer_id, installments y el payer. Ese objeto tiene la forma que espera la API de pagos, pero no lo mandes tal cual: tu API route lo va a corregir.

components/Pago.tsx
"use client";

import { useEffect, useRef, useState } from "react";

export type ResultadoPago = "aprobado" | "pendiente" | "rechazado";

interface FormDataBrick {
  token?: string;
  payment_method_id?: string;
  issuer_id?: string | number;
  installments?: number;
  payer?: { email?: string };
}

interface BrickMP {
  unmount: () => void;
}

interface ConstructorMP {
  new (publicKey: string, opciones: { locale: string }): {
    bricks: () => {
      create: (tipo: string, contenedor: string, opciones: unknown) => Promise<BrickMP>;
    };
  };
}

declare global {
  interface Window {
    MercadoPago?: ConstructorMP;
  }
}

const CONTENEDOR = "brick-pago";

export default function Pago({
  email,
  monto,
  cupon,
  onResultado,
}: {
  email: string;
  monto: number;
  cupon: string | null;
  onResultado: (r: ResultadoPago) => void;
}) {
  const [cargando, setCargando] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const montado = useRef(false);

  useEffect(() => {
    if (montado.current) return;
    montado.current = true;

    const publicKey = process.env.NEXT_PUBLIC_MP_PUBLIC_KEY;
    if (!publicKey) {
      setError("Falta configurar el cobro.");
      setCargando(false);
      return;
    }

    let brick: BrickMP | null = null;

    const construir = async () => {
      const mp = new (window.MercadoPago as ConstructorMP)(publicKey, { locale: "es-MX" });
      brick = await mp.bricks().create("payment", CONTENEDOR, {
        initialization: {
          amount: monto,
          payer: { email: email || undefined },
        },
        customization: {
          paymentMethods: {
            creditCard: "all",
            debitCard: "all",
            bankTransfer: "all",
            maxInstallments: 3,
          },
        },
        callbacks: {
          onReady: () => setCargando(false),
          onSubmit: async ({ formData }: { formData: FormDataBrick }) => {
            const res = await fetch("/api/pago", {
              method: "POST",
              headers: { "Content-Type": "application/json" },
              body: JSON.stringify({ formData, cupon }),
            });
            const datos = (await res.json()) as { estado?: string; error?: string };
            if (!res.ok) {
              onResultado("rechazado");
              throw new Error(datos.error ?? "pago rechazado");
            }
            if (datos.estado === "approved") onResultado("aprobado");
            else if (datos.estado === "rejected") onResultado("rechazado");
            else onResultado("pendiente");
          },
          onError: () => {
            setError("Algo falló con el formulario de pago.");
            setCargando(false);
          },
        },
      });
    };

    const sdkListo = () => {
      void construir().catch(() => {
        setError("No se pudo cargar el formulario de pago.");
        setCargando(false);
      });
    };

    if (window.MercadoPago) {
      sdkListo();
    } else {
      const s = document.createElement("script");
      s.src = "https://sdk.mercadopago.com/js/v2";
      s.onload = sdkListo;
      s.onerror = () => {
        setError("No se pudo cargar el formulario de pago.");
        setCargando(false);
      };
      document.body.appendChild(s);
    }

    return () => {
      brick?.unmount();
    };
  }, [email, monto, cupon, onResultado]);

  return (
    <div>
      {cargando && !error && <p>Preparando el pago seguro...</p>}
      {error && <p>{error}</p>}
      <div id={CONTENEDOR} />
    </div>
  );
}
  • El ref montado evita que el brick se construya dos veces en desarrollo con StrictMode. Sin eso ves dos formularios apilados.
  • Sin efectivo (ticket) a propósito: OXXO y similares tardan en acreditarse. Si tu evento es en dos semanas, un pago pendiente en efectivo es un lugar que no sabes si vender. Si vendes algo digital y puedes esperar de uno a dos días, agrega ticket: 'all' a paymentMethods y el brick pinta la opción de OXXO; el pago nace pending y solo el webhook lo cierra.
  • Lo que el brick manda como monto es decorativo. Lo verás en la siguiente sección: el servidor lo pisa.
  • Si prefieres React puro, el paquete @mercadopago/sdk-react expone initMercadoPago y el componente Payment con las mismas opciones.

04 el servidor

La API route que crea el pago

Aquí vive la decisión más importante de toda la integración: el precio se decide en el servidor. El brick manda transaction_amount, y cualquiera con las DevTools abiertas lo cambia. Lo probé: mandé el pago del taller a un peso desde el navegador y el servidor lo cobró al precio real porque reescribe el campo. El cupón viaja como código, jamás como precio, y el servidor lo revalida contra su tabla.

lib/mp.ts
import { MercadoPagoConfig } from "mercadopago";

/** Precio canónico. El servidor nunca confía en el monto del cliente. */
export const PRECIO_MXN = 3997;
export const DESCRIPCION_PAGO = "Taller presencial · 29 de agosto";

/**
 * Lo que ve el comprador en su estado de cuenta. Va por pago, no por cuenta:
 * sin él aparece MERPAGO* seguido de tu nombre personal, y eso se traduce
 * en "cargo no reconocido" y contracargos.
 */
export const DESCRIPTOR_TARJETA = "MIMARCA*TALLER";

export function clienteMP() {
  const accessToken = process.env.MP_ACCESS_TOKEN;
  if (!accessToken) return null;
  return new MercadoPagoConfig({ accessToken, options: { timeout: 8000 } });
}

/** Llamada directa a PostgREST con service role. Solo servidor. */
export async function supabase(ruta: string, init: RequestInit = {}): Promise<Response> {
  return fetch(`${process.env.SUPABASE_URL}/rest/v1/${ruta}`, {
    ...init,
    headers: {
      apikey: process.env.SUPABASE_SERVICE_ROLE_KEY as string,
      Authorization: `Bearer ${process.env.SUPABASE_SERVICE_ROLE_KEY}`,
      "Content-Type": "application/json",
      ...(init.headers ?? {}),
    },
  });
}
app/api/pago/route.ts
import { NextResponse } from "next/server";
import { Payment } from "mercadopago";
import { DESCRIPCION_PAGO, DESCRIPTOR_TARJETA, PRECIO_MXN, clienteMP, supabase } from "@/lib/mp";
import { consumirCupon, validarCupon } from "@/lib/cupones";

interface FormDataBrick {
  token?: string;
  payment_method_id?: string;
  /** El brick lo manda como texto; el SDK lo tipa numérico. */
  issuer_id?: string | number;
  installments?: number;
  transaction_amount?: number;
  payer?: { email?: string; identification?: { type?: string; number?: string } };
}

interface Cuerpo {
  formData?: FormDataBrick;
  cupon?: string;
}

export async function POST(req: Request) {
  const cliente = clienteMP();
  if (!cliente) {
    return NextResponse.json({ error: "Cobro no configurado" }, { status: 500 });
  }

  let cuerpo: Cuerpo;
  try {
    cuerpo = (await req.json()) as Cuerpo;
  } catch {
    return NextResponse.json({ error: "JSON inválido" }, { status: 400 });
  }

  const form = cuerpo.formData;
  if (!form?.payment_method_id) {
    return NextResponse.json({ error: "Faltan datos de pago" }, { status: 400 });
  }

  // El navegador solo manda el código; el precio se recalcula aquí.
  const aplicado = cuerpo.cupon ? await validarCupon(cuerpo.cupon) : null;
  const monto = aplicado?.precio ?? PRECIO_MXN;

  const { issuer_id, ...restoForm } = form;
  const pago = new Payment(cliente);

  try {
    const creado = await pago.create({
      body: {
        ...restoForm,
        ...(issuer_id !== undefined ? { issuer_id: Number(issuer_id) } : {}),
        transaction_amount: monto,
        description: DESCRIPCION_PAGO,
        statement_descriptor: DESCRIPTOR_TARJETA,
        installments: form.installments ?? 1,
        metadata: { cupon: aplicado?.codigo ?? null },
      },
      // Un doble toque en el botón no debe generar dos cargos.
      requestOptions: { idempotencyKey: crypto.randomUUID() },
    });

    await supabase("pagos?on_conflict=mp_payment_id", {
      method: "POST",
      headers: { Prefer: "resolution=merge-duplicates" },
      body: JSON.stringify({
        mp_payment_id: String(creado.id),
        estado: creado.status ?? "pending",
        estado_detalle: creado.status_detail ?? null,
        monto,
        monto_original: PRECIO_MXN,
        cupon_codigo: aplicado?.codigo ?? null,
        metodo: creado.payment_method_id ?? null,
        email: creado.payer?.email ?? form.payer?.email ?? null,
        crudo: creado,
      }),
    });

    // El uso del cupón se consume solo si el dinero entró.
    if (creado.status === "approved" && aplicado) {
      await consumirCupon(aplicado.codigo);
    }

    return NextResponse.json({
      id: creado.id,
      estado: creado.status,
      detalle: creado.status_detail,
    });
  } catch (e) {
    console.error("mp pago", e);
    return NextResponse.json({ error: "No se pudo procesar el pago" }, { status: 502 });
  }
}
  • issuer_id llega como string desde el brick y el SDK lo tipa number. Si no lo conviertes, TypeScript no compila. Es la primera pared con la que chocas.
  • X-Idempotency-Key es obligatorio en la API de pagos desde 2024. El SDK lo manda si le pasas idempotencyKey en requestOptions; un UUID v4 por intento basta.
  • statement_descriptor va por pago, no por cuenta. Cada producto puede tener el suyo y no afecta tus otros cobros. La documentación no publica el límite de caracteres: lo validé mandando un pago con token inválido a propósito y revisando si el error mencionaba el descriptor. Quince caracteres pasan.
  • La respuesta ya trae status (approved, rejected, in_process, pending) y status_detail. Con tarjeta, la mayoría de las veces sabes el resultado en esta misma llamada. El webhook es el respaldo, no el camino principal.

05 el respaldo que no miente

El webhook: firma HMAC y consulta por id

Mercado Pago avisa por POST a tu URL cada vez que un pago cambia de estado, con headers x-signature (ts=...,v1=...) y x-request-id, y el id del pago en data.id (también como query param). La firma es HMAC-SHA256 con la clave secreta que el panel genera al guardar el webhook, sobre un manifiesto id:{data.id};request-id:{x-request-id};ts:{ts};. No lo armes a mano: el SDK de Node exporta WebhookSignatureValidator y el error InvalidWebhookSignatureError desde la raíz del paquete.

La regla que separa una integración segura de una que te pueden vaciar: el cuerpo de la notificación no se usa para nada más que sacar el id. El estado real se consulta a Mercado Pago con el access token. Cualquiera puede pegarte un POST que diga approved; nadie puede hacer que la API de Mercado Pago lo diga.

app/api/mp/webhook/route.ts
import { NextResponse } from "next/server";
import { InvalidWebhookSignatureError, Payment, WebhookSignatureValidator } from "mercadopago";
import { clienteMP, supabase } from "@/lib/mp";
import { darAcceso } from "@/lib/acceso";

export async function POST(req: Request) {
  const url = new URL(req.url);

  let cuerpo: { type?: string; action?: string; data?: { id?: string } } = {};
  try {
    cuerpo = await req.json();
  } catch {
    /* A veces avisa sin cuerpo, con los datos en el query */
  }

  const dataId =
    cuerpo.data?.id ?? url.searchParams.get("data.id") ?? url.searchParams.get("id") ?? undefined;

  const secreto = process.env.MP_WEBHOOK_SECRET;
  if (!secreto) {
    return NextResponse.json({ error: "webhook sin secreto" }, { status: 500 });
  }

  try {
    WebhookSignatureValidator.validate({
      xSignature: req.headers.get("x-signature") ?? undefined,
      xRequestId: req.headers.get("x-request-id") ?? undefined,
      dataId,
      secret: secreto,
      toleranceSeconds: 300,
    });
  } catch (e) {
    if (e instanceof InvalidWebhookSignatureError) {
      console.warn("mp webhook: firma inválida", e.reason);
      return NextResponse.json({ error: "firma inválida" }, { status: 401 });
    }
    throw e;
  }

  // Solo nos interesan los avisos de pago. Los demás se confirman y se ignoran.
  if (cuerpo.type === "payment" && dataId) {
    try {
      await sincronizarPago(dataId);
    } catch (e) {
      // Confirmamos igual: si respondemos error, Mercado Pago reintenta
      // cada 15 minutos y un bug nuestro se vuelve un ciclo.
      console.error("mp webhook: no se pudo sincronizar", dataId, e);
    }
  }

  return NextResponse.json({ ok: true });
}

/** Consulta el pago en Mercado Pago y refleja su estado real en la base. */
async function sincronizarPago(dataId: string) {
  const cliente = clienteMP();
  if (!cliente) return;

  const pago = await new Payment(cliente).get({ id: dataId });

  await supabase("pagos?on_conflict=mp_payment_id", {
    method: "POST",
    headers: { Prefer: "resolution=merge-duplicates" },
    body: JSON.stringify({
      mp_payment_id: String(pago.id),
      estado: pago.status ?? "pending",
      estado_detalle: pago.status_detail ?? null,
      monto: pago.transaction_amount ?? 0,
      metodo: pago.payment_method_id ?? null,
      email: pago.payer?.email ?? null,
      crudo: pago,
      updated_at: new Date().toISOString(),
    }),
  });

  if (pago.status === "approved" && pago.payer?.email) {
    await darAcceso({ paymentId: String(pago.id), email: pago.payer.email });
  }
}

/** Mercado Pago pega un GET al dar de alta la URL. */
export async function GET() {
  return NextResponse.json({ ok: true });
}

Alta del webhook en el panel (en este orden)

  1. Despliega el endpoint primero. Si registras la URL antes de que exista, la notificación de prueba se topa con un 404 y el panel te lo marca en rojo.
  2. En la aplicación: Notificaciones, Webhooks, Configurar notificaciones. Pon la URL de producción (https://tudominio.com/api/mp/webhook).
  3. Marca el evento 'Pagos'. En el panel aparece como legacy porque Mercado Pago empuja su API de Orders, pero es el que corresponde a la API de pagos que usa el brick.
  4. Guarda. La clave secreta se genera al guardar, no antes. Cópiala a MP_WEBHOOK_SECRET y vuelve a desplegar.
  5. No configures IPN. Es el sistema anterior; tenerlo junto con Webhooks duplica avisos.

La trampa que más tiempo me costó: el ts del header viene en segundos. Si armas el manifiesto con Date.now() en milisegundos, la firma no cuadra y todo responde 401. El validador del SDK ya lo maneja (convierte ts a milisegundos para la tolerancia), y es la razón para no armar el HMAC a mano. Responde 200 en menos de 22 segundos: si te pasas, Mercado Pago reintenta cada 15 minutos.

Un detalle que te ahorra confusión cuando leas guías de otros procesadores: Mercado Pago no firma el cuerpo de la notificación, firma el manifiesto (id, request-id y ts). Por eso aquí puedes hacer req.json() antes de validar sin romper nada. En Stripe es al revés: la firma es sobre el cuerpo crudo, y parsear el JSON antes de verificar te da un error de firma que parece un secreto mal copiado. Lo verás en la sección de Stripe.

06 tu base de datos

El estado del pago vive en tu tabla

Dos rutas escriben en la misma tabla: la API route al crear el pago y el webhook al sincronizarlo. Por eso la tabla se indexa por el id del pago de Mercado Pago y las dos escrituras son upserts. Un pago que en la creación quedó in_process y que el webhook luego marca approved termina como una sola fila con el estado correcto.

Migración SQL: pagos y cupones
create table pagos (
  id uuid primary key default gen_random_uuid(),
  mp_payment_id text unique not null,
  estado text not null,             -- pending | approved | rejected | in_process | ...
  estado_detalle text,
  monto numeric(10,2) not null,
  monto_original numeric(10,2),
  cupon_codigo text,
  metodo text,
  email text,
  acceso_otorgado_en timestamptz,
  crudo jsonb,
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);
create index pagos_estado_idx on pagos (estado);

create table cupones (
  codigo text primary key,
  tipo text not null check (tipo in ('porcentaje', 'monto')),
  valor numeric(10,2) not null,
  usos_max int,
  usos int not null default 0,
  expira_en timestamptz,
  activo boolean not null default true
);

-- Consume un uso de forma atómica. Devuelve 0 filas si ya no quedan.
create or replace function usar_cupon(p_codigo text)
returns setof cupones
language sql
as $$
  update cupones
     set usos = usos + 1
   where codigo = p_codigo
     and activo
     and (usos_max is null or usos < usos_max)
  returning *;
$$;

-- Nadie desde el navegador ejecuta la RPC ni toca las tablas.
revoke execute on function usar_cupon(text) from public, anon;
alter table pagos enable row level security;
alter table cupones enable row level security;
EstadoQué significaQué haces
approvedEl dinero entró.Dar acceso, consumir cupón, marcar la fila con acceso_otorgado_en.
in_process / pendingTarjeta en revisión o transferencia sin acreditar.Pantalla de 'te avisamos'. El webhook cierra el ciclo.
rejectedRechazado; status_detail dice por qué.Mostrar el motivo traducido y dejar reintentar con otra tarjeta.
refunded / charged_backDevolución o contracargo, llega solo por webhook.Revocar acceso y avisar.

Lo que sigue al approved es el otro medio proyecto. En una venta directa el patrón es: el webhook (o la API route, si la respuesta ya fue approved) llama a una función darAcceso idempotente que da de alta al comprador en tu plataforma de cursos o comunidad, y guarda acceso_otorgado_en para no repetirlo. Si la provisión falla, la fila queda sin esa marca y un reintento manual o un cron la recoge. El webhook siempre responde 200 aunque la provisión falle: reintentar la provisión es tu trabajo, no el de Mercado Pago.

07 el detalle que vende

Cupones que se consumen al aprobar, no al escribir

Los cupones nativos de Mercado Pago están pensados para campañas de su marketplace. Una tabla propia te dice qué cupón vendió y te deja decidir las reglas. Lo que cambió el resultado fue el momento en que se gasta el uso:

lib/cupones.ts
import { PRECIO_MXN, supabase } from "@/lib/mp";

/** Mercado Pago no procesa cobros por debajo de este monto. */
export const MONTO_MINIMO = 10;

export interface Cupon {
  codigo: string;
  tipo: "porcentaje" | "monto";
  valor: number;
  usos_max: number | null;
  usos: number;
  expira_en: string | null;
  activo: boolean;
}

export interface CuponAplicado {
  codigo: string;
  precio: number;
  descuento: number;
}

export function normalizar(codigo: string): string {
  return codigo.trim().toUpperCase().slice(0, 40);
}

function calcularPrecio(cupon: Cupon): number {
  const bruto =
    cupon.tipo === "porcentaje" ? PRECIO_MXN * (1 - cupon.valor / 100) : PRECIO_MXN - cupon.valor;
  return Math.max(MONTO_MINIMO, Math.round(bruto * 100) / 100);
}

/** Única fuente de verdad del precio: el navegador manda el código, jamás el monto. */
export async function validarCupon(codigoCrudo: string): Promise<CuponAplicado | null> {
  const codigo = normalizar(codigoCrudo);
  if (!codigo) return null;

  const res = await supabase(
    `cupones?codigo=eq.${encodeURIComponent(codigo)}&activo=is.true&select=*`,
  );
  if (!res.ok) return null;

  const [cupon] = (await res.json()) as Cupon[];
  if (!cupon) return null;
  if (cupon.usos_max !== null && cupon.usos >= cupon.usos_max) return null;
  if (cupon.expira_en && new Date(cupon.expira_en) <= new Date()) return null;

  const precio = calcularPrecio(cupon);
  return { codigo: cupon.codigo, precio, descuento: Math.round((PRECIO_MXN - precio) * 100) / 100 };
}

/** Consume un uso. Solo debe llamarse cuando el pago quedó aprobado. */
export async function consumirCupon(codigo: string): Promise<void> {
  await supabase("rpc/usar_cupon", {
    method: "POST",
    body: JSON.stringify({ p_codigo: normalizar(codigo) }),
  });
}
  • Si consumes el uso cuando la persona escribe el código, los abandonos queman el cupón. Se consume cuando el pago queda approved, con una RPC atómica (update ... where usos < usos_max returning) para que dos compras simultáneas no pasen el límite.
  • El campo de cupón va discreto en la UI. Uno prominente le recuerda a quien no tiene cupón que está pagando de más.
  • Cupón de descuento alto en sitio público: código no adivinable y usos_max en 1.
  • Un cupón grande es la mejor forma de probar cobros reales sin regalar un lugar ni tocar el precio público.

08 antes de cobrarle a alguien

Tarjetas de prueba y el cobro real

Con las credenciales TEST- el brick acepta tarjetas de prueba, y el nombre del titular decide el resultado. Es la forma de recorrer aprobado, pendiente y cada tipo de rechazo sin gastar un peso.

Tarjeta (México)NúmeroCVVVence
Mastercard crédito5474 9254 3267 036612311/30
Visa crédito4075 5957 1648 376412311/30
American Express3711 803032 57522123411/30
Mastercard débito5579 0534 6148 264712311/30
Visa débito4189 1412 2126 763312311/30
Nombre del titularResultado
APROAprobado
CONTPendiente
FUNDRechazado por fondos insuficientes
SECURechazado por código de seguridad
EXPIRechazado por fecha de vencimiento
CALLRechazado, requiere validación
OTHERechazado por error general
Probar el webhook en local con la firma real (terminal)
vercel dev & sleep 5 && npx localtunnel --port 3000
Pon la URL del túnel en el panel como webhook de prueba con las credenciales TEST-. Lo que te importa verificar: que responde 200, que la firma pasa, y que la fila en pagos cambia de estado con un pago CONT.

Después, un cobro real chico con las credenciales de producción. Tres cosas que solo se ven ahí: la comisión (en un cobro de prueba de 39.97 pesos la comisión fue 6.26; ese 15.7 por ciento no es representativo porque hay un componente fijo que se come los montos chicos), el money_release_status (en ese caso released de inmediato, sin retención) y que el retiro a tu cuenta bancaria funciona. Con un solo dato no puedes separar el fijo del porcentaje; haz dos cobros de montos distintos si necesitas el desglose.

09 resumen de cicatrices

Las trampas, en una lista

  • El ts de x-signature viene en segundos. Firmar con milisegundos da 401 en todo. Usa WebhookSignatureValidator del SDK.
  • Reescribe transaction_amount en el servidor siempre. Probado: un intento de pagar a un peso desde el navegador se cobró al precio completo.
  • issuer_id llega como string y el SDK lo tipa number. Number(issuer_id) o no compila.
  • idempotencyKey en cada payment.create. Es obligatorio y evita el doble cargo por doble toque.
  • Responde 200 rápido en el webhook y procesa adentro de un try. Si respondes error por un bug tuyo, entras en el ciclo de reintentos.
  • El estado real se consulta por id con el access token. El cuerpo del webhook solo sirve para saber qué id consultar.
  • Sin statement_descriptor, el comprador ve MERPAGO* seguido de tu nombre personal en su estado de cuenta. Eso es un contracargo esperando pasar.
  • El evento del webhook para esta API se llama 'Pagos' y aparece marcado como legacy. Es el correcto. No configures IPN.
  • La clave secreta del webhook se genera al guardar, no antes. Crea el endpoint, regístralo, guarda, copia la clave, vuelve a desplegar.
  • Mercado Pago firma el manifiesto, no el cuerpo; Stripe firma el cuerpo crudo. Si migras el webhook a Stripe, lee el body con req.text() antes de constructEvent o toda firma falla.

10 si tu comprador no está en México

La misma arquitectura con Stripe

Mercado Pago es la opción correcta cuando vendes en México y Latinoamérica: cobra en pesos, acepta transferencia y OXXO, y la persona ya tiene cuenta. Pero si tu audiencia paga en dólares o euros, o vives en un país donde Mercado Pago no opera, la respuesta es Stripe. Lo importante es que no tienes que volver a aprender nada: las tres reglas son idénticas. Cambian los nombres de las llaves, el header de la firma y una trampa nueva con el cuerpo crudo.

Mercado PagoStripe
Llave del navegadorPublic key (APP_USR-)Publishable key (pk_live_ / pk_test_)
Llave del servidorAccess tokenSecret key (sk_live_ / sk_test_)
Formulario embebidoPayment BrickPayment Element
Quién decide el montoTu API route al crear el pagoTu API route al crear el PaymentIntent
Header de la firmax-signature (ts, v1)Stripe-Signature (t, v1)
Qué se firmaEl manifiesto id;request-id;tsEl cuerpo crudo de la petición
Validador del SDKWebhookSignatureValidator.validatestripe.webhooks.constructEvent
Tolerancia por defectoLa que tú pases (300 s en esta guía)5 minutos; nunca la pongas en 0
Reintentos si no respondes 2xxCada 15 minutosHasta 3 días con espera exponencial
Probar en localTúnel + webhook de prueba en el panelstripe listen --forward-to
Comisión en México (tarjeta nacional)Varía por cuenta y plazo de liberación3.6 % + 3 MXN; +0.5 % internacional; +2 % si convierte moneda
Instalar los paquetes (terminal)
npm install stripe @stripe/stripe-js @stripe/react-stripe-js
Verificado con stripe 22.6, @stripe/stripe-js 9.14 y @stripe/react-stripe-js 6.8. El paquete stripe va solo al servidor; los otros dos al cliente.
app/api/stripe/intent/route.ts (el servidor decide el monto)
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { PRECIO_MXN, DESCRIPCION_PAGO, DESCRIPTOR_TARJETA } from "@/lib/mp";
import { validarCupon } from "@/lib/cupones";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string);

export async function POST(req: Request) {
  const { cupon, email } = (await req.json()) as { cupon?: string; email?: string };

  // Igual que con Mercado Pago: el navegador manda el código, nunca el precio.
  const aplicado = cupon ? await validarCupon(cupon) : null;
  const monto = aplicado?.precio ?? PRECIO_MXN;

  const intent = await stripe.paymentIntents.create(
    {
      amount: Math.round(monto * 100), // Stripe cobra en centavos
      currency: "mxn",
      description: DESCRIPCION_PAGO,
      statement_descriptor_suffix: DESCRIPTOR_TARJETA.slice(0, 22),
      receipt_email: email,
      automatic_payment_methods: { enabled: true },
      metadata: { cupon: aplicado?.codigo ?? "" },
    },
    { idempotencyKey: crypto.randomUUID() },
  );

  return NextResponse.json({ clientSecret: intent.client_secret });
}
app/api/stripe/webhook/route.ts (cuerpo crudo, firma, dedup por evento)
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { supabase } from "@/lib/mp";
import { darAcceso } from "@/lib/acceso";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string);

export async function POST(req: Request) {
  // Aquí sí importa: la firma es sobre el cuerpo crudo. Nada de req.json() antes.
  const crudo = await req.text();
  const firma = req.headers.get("stripe-signature") ?? "";

  let evento: Stripe.Event;
  try {
    evento = stripe.webhooks.constructEvent(crudo, firma, process.env.STRIPE_WEBHOOK_SECRET as string);
  } catch {
    return NextResponse.json({ error: "firma inválida" }, { status: 400 });
  }

  // Stripe reintenta hasta 3 días: el id del evento es tu candado contra procesar dos veces.
  const yaVisto = await supabase("eventos_stripe", {
    method: "POST",
    headers: { Prefer: "return=minimal" },
    body: JSON.stringify({ event_id: evento.id, tipo: evento.type }),
  });
  if (yaVisto.status === 409) return NextResponse.json({ ok: true, duplicado: true });

  try {
    if (evento.type === "payment_intent.succeeded") {
      const pi = evento.data.object;
      await supabase("pagos?on_conflict=mp_payment_id", {
        method: "POST",
        headers: { Prefer: "resolution=merge-duplicates" },
        body: JSON.stringify({
          mp_payment_id: pi.id, // misma tabla; la columna guarda el id del procesador
          estado: "approved",
          monto: pi.amount_received / 100,
          metodo: "stripe",
          email: pi.receipt_email ?? null,
          crudo: pi,
          updated_at: new Date().toISOString(),
        }),
      });
      if (pi.receipt_email) await darAcceso({ paymentId: pi.id, email: pi.receipt_email });
    }
    if (evento.type === "charge.refunded" || evento.type === "charge.dispute.created") {
      // Revocar acceso y avisar, igual que refunded / charged_back en Mercado Pago.
    }
  } catch (e) {
    console.error("stripe webhook", evento.type, e);
  }

  return NextResponse.json({ ok: true });
}
Tabla de dedup para los eventos de Stripe
create table eventos_stripe (
  event_id text primary key,
  tipo text not null,
  recibido_en timestamptz default now()
);
alter table eventos_stripe enable row level security;
  • En el cliente, el Payment Element se monta con la publishable key y el clientSecret que te devuelve /api/stripe/intent; se confirma con stripe.confirmPayment y redirect: 'if_required' para que el 3D Secure no te saque de la página si el banco no lo exige.
  • Stripe hoy recomienda crear una Checkout Session con ui_mode elements en lugar de un PaymentIntent suelto. Para un solo producto a precio fijo el PaymentIntent es menos código y hace exactamente lo de esta guía; si necesitas impuestos automáticos, varios artículos o precios adaptativos por país, ve por Checkout Sessions.
  • Para probar en local no necesitas túnel: stripe listen --forward-to localhost:3000/api/stripe/webhook te imprime un whsec_ temporal y stripe trigger payment_intent.succeeded dispara el evento. Necesitas instalar la CLI de Stripe; no la corrí para esta guía.
  • Marca charge.dispute.created en el panel aunque solo vendas una vez. Un contracargo que nadie responde se pierde solo.
  • Si vendes a Europa y quieres que el impuesto lo resuelva otro, existe la figura de merchant of record (Lemon Squeezy, Paddle). Es un link o overlay externo, no un checkout embebido: rompe la premisa de esta guía y no lo probé.

11 si prefieres que un agente lo escriba

PagoKit: el mismo patrón generado por Claude Code

Existe un plugin gratuito de Claude Code, PagoKit (MIT), que hace algo parecido a esta guía pero en modo asistente: lee tu proyecto, te pregunta país, si cobras una vez o por suscripción y si necesitas métodos locales, y con eso elige entre Stripe, Mercado Pago, Wompi (Colombia) y Lemon Squeezy, y escribe los archivos: componente, endpoint de checkout, webhook firmado, endpoint de devoluciones, portal del cliente, esquema de base y un checklist de producción. Lo cloné y lo revisé archivo por archivo antes de recomendarlo.

Instalar y arrancar (terminal)
git clone https://github.com/Hainrixz/agente-pagokit ~/agente-pagokit && cd ~/tu-proyecto && claude --plugin-dir ~/agente-pagokit
Dentro de Claude Code: /pagokit:start (asistente completo), /pagokit:doctor (audita una integración que ya tienes, sin escribir nada) y /pagokit:test (manda eventos firmados, inválidos y repetidos a tu webhook local; para Stripe usa la CLI de Stripe). Pide Node 18+ y Claude Code 2.x.
Lo que verifiqué

claude plugin validate pasa. Sus 42 pruebas de validadores pasan (npm run test:validators). Las plantillas de Mercado Pago firman el manifiesto correcto y consultan el pago por id, igual que aquí. Las de Stripe usan req.text() y constructEvent.

Los candados

Hooks de Claude Code que bloquean la escritura si el archivo trae una llave real inline (sk_live_, APP_USR-), si crea .env sin .gitignore, si el webhook no verifica firma, si la idempotencia usa Math.random() o Date.now(), o si parsea el JSON antes de verificar. Son scripts de Node, no texto en un prompt.

Sus límites

Solo Next.js App Router y Express, con Prisma, Drizzle o SQLAlchemy. Si tu base es Supabase por PostgREST como en esta guía, adaptas lib/db.ts a mano. Los candados solo se activan en archivos con 'webhook' en la ruta o que usen sus nombres de verificador; ponle ese nombre a tu ruta.

Lo que no probé

No corrí /pagokit:start de punta a punta sobre un proyecto real ni /pagokit:test contra Stripe. Lo que afirmo es lo que leí en sus plantillas y lo que sus pruebas demuestran.

Mi lectura: si ya entendiste esta guía, los 14 archivos que genera son los mismos que ya tienes, con dos extras que aquí no cubro (devoluciones desde un endpoint y portal del cliente) y una decisión distinta que vale la pena conocer: sus plantillas dan acceso solo desde el webhook, nunca desde la respuesta de la API route. Es más conservador; para tarjeta yo doy acceso en cuanto payment.create responde approved y dejo el webhook como respaldo. Donde sí le saco jugo es en /pagokit:doctor sobre una integración que ya existe: te dice en segundos si una llave viva quedó en el código o si el webhook no verifica firma.

FAQ lo que suelen preguntar

Preguntas frecuentes

¿Puedo saltarme el webhook si la API route ya me devuelve approved?

Para tarjeta, la respuesta de payment.create casi siempre trae el estado final y puedes dar acceso ahí mismo. Pero transferencias, pagos en revisión, devoluciones y contracargos solo llegan por webhook. Sin él, tu tabla se queda con pagos in_process que nunca se cierran y con accesos que nunca se revocan.

¿Por qué no validar la firma a mano con crypto.createHmac?

Se puede: el manifiesto es id:{data.id};request-id:{x-request-id};ts:{ts}; con HMAC-SHA256 en hex y comparación en tiempo constante. Pero el validador del SDK ya hace eso, omite las partes vacías como dicta la documentación, compara en tiempo constante y convierte el ts a milisegundos para la tolerancia. Armarlo a mano es exactamente donde se cuela el error de segundos contra milisegundos.

¿Qué pasa si el webhook llega antes de que la API route escriba la fila?

Nada grave, y por eso las dos escrituras son upserts sobre mp_payment_id. La que llegue segunda solo actualiza el estado. Lo que sí importa es que darAcceso sea idempotente y que marques acceso_otorgado_en para no dar de alta dos veces.

¿Cuotas sin intereses o con intereses?

maxInstallments en el brick limita cuántas cuotas se ofrecen; quién absorbe el interés se configura en la cuenta de Mercado Pago, no en el código. Para un taller de precio medio, tres cuotas es un buen tope: más opciones alargan el formulario sin subir conversión.

¿Puedo reutilizar esto con Stripe o con otro procesador?

La arquitectura sí: formulario embebido con llave pública, API route que crea el cargo con llave secreta y reescribe el monto, webhook firmado que consulta el estado por id, tabla propia con upsert. Cambian los nombres de los campos y el algoritmo de firma. Las tres reglas son las mismas en cualquier procesador. La sección de Stripe trae el código equivalente.

¿Mercado Pago o Stripe? ¿Cómo decido?

Por dónde está el comprador, no por dónde estás tú. Si vendes en México o Latinoamérica en moneda local, Mercado Pago: la persona ya tiene cuenta, hay transferencia y OXXO, y la tasa de aprobación con tarjetas locales es mejor. Si cobras en dólares o euros a gente fuera de la región, Stripe. Si son las dos audiencias, monta los dos con la misma tabla de pagos: la columna del id del procesador y la columna metodo te dicen de dónde vino cada fila.

¿Y si quiero aceptar OXXO?

Agrega ticket: 'all' a paymentMethods en el brick. El pago nace pending con un voucher que la persona paga en tienda, y se acredita entre unas horas y dos días después; solo el webhook lo cierra. Tiene sentido para productos digitales o cuando hay tiempo antes del evento. Para un taller que es pasado mañana, no: te quedas sin saber si ese lugar está vendido.

¿Cómo hago una devolución sin entrar al panel?

El SDK de Node exporta PaymentRefund: new PaymentRefund(cliente).create({ payment_id, body: { amount } }) devuelve total o parcial. Ponlo detrás de una API route protegida con contraseña server-side, nunca expuesta al público. El webhook te avisa después con el estado refunded y ahí revocas el acceso. No cubrí devoluciones en producción en esta guía; la firma del método está en el SDK y la vi, pero pruébala con credenciales TEST- antes de confiar.

¿Sirve para suscripciones?

No tal cual. Esta guía es cobro único. Mercado Pago maneja recurrencia con PreApproval (también exportado por el SDK) y Stripe con Subscriptions; en los dos casos aparecen eventos nuevos en el webhook (pago fallido de la renovación, cancelación) y una tabla de suscripciones aparte. Si ese es tu caso, PagoKit genera ese esqueleto y es un buen punto de partida.

¿Cuándo NO conviene montar el checkout embebido?

Cuando eres marketplace y repartes el dinero entre varios vendedores (eso es OAuth con Mercado Pago o Stripe Connect, otro proyecto); cuando tu empresa exige que pagos viva en un sistema con auditoría propia; cuando necesitas que alguien más sea el responsable fiscal de vender a Europa; o cuando ya tienes una integración funcionando y solo te molesta el diseño. En ese último caso, el brick se puede estilizar; migrar todo por estética no vale el riesgo.

Cierre de la guía

Cobrar dentro de tu landing no es una integración grande: son un componente, dos API routes y dos tablas. Lo que la hace segura son tres reglas que no se negocian: el servidor decide el monto, el webhook valida la firma y consulta el estado por id, y tu tabla es la fuente de verdad de quién pagó. Monta la versión de prueba con la tarjeta APRO, haz un cobro real chico, retíralo, y ya tienes un checkout que no te saca a nadie de tu página. Esta guía vive en el Lab de David Iriza.

Fuentes oficiales8

Sigue con estas guías

Guía escrita con la información oficial disponible al 28 de agosto de 2026. Esta página no está afiliada a Mercado Pago. Las herramientas cambian; ante la duda, revisa la documentación oficial de Mercado Pago Developers.