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
- O navegador carrega o componente de cartão com a public key do ambiente correto (teste ou produção).
- 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.
- 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.
- 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.
- O servidor grava o id do pagamento e o status devolvido, e responde ao navegador com uma mensagem adequada a cada status.
- 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ê?
Referência: https://www.mercadopago.com.br/developers/pt/docs/checkout-api/landing