Validação

Guia de Validação de Webhooks Mercos (assinatura HMAC-SHA256)

O que é este guia:

Este documento explica, de forma técnica e direta, como validar a assinatura X-Hub-Signature-256 enviada em todo webhook disparado pela Mercos. Ele cobre o formato exato do input do HMAC, como tratar a chave secreta, exemplos de implementação prontos em três linguagens e uma lista das falhas mais comuns que fazem a validação quebrar. Use este guia se você está implementando ou depurando o recebimento de webhooks da Mercos no seu próprio backend.

Resumo:

  1. Qual é o input do HMAC-SHA256?
  2. Como o secret deve ser utilizado?
  3. Exemplos de código (Node.js, Python, PHP)
  4. Resumo das armadilhas mais comuns

Qual é o input do HMAC-SHA256?

O input do HMAC é o body raw exato da requisição HTTP, sem nenhuma transformação adicional. Os bytes que chegam no corpo da requisição são exatamente os mesmos bytes que entram no cálculo da assinatura.

Não há canonicalização, reordenação de chaves, concatenação de campos extras ou normalização de espaços. O algoritmo é simples:

assinatura = HMAC-SHA256(chave_decodificada, body_raw_bytes)

Detalhes do formato do body

AspectoComportamento
EncodingUTF-8
JSONCompacto, sem espaços nos separadores (ex: {"a":1,"b":2}, não {"a": 1, "b": 2})
Caracteres não-ASCIIPreservados literalmente (ex: "João"), sem escape para \uXXXX
Content-Typeapplication/json; charset=utf-8
Header da assinaturaX-Hub-Signature-256: sha256=<digest_hex>

Recomendação prática

Não use JSON.parse seguido de JSON.stringify antes de validar. Re-serializar o body introduz espaços ou reordena chaves, mudando os bytes — e a assinatura quebra. Capture o body raw direto do framework (req.rawBody, request.get_data(), file_get_contents('php://input'), etc.).


2. Como o secret deve ser utilizado?

A chave gerada pelo botão "Gerar nova chave" no painel da Mercos é uma string hexadecimal com 64 caracteres. Ela representa 32 bytes binários, e é esse valor binário que deve ser usado como key do HMAC.

Antes de calcular a assinatura, a chave precisa ser decodificada de hexadecimal para bytes. Esse é o erro mais comum em integrações: usar os 64 caracteres literais como key faz com que a assinatura nunca bata, mesmo que todo o resto esteja correto.

Como decodificar em cada linguagem

LinguagemFunção de decodificação
Pythonbytes.fromhex(chave)
Node.jsBuffer.from(chave, 'hex')
PHPhex2bin($chave)
Gohex.DecodeString(chave)
Ruby[chave].pack('H*')
JavaHexFormat.of().parseHex(chave)
C#Convert.FromHexString(chave)
⚠️

Atenção

Se você usar a string "4d14a3de44a120a9..." diretamente como key do HMAC, sem o passo de decodificação, a assinatura nunca vai bater — independente de quão correto esteja o restante do código.


3. Exemplos de código

Os exemplos abaixo foram testados e validam a assinatura corretamente. Todos eles fazem três coisas essenciais:

  • (a) capturam o body raw antes de qualquer parsing;
  • (b) decodificam a chave de hex para bytes;
  • (c) usam comparação resistente a timing attack para validar o digest.

Node.js / Express

const crypto = require('crypto');
const express = require('express');
const app = express();

const CHAVE_HEX = process.env.MERCOS_WEBHOOK_SECRET; // 64 chars hex

// Capturar body raw - NAO usar express.json() antes da validacao
app.use(express.raw({ type: 'application/json' }));

app.post('/webhook', (req, res) => {
  const assinaturaRecebida = req.headers['x-hub-signature-256'] || '';
  const digestRecebido = assinaturaRecebida.replace(/^sha256=/, '');

  const chaveBytes = Buffer.from(CHAVE_HEX, 'hex');
  const digestEsperado = crypto
    .createHmac('sha256', chaveBytes)
    .update(req.body) // req.body eh Buffer com o raw body
    .digest('hex');

  const valido = digestRecebido.length === digestEsperado.length &&
    crypto.timingSafeEqual(
      Buffer.from(digestRecebido, 'hex'),
      Buffer.from(digestEsperado, 'hex'),
    );

  if (!valido) return res.status(401).send('Assinatura invalida');

  const payload = JSON.parse(req.body.toString('utf-8'));
  // ... processar payload
  res.status(200).send('ok');
});

Python / Flask

import hmac
import hashlib
import os
from flask import Flask, request, abort

app = Flask(__name__)
CHAVE_HEX = os.environ['MERCOS_WEBHOOK_SECRET']  # 64 chars hex

@app.post('/webhook')
def webhook():
    body_raw = request.get_data()  # bytes, sem parsing
    assinatura_recebida = request.headers.get('X-Hub-Signature-256', '')
    digest_recebido = assinatura_recebida.removeprefix('sha256=')

    digest_esperado = hmac.new(
        bytes.fromhex(CHAVE_HEX),
        body_raw,
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(digest_recebido, digest_esperado):
        abort(401)

    payload = request.get_json()
    # ... processar payload
    return 'ok', 200

PHP

<?php
$chave_hex = getenv('MERCOS_WEBHOOK_SECRET'); // 64 chars hex
$body_raw = file_get_contents('php://input');

$assinatura_recebida = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
$digest_recebido = str_replace('sha256=', '', $assinatura_recebida);

$digest_esperado = hash_hmac(
    'sha256',
    $body_raw,
    hex2bin($chave_hex),
);

if (!hash_equals($digest_recebido, $digest_esperado)) {
    http_response_code(401);
    exit('Assinatura invalida');
}

$payload = json_decode($body_raw, true);
// ... processar payload
http_response_code(200);

4. Resumo das armadilhas mais comuns

  • Body re-serializado. Usar JSON.stringify(req.body) ou equivalente ao invés do raw body. Re-serializar muda os bytes (espaços, ordem das chaves, escape de unicode) e a assinatura quebra. Sempre use o body raw exato.

  • Chave hex usada como string literal. Passar os 64 caracteres da chave direto como key do HMAC, sem hex-decode. A chave precisa virar 32 bytes binários antes de entrar no HMAC.

  • Comparação não-timing-safe. Usar ==, === ou strcmp para comparar digests. Não quebra a validação em condições normais, mas abre brecha para timing attack. Use sempre crypto.timingSafeEqual, hmac.compare_digest ou hash_equals.

  • Middleware de parsing antes da validação. Em Express, registrar express.json() antes da rota do webhook faz o body raw ser consumido. Use express.raw() no endpoint específico ou guarde o raw em verify.

  • Trim ou normalização do header. Não aplicar .trim() nem mudar caixa do digest recebido. O digest hexadecimal é case-sensitive em algumas comparações binárias.


Suporte

Em caso de dúvida adicional ou dificuldade para validar a assinatura mesmo seguindo este guia, entre em contato com o suporte da Mercos informando:

  1. Endpoint configurado
  2. ID do evento que falhou na validação
  3. Os primeiros e últimos 8 bytes do body raw recebido (em hex)
  4. O header X-Hub-Signature-256 recebido

Essas informações permitem reproduzir o cálculo do lado da Mercos.