Pular para o conteúdo
ODSign

Validar a assinatura dos webhooks

Confira o cabeçalho X-Signature (HMAC-SHA256 do corpo com o segredo do webhook) com exemplos em PHP e Node.js.

2 min de leitura Avançado Atualizado em 11 de novembro de 2026

Nesta página

Qualquer pessoa que descubra a URL do seu webhook pode enviar chamadas falsas para ela. Por isso, cada entrega do ODSign vem assinada: antes de confiar no conteúdo, o seu sistema deve recalcular a assinatura e compará-la com a que chegou no cabeçalho X-Signature.

Como a assinatura é feita

  • Algoritmo: HMAC-SHA256.
  • Chave: o segredo completo do webhook, exatamente como foi exibido ao criar, incluindo o prefixo whsec_.
  • Mensagem: o corpo da requisição, byte a byte, exatamente como chegou.
  • Formato: hexadecimal em letras minúsculas (64 caracteres), sem prefixo.
  • Cabeçalho: X-Signature. O nome do evento vem em X-Event.

O teste do botão Testar (evento test.ping) é assinado da mesma forma, então serve para conferir a sua implementação. O corpo do teste é curto, por exemplo {"event":"test.ping","sent_at":"2026-11-11T13:00:00+00:00"}, sem os dados de envelope.

Atenção: Calcule a assinatura sobre o corpo bruto, antes de qualquer conversão. Se você decodificar o JSON e codificar de novo, espaços, acentos e barras podem mudar e a assinatura não vai bater.

Passo a passo

  1. Ao criar o webhook em Configurações › Webhooks, copie o segredo do aviso Copie o segredo do webhook agora (veja Webhooks de saída).
  2. Guarde o segredo no seu servidor, em variável de ambiente ou cofre de segredos (nos exemplos, ODSIGN_WEBHOOK_SECRET).
  3. No endpoint que recebe as chamadas, leia o corpo bruto e o cabeçalho X-Signature.
  4. Calcule o HMAC-SHA256 do corpo com o segredo e compare com o cabeçalho usando uma comparação em tempo constante.
  5. Se não bater, responda 401 e descarte a chamada. Se bater, responda 200 e processe o evento.
  6. Clique em Testar no cartão do webhook e confira se aparece "Teste entregue (HTTP 200).".

Exemplo em PHP

<?php
// webhook-odsign.php
$secret = getenv('ODSIGN_WEBHOOK_SECRET'); // "whsec_..." completo
$body = file_get_contents('php://input');   // corpo bruto, sem decodificar
$received = $_SERVER['HTTP_X_SIGNATURE'] ?? '';

$expected = hash_hmac('sha256', $body, $secret);

if (! hash_equals($expected, $received)) {
    http_response_code(401);
    exit('assinatura inválida');
}

$payload = json_decode($body, true);
$event = $_SERVER['HTTP_X_EVENT'] ?? $payload['event'];

if ($event === 'envelope.completed') {
    $envelopeId = $payload['envelope']['id'];
    // Coloque o processamento pesado (por exemplo, baixar o PDF pela API) em uma fila.
}

http_response_code(200);
echo 'ok';

No Laravel, a mesma conferência fica assim:

use Illuminate\Http\Request;

Route::post('/webhooks/odsign', function (Request $request) {
    $expected = hash_hmac('sha256', $request->getContent(), config('services.odsign.webhook_secret'));

    abort_unless(hash_equals($expected, (string) $request->header('X-Signature')), 401);

    $payload = $request->json()->all();
    // ...

    return response()->noContent(); // 204, também é 2xx
});

Lembre-se de tirar essa rota da verificação de CSRF do Laravel.

Exemplo em Node.js (Express)

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

const app = express();
const SECRET = process.env.ODSIGN_WEBHOOK_SECRET; // "whsec_..." completo

// express.raw mantém o corpo como Buffer, sem converter o JSON.
app.post('/webhooks/odsign', express.raw({ type: 'application/json' }), (req, res) => {
  const received = Buffer.from(req.get('X-Signature') || '', 'utf8');
  const expected = Buffer.from(
    crypto.createHmac('sha256', SECRET).update(req.body).digest('hex'),
    'utf8',
  );

  if (received.length !== expected.length || !crypto.timingSafeEqual(received, expected)) {
    return res.status(401).send('assinatura inválida');
  }

  const payload = JSON.parse(req.body.toString('utf8'));
  const event = req.get('X-Event') || payload.event;

  if (event === 'envelope.completed') {
    // Agende o download do PDF assinado pela API.
  }

  res.sendStatus(200);
});

app.listen(3000);

Use express.raw só nesta rota. Se a aplicação já usa express.json() antes dela, o corpo chega convertido e a assinatura não confere.

Boas práticas

  • Compare em tempo constante (hash_equals no PHP, crypto.timingSafeEqual no Node.js), nunca com == ou ===.
  • Responda rápido. Devolva 2xx assim que validar e processe o evento em segundo plano. Respostas lentas contam como falha e geram novas tentativas.
  • Trate repetições. A mesma entrega pode chegar mais de uma vez. Use o event, o envelope.id e o recipient.id para ignorar o que já foi processado.
  • Descarte eventos muito antigos, se quiser uma proteção a mais contra reenvio malicioso: o campo sent_at do corpo faz parte do conteúdo assinado.
  • Não confie só no webhook para dados finais. Para decidir algo importante, consulte o envelope pela API (Referência dos endpoints) com o id recebido.
  • Proteja o segredo como uma senha. Se ele vazar, exclua o webhook e crie outro, que terá um novo segredo.

Problemas comuns

  • A assinatura nunca confere. Quase sempre o corpo foi alterado antes do cálculo (JSON decodificado e codificado de novo, espaços removidos, conversão de codificação). Calcule sobre o corpo bruto.
  • Confere no teste, mas não nos eventos reais. Os eventos reais têm acentos e barras sem escape, como em "Contrato de prestação". Confira se o seu framework não está convertendo o corpo antes da validação.
  • Uso o segredo sem o prefixo whsec_. O prefixo faz parte da chave. Use o valor completo.
  • Perdi o segredo. Ele não pode ser exibido de novo. Exclua o webhook e crie outro em Configurações › Webhooks.

Este artigo foi útil?

Artigos relacionados

Não encontrou o que procurava?

Nossa equipe ajuda você. Conte o que aconteceu e responderemos por e-mail.

Fale com o suporte