Programação Por Equipe DevBras

Capa do artigo: Checkout transparente do Mercado Pago: o cartão fica na sua página, não no seu banco

Checkout transparente do Mercado Pago: o cartão fica na sua página, não no seu banco

Integre o Checkout Transparente com token gerado no navegador, pagamento criado no servidor com chave de idempotência e confirmação por notificação conferida, sem gravar nem logar o cartão.

A loja migrou do redirecionamento para o Checkout Transparente, e na reunião alguém sugeriu: "agora que o cartão passa pela nossa página, vamos guardar o número para a pessoa não digitar de novo na próxima compra". Na semana seguinte a tabela pedidos tem uma coluna numero_cartao e o log da API tem o corpo inteiro das requisições de pagamento.

A resposta curta: "transparente" descreve a experiência, não a custódia. O formulário aparece na sua página, mas os dados do cartão são capturados pelos componentes do Mercado Pago e trocados por um token no navegador. O seu servidor recebe esse token, cria o pagamento e guarda só o identificador da cobrança e o status. Número, validade e código de segurança nunca chegam à sua tabela nem ao seu log. Para recompra, use o recurso de cartões salvos do próprio provedor.

Por que guardar o cartão muda tudo

Quem armazena, processa ou transmite número de cartão entra no escopo do PCI DSS, o padrão de segurança das bandeiras. Isso significa requisitos de criptografia, segmentação de rede, controle de acesso, auditoria e testes periódicos que uma loja pequena não tem estrutura para cumprir. O código de segurança (CVV) é ainda mais restrito: não pode ser guardado depois da autorização nem por quem tem toda a certificação.

O desenho do Checkout Transparente existe justamente para que você tenha a experiência integrada sem esse peso. Os campos do cartão são renderizados pelos componentes do Mercado Pago (o Brick de pagamento ou os campos seguros do SDK JavaScript), e o que sai deles é um token de uso único. Se você captura o número num <input> seu e manda para o seu servidor, desfez a proteção que a integração oferecia. O conceito geral, válido para qualquer provedor, está em o número do cartão não precisa passar pelo seu servidor. Aqui o foco é o fluxo concreto do Mercado Pago.

O fluxo completo, do formulário à confirmação

  1. O navegador carrega o componente de cartão com a public key do ambiente correto (teste ou produção).
  2. A pessoa digita o cartão nos campos do provedor, e o componente devolve um token, o meio de pagamento, o emissor e as parcelas escolhidas.
  3. O navegador envia ao seu servidor só o token e esses metadados, junto com o id do pedido. Nunca o valor: o valor sai do pedido no banco.
  4. O servidor cria o pagamento com o access token (que é segredo e fica só no servidor) e uma chave de idempotência ligada ao pedido.
  5. O servidor grava o id do pagamento e o status devolvido, e responde ao navegador com uma mensagem adequada a cada status.
  6. A notificação do Mercado Pago confirma mudanças de status depois (aprovação tardia, estorno, contestação). O servidor confere a notificação e consulta o pagamento na API antes de atualizar o pedido.

Criando o pagamento no servidor (Node.js)

O exemplo usa o SDK oficial para Node, numa loja em que o pedido 8812 custa R$ 129,90 e está guardado em centavos, como recomenda o post sobre dinheiro em centavos:

import express from 'express';
import { MercadoPagoConfig, Payment } from 'mercadopago';
import { buscarPedido, salvarPagamento } from './pedidos.js';

const client = new MercadoPagoConfig({ accessToken: process.env.MP_ACCESS_TOKEN });
const pagamentos = new Payment(client);
const app = express();
app.use(express.json());

app.post('/api/pedidos/:id/pagar', async (req, res) => {
  const { token, paymentMethodId, issuerId, installments, email } = req.body;

  try {
    const pedido = await buscarPedido(req.params.id);
    if (!pedido || pedido.status !== 'aguardando_pagamento') {
      return res.status(409).json({ erro: 'Pedido indisponível para pagamento.' });
    }

    const resultado = await pagamentos.create({
      body: {
        transaction_amount: pedido.totalCentavos / 100,
        token,
        description: 'Pedido ' + pedido.id,
        installments: Number(installments),
        payment_method_id: paymentMethodId,
        issuer_id: issuerId,
        payer: { email },
        external_reference: String(pedido.id)
      },
      requestOptions: { idempotencyKey: 'pedido-' + pedido.id + '-tentativa-' + pedido.tentativa }
    });

    await salvarPagamento(pedido.id, {
      mpPaymentId: String(resultado.id),
      status: resultado.status,
      statusDetail: resultado.status_detail
    });

    return res.json({ status: resultado.status, detalhe: resultado.status_detail });
  } catch (err) {
    console.error('Falha ao criar pagamento', { pedido: req.params.id, mensagem: err.message });
    return res.status(502).json({ erro: 'Não foi possível processar o pagamento agora.' });
  }
});

