Programação Por Equipe DevBras

Capa do artigo: Webhook Mercado Pago, Stripe e Asaas: validar no PHP

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.


Avalie este artigo

Referência: https://github.com/marketplace/actions/workflow-webhook-action