Checkout Pro de Mercado Pago en Next.js

Checkout Pro de Mercado Pago en Next.js: preferencia desde el servidor, precio de tu catálogo, back_urls, expiración y por qué confirma el webhook.

MC
Martin Coll

Checkout Pro es la integración de Mercado Pago en la que el pago ocurre en una página de Mercado Pago: tu servidor crea una preferencia, redirigís al usuario a la URL que te devuelven (init_point), paga ahí con lo que tenga (tarjeta, dinero en cuenta, efectivo según el país) y vuelve a tu sitio. Es la forma más rápida de cobrar sin manejar datos de tarjeta.

Esta guía muestra cómo hacerlo en Next.js con el App Router, con las decisiones que tomamos en el adapter de useDeploy después de cometer los errores típicos.

El flujo en cuatro pasos

  1. El usuario elige qué comprar. Tu servidor crea la preferencia con POST https://api.mercadopago.com/checkout/preferences.
  2. Redirigís al usuario al init_point de la respuesta.
  3. Paga en Mercado Pago y vuelve a una de tus back_urls.
  4. Mercado Pago te manda un webhook. Ese webhook, no la vuelta del usuario, es lo que confirma el pago.

El paso 4 es el que más se saltea, y es el que importa.

Crear la preferencia en el servidor

Un Server Action (o un Route Handler) que recibe solo el id del plan:

// app/checkout/actions.ts
'use server';
import 'server-only';
import { redirect } from 'next/navigation';
import { getPlanById } from '@/server/billing/plans'; // tu catálogo
import { getSession } from '@/server/auth';

const MP_API_BASE = 'https://api.mercadopago.com';

export async function startCheckout(planId: string) {
  const session = await getSession();
  if (!session) redirect('/login');

  // El precio sale de TU catálogo, nunca del cliente.
  const plan = await getPlanById(planId);
  if (!plan?.active) throw new Error('Plan inexistente');

  const appUrl = process.env.APP_URL!;
  const res = await fetch(`${MP_API_BASE}/checkout/preferences`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.MERCADO_PAGO_ACCESS_TOKEN}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      items: [
        {
          id: plan.id,
          title: plan.name,
          quantity: 1,
          unit_price: plan.amountCents / 100, // Mercado Pago usa unidades mayores
          currency_id: plan.currency.toUpperCase(),
        },
      ],
      payer: { email: session.user.email },
      external_reference: session.user.id, // a quién acreditar el pago
      back_urls: {
        success: `${appUrl}/checkout/resultado`,
        failure: `${appUrl}/checkout/resultado`,
        pending: `${appUrl}/checkout/resultado`,
      },
      auto_return: 'approved',
      notification_url: `${process.env.API_URL}/billing/webhook`,
      metadata: { plan_id: plan.id },
      expires: true,
      expiration_date_to: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
    }),
    signal: AbortSignal.timeout(15_000),
  });
  if (!res.ok) throw new Error(`Mercado Pago ${res.status}: ${await res.text()}`);

  const { init_point } = (await res.json()) as { init_point: string };
  redirect(init_point);
}

Qué hace cada decisión:

  • import 'server-only': si alguien importa este módulo desde un componente de cliente, el build falla. El access token nunca llega al navegador.
  • El precio sale del catálogo del servidor. Si el monto viniera del cliente, cualquiera podría pagar $1 por un plan de $199 desde las DevTools. El cliente manda un planId; el servidor decide cuánto vale.
  • external_reference: el id de tu usuario u orden. Vuelve en el pago, y con eso sabés a quién acreditar cuando llega el webhook.
  • auto_return: 'approved': si el pago se aprueba, Mercado Pago devuelve al usuario a back_urls.success sin que tenga que tocar "volver al sitio".
  • notification_url: dónde Mercado Pago avisa de cambios en el pago. Tiene que ser una URL pública, así que en desarrollo local necesitás un túnel.
  • expires + expiration_date_to: la preferencia deja de servir a los 30 minutos. Evita que alguien pague mañana un link de un precio viejo.
  • metadata: datos tuyos que vuelven en el pago. Útil para saber qué plan se compró sin otra consulta.

La página de vuelta no confirma nada

Cuando el usuario vuelve a /checkout/resultado, la URL trae parámetros como status y payment_id. No los uses para dar acceso. Son parte de la URL: cualquiera puede escribir ?status=approved a mano.

Lo correcto es mostrar el estado de tu base, que solo se actualiza cuando llega y se valida el webhook:

// app/checkout/resultado/page.tsx
import { getSession } from '@/server/auth';
import { getLatestPurchase } from '@/server/billing/purchases';

export default async function Resultado() {
  const session = await getSession();
  const purchase = session ? await getLatestPurchase(session.user.id) : null;

  if (purchase?.paidAt) return <p>¡Listo! Tu compra quedó confirmada.</p>;
  return <p>Estamos confirmando tu pago con Mercado Pago. Puede tardar unos segundos.</p>;
}

Si querés que se actualice sola, sumá un refresco cada pocos segundos (router.refresh() en un componente cliente chico) hasta que paidAt aparezca.

El webhook: donde se confirma el pago

El webhook de Mercado Pago no trae el pago, trae un aviso con el id. Hay que verificar la firma, leer el pago con la API y recién ahí acreditar, y hacerlo de forma idempotente porque puede llegar varias veces. Está todo detallado en webhooks de Mercado Pago: cómo validar la firma.

Probar en desarrollo

  • Usá las credenciales de prueba de tu aplicación y usuarios de prueba.
  • Para que el webhook llegue a tu máquina, exponé el endpoint con un túnel y poné esa URL en notification_url.
  • Guardá un webhook real capturado y reenvialo varias veces: si tu handler es idempotente, el resultado tiene que ser una sola compra.

Preguntas frecuentes

¿Checkout Pro o Checkout API (Bricks)? Checkout Pro es más rápido de integrar y Mercado Pago maneja la página de pago. Con Bricks o la Checkout API el pago ocurre dentro de tu sitio, pero integrás más piezas.

¿Puedo cobrar suscripciones con Checkout Pro? Checkout Pro es para pagos únicos. Para cobros recurrentes Mercado Pago tiene la API de suscripciones (preapproval), que es otra integración. El adapter de useDeploy hoy no la implementa.

¿Por qué el usuario vuelve con status=approved pero mi base no tiene el pago? Porque la vuelta y el webhook son independientes y el webhook puede tardar unos segundos. Esperá al webhook; no confirmes con los parámetros de la URL.


En useDeploy este flujo ya está armado: la preferencia con el precio del catálogo, la vuelta que espera al webhook y el webhook con firma verificada e idempotencia, junto con auth, organizaciones y el resto de un SaaS. Mirá la integración con Mercado Pago. Si recién arrancás con la API, empezá por API de Mercado Pago con Node.js y TypeScript.