Repare no que não está aqui. O valor não vem do navegador, então ninguém paga R$ 1,00 por um pedido de R$ 129,90 editando a requisição. O log de erro registra o id do pedido e a mensagem, não o req.body, que tem e-mail e token. E a chave de idempotência amarra a cobrança ao pedido e à tentativa: se a tela piscar e o botão for clicado duas vezes, a segunda chamada não gera uma segunda cobrança. O motivo de incluir o número da tentativa, e não só o pedido, está em idempotência no pagamento: depois de uma recusa, a pessoa precisa conseguir tentar com outro cartão.

O que a tabela de pedidos guarda

  • O id do pagamento no Mercado Pago, para consulta, estorno e suporte.
  • O status e o detalhe do status, para mostrar a mensagem certa e conciliar.
  • Opcionalmente, a bandeira e os quatro últimos dígitos, se o provedor devolver, para a pessoa reconhecer o cartão no histórico.
  • Nada de número completo, validade ou código de segurança.

Confirmando pela notificação, não pelo navegador

A resposta que o navegador recebe é útil para a tela, mas não é a verdade final: um pagamento pode ficar pendente em análise e ser aprovado ou recusado minutos depois, e a pessoa pode fechar a aba antes. A fonte de verdade é a notificação que o Mercado Pago envia ao seu servidor. O provedor assina as notificações com um segredo configurado no painel, usando um cabeçalho x-signature com um timestamp e um HMAC SHA-256. Confira o formato exato do texto assinado na documentação de notificações da sua integração; no formato documentado atualmente, ele combina o id do recurso, o x-request-id e o timestamp:

import crypto from 'node:crypto';

function assinaturaValida(req) {
  const cabecalho = req.get('x-signature') || '';
  const requestId = req.get('x-request-id') || '';
  const dataId = String(req.query['data.id'] || '').toLowerCase();

  const partes = Object.fromEntries(
    cabecalho.split(',').map((p) => p.trim().split('='))
  );
  if (!partes.ts || !partes.v1) return false;

  const manifesto = 'id:' + dataId + ';request-id:' + requestId + ';ts:' + partes.ts + ';';
  const esperado = crypto
    .createHmac('sha256', process.env.MP_WEBHOOK_SECRET)
    .update(manifesto)
    .digest('hex');

  const a = Buffer.from(esperado);
  const b = Buffer.from(partes.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post('/webhooks/mercadopago', async (req, res) => {
  if (!assinaturaValida(req)) return res.sendStatus(401);
  if (req.query.type !== 'payment') return res.sendStatus(200);

  try {
    const pagamento = await pagamentos.get({ id: req.query['data.id'] });
    await salvarPagamento(Number(pagamento.external_reference), {
      mpPaymentId: String(pagamento.id),
      status: pagamento.status,
      statusDetail: pagamento.status_detail
    });
    return res.sendStatus(200);
  } catch (err) {
    console.error('Falha ao processar notificação', { id: req.query['data.id'], mensagem: err.message });
    return res.sendStatus(500);
  }
});

Mesmo com a assinatura válida, o código consulta o pagamento na API antes de atualizar o pedido, e só responde 200 depois de gravar; se algo falhar, o 500 faz o provedor tentar de novo mais tarde. Assim o status gravado é sempre o que o provedor diz agora, e notificações repetidas ou fora de ordem não estragam nada, desde que salvarPagamento seja idempotente. Os princípios de assinatura de webhook valem para qualquer provedor e estão em webhook com segredo.

Erros comuns

  • Capturar o cartão num campo próprio e enviar ao servidor. Você passa a transmitir número de cartão e herda o escopo do PCI DSS que a integração evitava.
  • Logar o corpo da requisição de pagamento. E-mail, CPF e token vão parar no agregador de log, acessível a muito mais gente do que o banco.
  • Aceitar o valor enviado pelo navegador. Qualquer pessoa com DevTools paga o que quiser.
  • Usar o access token de produção no front ou no repositório. Quem obtém essa chave consegue criar cobranças e consultar pagamentos em nome da loja.
  • Marcar o pedido como pago pelo retorno do navegador. Pagamentos pendentes viram "pagos" e recusas posteriores não são refletidas.
  • Testar só o cartão aprovado. Recusa por saldo, dados inválidos e análise pendente são justamente os casos em que a mensagem errada gera chamado no suporte.

Checklist

  • Nenhuma coluna do banco guarda número completo, validade ou código de segurança.
  • Os campos do cartão são do componente do provedor, não <input> seus.
  • O valor cobrado vem do pedido no servidor.
  • Toda criação de pagamento tem chave de idempotência ligada ao pedido e à tentativa.
  • O access token está só em variável de ambiente do servidor, com chaves diferentes para teste e produção.
  • A notificação tem a assinatura conferida, e o status é consultado na API antes de gravar.
  • Os logs registram ids e status, não o payload.
  • Você testou aprovação, recusa e pagamento pendente no ambiente de teste.

Rode agora um SELECT nos nomes de coluna das suas tabelas de pedido e pagamento e procure "cartao", "card" e "cvv": alguma delas guarda algo que o provedor deveria estar guardando por você?

Avalie este artigo

Referência: https://www.mercadopago.com.br/developers/pt/docs/checkout-api/landing