Checkout Pro do Mercado Pago no Next.js (com Pix)

Checkout Pro do Mercado Pago no Next.js: preferência no servidor, preço do seu catálogo, back_urls, expiração, Pix e por que o webhook confirma.

MC
Martin Coll

O Checkout Pro é a integração do Mercado Pago em que o pagamento acontece numa página do Mercado Pago: o seu servidor cria uma preferência, você redireciona o usuário para a URL que ela devolve (init_point), ele paga ali com o que tiver e volta para o seu site. É a forma mais rápida de cobrar sem lidar com dados de cartão.

No Brasil, a documentação oficial do Checkout Pro (https://www.mercadopago.com.br/developers/pt/docs/checkout-pro/overview) lista como meios de pagamento cartão de crédito ou débito, Pix, boleto, Conta Mercado Pago e Linha de Crédito. Ou seja, o seu cliente pode pagar com Pix sem você integrar nada à parte: é a mesma preferência e o mesmo webhook.

Este guia mostra como fazer isso no Next.js com o App Router, com as decisões que tomamos no adapter do useDeploy depois de cometer os erros típicos.

O fluxo em quatro passos

  1. O usuário escolhe o que comprar. O seu servidor cria a preferência com POST https://api.mercadopago.com/checkout/preferences.
  2. Você redireciona o usuário para o init_point da resposta.
  3. Ele paga no Mercado Pago e volta para uma das suas back_urls.
  4. O Mercado Pago manda um webhook. Esse webhook, e não a volta do usuário, é o que confirma o pagamento.

O passo 4 é o que mais se pula, e é o que importa. Com Pix isso pesa ainda mais: o usuário pode pagar pelo app do banco e nem voltar ao seu site, e só o webhook avisa.

Criar a preferência no servidor

Uma Server Action (ou um Route Handler) que recebe só o id do plano:

// app/checkout/actions.ts
'use server';
import 'server-only';
import { redirect } from 'next/navigation';
import { getPlanById } from '@/server/billing/plans'; // seu 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');

  // O preço sai do SEU catálogo, nunca do cliente.
  const plan = await getPlanById(planId);
  if (!plan?.active) throw new Error('Plano 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, // o Mercado Pago usa unidades maiores
          currency_id: plan.currency.toUpperCase(), // 'BRL' para cobrar em reais
        },
      ],
      payer: { email: session.user.email },
      external_reference: session.user.id, // a quem creditar o pagamento
      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);
}

O que cada decisão faz:

  • import 'server-only': se alguém importar este módulo de um componente de cliente, o build falha. O access token nunca chega ao navegador.
  • O preço sai do catálogo do servidor. Se o valor viesse do cliente, qualquer pessoa poderia pagar $1 por um plano de $199 pelo DevTools. O cliente manda um planId; o servidor decide quanto vale.
  • currency_id: a moeda em que a preferência cobra. Para cobrar em reais, BRL; a conta do Mercado Pago precisa ser do país correspondente.
  • external_reference: o id do seu usuário ou pedido. Volta no pagamento, e com isso você sabe a quem creditar quando o webhook chega.
  • auto_return: 'approved': se o pagamento é aprovado, o Mercado Pago devolve o usuário para back_urls.success sem que ele precise tocar em "voltar ao site".
  • notification_url: onde o Mercado Pago avisa das mudanças no pagamento. Precisa ser uma URL pública, então em desenvolvimento local você precisa de um túnel.
  • expires + expiration_date_to: a preferência deixa de servir após 30 minutos. Evita que alguém pague amanhã um link com um preço antigo.
  • metadata: dados seus que voltam no pagamento. Útil para saber qual plano foi comprado sem outra consulta.

A página de volta não confirma nada

Quando o usuário volta para /checkout/resultado, a URL traz parâmetros como status e payment_id. Não os use para liberar acesso. Fazem parte da URL: qualquer pessoa pode digitar ?status=approved na mão.

O correto é mostrar o estado do seu banco, que só é atualizado quando o webhook chega e é validado:

// 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>Pronto! Sua compra foi confirmada.</p>;
  return <p>Estamos confirmando seu pagamento com o Mercado Pago. Pode levar alguns segundos.</p>;
}

Se quiser que a página se atualize sozinha, adicione um refresh a cada poucos segundos (router.refresh() num componente de cliente pequeno) até o paidAt aparecer.

O webhook: onde o pagamento se confirma

O webhook do Mercado Pago não traz o pagamento, traz um aviso com o id. É preciso verificar a assinatura, ler o pagamento pela API e só então creditar, e fazer isso de forma idempotente porque ele pode chegar várias vezes. Está tudo detalhado em webhooks do Mercado Pago: como validar a assinatura.

Testar em desenvolvimento

  • Use as credenciais de teste da sua aplicação e usuários de teste.
  • Para o webhook chegar na sua máquina, exponha o endpoint com um túnel e coloque essa URL em notification_url.
  • Guarde um webhook real capturado e reenvie-o várias vezes: se o seu handler é idempotente, o resultado tem que ser uma única compra.

Perguntas frequentes

Checkout Pro ou Checkout API (Bricks)? O Checkout Pro é mais rápido de integrar e o Mercado Pago cuida da página de pagamento. Com Bricks ou a Checkout API o pagamento acontece dentro do seu site, mas você integra mais peças.

O Checkout Pro aceita Pix? No Brasil, sim, segundo a documentação oficial do Checkout Pro citada acima. É um dos meios de pagamento da própria página do Mercado Pago, junto com cartão e boleto.

Posso cobrar assinaturas com o Checkout Pro? O Checkout Pro é para pagamentos únicos. Para cobranças recorrentes o Mercado Pago tem uma API própria (preapproval), que é outra integração. O adapter do useDeploy hoje não a implementa.

Por que o usuário volta com status=approved mas o meu banco não tem o pagamento? Porque a volta e o webhook são independentes, e o webhook pode levar alguns segundos. Espere o webhook; não confirme com os parâmetros da URL.


No useDeploy esse fluxo já vem montado: a preferência com o preço do catálogo, a volta que espera o webhook e o webhook com assinatura verificada e idempotência, junto com auth, organizações e o resto de um SaaS. Veja a integração com o Mercado Pago. Se você está começando com a API, comece por API do Mercado Pago com Node.js e TypeScript (em espanhol).