Webhook Mercado Pago, Stripe e Asaas: validar no PHP
Webhook do Mercado Pago, Stripe e Asaas: como cada um assina a chamada, o código PHP que confere os três e um recebedor que responde rápido sem duplicar.
Todo gateway de pagamento manda webhook, mas cada um prova a origem de um jeito. O Mercado Pago assina um "manifesto" montado com o id do recurso, o Stripe assina o corpo cru da requisição e o Asaas envia um token fixo num cabeçalho. Copiar o código de um para o outro quebra a validação, ou pior, deixa passar chamada forjada. Este guia mostra a conferência dos três em PHP puro, testada com PHP 8.4, e um recebedor que responde rápido sem processar o mesmo pagamento duas vezes.
Se você ainda não protege a rota de webhook, comece pelo guia sobre webhook sem assinatura, que explica por que URL secreta não basta e como testar a rota com um POST forjado. Aqui o foco é a diferença entre os provedores.
O que cada provedor envia
Cabeçalho O que é assinado Repetição
Mercado Pago x-signature: ts,v1 id:[data.id];request-id:[x-req]; a cada 15 min
x-request-id ts:[ts]; (HMAC-SHA256) se não houver 200/201
Stripe Stripe-Signature: t + "." + corpo cru por até 3 dias
t=...,v1=... (HMAC-SHA256, segredo whsec_)
Asaas asaas-access-token nada: é um token fixo que entrega "pelo menos
você define no painel uma vez"
Os detalhes estão na documentação oficial de cada um: webhooks do Mercado Pago, verificação de assinatura do Stripe e eventos do Asaas.
Mercado Pago: o manifesto
O Mercado Pago não assina o corpo. Ele monta uma string com o data.id que vem na URL da notificação (em minúsculas, se for alfanumérico), o cabeçalho x-request-id e o ts do próprio x-signature. Se algum valor não vier, o par correspondente sai do manifesto.
function mpValido(string $xSignature, string $xRequestId, string $dataId,
string $segredo, int $toleranciaSeg = 300): bool {
$partes = [];
foreach (explode(',', $xSignature) as $par) {
[$k, $v] = array_map('trim', explode('=', $par, 2) + [1 => '']);
$partes[$k] = $v;
}
if (empty($partes['ts']) || empty($partes['v1'])) return false;
$manifesto = '';
if ($dataId !== '') $manifesto .= 'id:' . strtolower($dataId) . ';';
if ($xRequestId !== '') $manifesto .= 'request-id:' . $xRequestId . ';';
$manifesto .= 'ts:' . $partes['ts'] . ';';
$esperado = hash_hmac('sha256', $manifesto, $segredo);
if (!hash_equals($esperado, $partes['v1'])) return false;
// ts pode vir em milissegundos
$ms = strlen($partes['ts']) > 10;
$agora = $ms ? (int) (microtime(true) * 1000) : time();
$limite = $ms ? $toleranciaSeg * 1000 : $toleranciaSeg;
return abs($agora - (int) $partes['ts']) <= $limite;
}
Uma pegadinha específica do PHP: a URL chega como ?data.id=123456, mas o PHP troca o ponto por sublinhado nos nomes de parâmetro. O valor fica em $_GET['data_id'], não em $_GET['data.id']. Quem procura pelo nome com ponto recebe vazio, monta o manifesto sem o id e conclui que a assinatura está sempre errada.
A documentação também avisa que notificações de QR Code não trazem essa assinatura. E os SDKs oficiais recentes já trazem um validador pronto; se você usa o SDK, prefira ele a manter código próprio.
Stripe: o corpo cru e intocado
function stripeValido(string $corpoCru, string $cabecalho,
string $segredo, int $tolerancia = 300): bool {
$t = null; $assinaturas = [];
foreach (explode(',', $cabecalho) as $par) {
[$k, $v] = explode('=', trim($par), 2) + [1 => ''];
if ($k === 't') $t = (int) $v;
if ($k === 'v1') $assinaturas[] = $v;
}
if ($t === null || !$assinaturas) return false;
if (abs(time() - $t) > $tolerancia) return false;
$esperado = hash_hmac('sha256', $t . '.' . $corpoCru, $segredo);
foreach ($assinaturas as $a) {
if (hash_equals($esperado, $a)) return true;
}
return false;
}
$ok = stripeValido(file_get_contents('php://input'),
$_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '',
getenv('STRIPE_WEBHOOK_SECRET'));
Dois detalhes importam. O corpo precisa ser exatamente o que chegou: ler de php://input funciona; decodificar o JSON e codificar de novo muda espaços e ordem, e a assinatura deixa de bater. No teste, um único espaço a mais no fim do corpo já fez a função retornar falso. E o cabeçalho pode trazer mais de um v1: quando você troca o segredo no painel, o Stripe pode manter o antigo ativo por até 24 horas e assina com os dois. Por isso o código aceita qualquer um que bata.
Asaas: token, não assinatura
function asaasValido(?string $token, string $esperado): bool {
return $token !== null && hash_equals($esperado, $token);
}
$ok = asaasValido($_SERVER['HTTP_ASAAS_ACCESS_TOKEN'] ?? null,
getenv('ASAAS_WEBHOOK_TOKEN'));
O Asaas não calcula HMAC: ele envia de volta o token que você cadastrou no webhook, de 32 a 255 caracteres, sem espaços, e que não deve ser a chave de API. Isso prova que quem chamou conhece o token, mas não protege o conteúdo do corpo. Por isso vale também conferir se a chamada vem das faixas de IP que o Asaas publica e, para decisões importantes, consultar a cobrança na API antes de liberar algo. Use hash_equals mesmo para token fixo: a comparação com === pode vazar informação pelo tempo de resposta.
Os três segredos ficam em variáveis de ambiente, nunca no código nem na imagem do container. O post sobre segredo fora da imagem mostra como.
Um recebedor que responde rápido
Os provedores não esperam muito: o Mercado Pago considera falha se não receber 200 ou 201 em até 22 segundos, e o Stripe pede um 2xx rápido, antes de qualquer processamento pesado. O desenho que funciona para os três é validar, anotar, responder e processar depois. Para o Mercado Pago, que trata a notificação como um aviso de mudança, o recebedor só registra qual pagamento consultar:
<?php
require __DIR__ . '/verifica.php';
$assinatura = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$requestId = $_SERVER['HTTP_X_REQUEST_ID'] ?? '';
$dataId = $_GET['data_id'] ?? '';
if (!mpValido($assinatura, $requestId, $dataId, getenv('MP_WEBHOOK_SECRET'))) {
http_response_code(401);
exit;
}
$db = new PDO('sqlite:' . __DIR__ . '/fila.db');
$db->exec('CREATE TABLE IF NOT EXISTS a_consultar (
origem TEXT, recurso TEXT, recebido_em TEXT, PRIMARY KEY (origem, recurso))');
$db->prepare('INSERT OR IGNORE INTO a_consultar VALUES (?, ?, ?)')
->execute(['mercadopago', 'payment:' . $dataId, date('c')]);
http_response_code(200);
Testando com php -S e uma assinatura gerada com openssl dgst -sha256 -hmac, a mesma notificação enviada duas vezes recebeu 200 nas duas e gerou uma única linha na fila. A mesma assinatura com outro data.id recebeu 401:
$ curl ... "localhost:8099/mp.php?data.id=123456&type=payment" # 1ª vez 200 $ curl ... "localhost:8099/mp.php?data.id=123456&type=payment" # repetida 200 $ curl ... "localhost:8099/mp.php?data.id=999&type=payment" # id trocado 401
Um worker lê a fila, busca o status atual do pagamento na API e só então muda o pedido, numa transição que não acontece duas vezes (de "aguardando" para "pago", nunca de "pago" para "pago"). No Stripe e no Asaas, cada evento tem um id próprio, que serve como chave única para ignorar repetições. O post sobre idempotência no pagamento detalha essa parte.
Quando a validação falha em produção
- Mercado Pago sempre inválido: confira se está lendo
data_id, se o id está em minúsculas e se o segredo é o da aplicação certa (teste e produção têm segredos diferentes). - Stripe sempre inválido: algum middleware leu e transformou o corpo antes, ou o segredo é de outro endpoint; cada endpoint tem o seu
whsec_. - Stripe falha só às vezes: relógio do servidor atrasado. Sincronize com NTP antes de aumentar a tolerância.
- Provedor reenviando sem parar: a rota redireciona (de HTTP para HTTPS, ou para a URL com barra no fim). O Stripe trata redirecionamento como falha; cadastre a URL final.
- Asaas parou de enviar: depois de 15 falhas seguidas, a fila do webhook pode ser pausada. Corrija a rota e reative no painel.
Referência: https://github.com/marketplace/actions/workflow-webhook-action