Skip to content

Validar a assinatura ​

Sua URL de webhook é pública — qualquer pessoa pode fazer POST nela. A assinatura é o que prova que a requisição veio do Edusati e que o corpo não foi alterado no caminho.

Valide sempre, antes de olhar o conteúdo.

Os headers ​

HeaderConteúdo
X-Edusati-Signaturet=<unix>,v1=<hmac-sha256 em hex>
X-Edusati-TimestampO mesmo t, solto (conveniência)
X-Edusati-EventTipo do evento — permite rotear antes de parsear o corpo
X-Edusati-DeliveryId desta tentativa de entrega (muda a cada retry)

Exemplo:

X-Edusati-Signature: t=1785849912,v1=8a1c4f3e9b2d7c60a5e8f1b4d3c2a190f7e6d5c4b3a29180e7f6d5c4b3a29180

O algoritmo ​

  1. Extraia t e v1 do header
  2. Rejeite se |agora - t| > 300 segundos (janela anti-replay de 5 minutos)
  3. Calcule HMAC-SHA256("<t>.<corpo cru>", segredo) em hexadecimal
  4. Compare com v1 usando comparação de tempo constante

Use o corpo BRUTO, não o JSON reserializado

A assinatura cobre exatamente os bytes que enviamos. Se você deixar o framework parsear o JSON e depois fizer JSON.stringify de volta, a ordem das chaves e os espaços mudam — e a assinatura falha, mesmo estando tudo certo.

Configure seu framework para preservar o corpo cru (express.raw, request.body do Flask, file_get_contents('php://input')).

Código pronto ​

js
const crypto = require('node:crypto');
const express = require('express');

const app = express();
const SEGREDO = process.env.EDUSATI_WEBHOOK_SECRET;
const TOLERANCIA = 300; // segundos

function assinaturaValida(cabecalho, corpoBruto) {
  if (!cabecalho) return false;

  let t = null;
  let v1 = null;
  for (const parte of cabecalho.split(',')) {
    const [chave, valor] = parte.trim().split('=');
    if (chave === 't') t = Number(valor);
    if (chave === 'v1') v1 = valor;
  }
  if (!t || !Number.isFinite(t) || !v1) return false;

  // Tolerância nos dois sentidos: relógio adiantado também é diferença legítima
  const agora = Math.floor(Date.now() / 1000);
  if (Math.abs(agora - t) > TOLERANCIA) return false;

  const esperado = crypto
    .createHmac('sha256', SEGREDO)
    .update(`${t}.${corpoBruto}`)
    .digest('hex');

  const a = Buffer.from(esperado, 'utf8');
  const b = Buffer.from(v1, 'utf8');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// express.raw preserva os bytes originais — express.json() os destruiria
app.post('/edusati', express.raw({ type: 'application/json' }), (req, res) => {
  const corpoBruto = req.body.toString('utf8');

  if (!assinaturaValida(req.get('x-edusati-signature'), corpoBruto)) {
    return res.sendStatus(401);
  }

  const evento = JSON.parse(corpoBruto);
  processarDepois(evento);   // fora do ciclo da requisição
  res.sendStatus(200);
});
python
import hashlib, hmac, os, time
from flask import Flask, request

app = Flask(__name__)
SEGREDO = os.environ["EDUSATI_WEBHOOK_SECRET"].encode()
TOLERANCIA = 300


def assinatura_valida(cabecalho: str | None, corpo_bruto: bytes) -> bool:
    if not cabecalho:
        return False

    t = v1 = None
    for parte in cabecalho.split(","):
        chave, _, valor = parte.strip().partition("=")
        if chave == "t":
            t = valor
        elif chave == "v1":
            v1 = valor
    if not t or not v1 or not t.isdigit():
        return False

    if abs(int(time.time()) - int(t)) > TOLERANCIA:
        return False

    esperado = hmac.new(
        SEGREDO, f"{t}.".encode() + corpo_bruto, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(esperado, v1)


@app.post("/edusati")
def edusati():
    corpo_bruto = request.get_data()          # bytes originais, não request.json
    if not assinatura_valida(request.headers.get("X-Edusati-Signature"), corpo_bruto):
        return "", 401

    evento = request.get_json()
    processar_depois(evento)
    return "", 200
php
<?php
$segredo    = getenv('EDUSATI_WEBHOOK_SECRET');
$tolerancia = 300;

$corpoBruto = file_get_contents('php://input');
$cabecalho  = $_SERVER['HTTP_X_EDUSATI_SIGNATURE'] ?? '';

$t = $v1 = null;
foreach (explode(',', $cabecalho) as $parte) {
    [$chave, $valor] = array_pad(explode('=', trim($parte), 2), 2, null);
    if ($chave === 't')  { $t  = $valor; }
    if ($chave === 'v1') { $v1 = $valor; }
}

if (!$t || !$v1 || !ctype_digit($t) || abs(time() - (int) $t) > $tolerancia) {
    http_response_code(401);
    exit;
}

$esperado = hash_hmac('sha256', $t . '.' . $corpoBruto, $segredo);

if (!hash_equals($esperado, $v1)) {
    http_response_code(401);
    exit;
}

$evento = json_decode($corpoBruto, true);
processarDepois($evento);
http_response_code(200);

Detalhes que importam ​

Comparação de tempo constante. hash_equals, hmac.compare_digest e timingSafeEqual existem porque == retorna assim que encontra o primeiro byte diferente. Essa diferença de microssegundos é suficiente para descobrir a assinatura correta byte a byte.

Tolerância nos dois sentidos. Use o valor absoluto da diferença. Um relógio adiantado no seu servidor é motivo tão legítimo de diferença quanto um atrasado — só o intervalo grande denuncia replay.

O v1 é o esquema, não a versão do payload. Ele indica o algoritmo de assinatura. Se um dia acrescentarmos v2, os dois virão no mesmo header durante a transição, e você continua procurando o que conhece.

O timestamp faz parte do que é assinado. É isso que impede alguém de capturar um POST válido e reenviá-lo depois: o corpo continuaria batendo, mas o t estaria fora da janela.

Rotacionar o segredo ​

Em Integrações › Webhooks › Rotacionar segredo. O segredo novo passa a valer na próxima tentativa de qualquer entrega, inclusive nas que já estavam na fila.

Não há período de validade dupla. Na prática, faça na ordem:

  1. Rotacione no painel e copie o segredo novo
  2. Atualize a variável de ambiente do seu servidor e reinicie
  3. Dispare um Testar para confirmar

Entregas em voo entre os passos 1 e 2 falham a validação e entram em retry — o backoff cobre a janela, mas evite fazer isso em horário de pico.

Depurando ​

"A assinatura nunca bate" — em 9 de 10 casos é o corpo reserializado. Confirme que você está usando os bytes crus. Um teste rápido: registre corpoBruto.length e compare com o content-length do header.

"Bate localmente e falha em produção" — proxy ou CDN reescrevendo o corpo. Certifique-se de que nada entre a internet e o seu handler normaliza JSON.

"Falha só às vezes" — provavelmente relógio dessincronizado. Verifique o NTP do servidor; diferenças acima de 5 minutos derrubam a validação.

Documentação da API pública do Edusati