Tema
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
| Header | Conteúdo |
|---|---|
X-Edusati-Signature | t=<unix>,v1=<hmac-sha256 em hex> |
X-Edusati-Timestamp | O mesmo t, solto (conveniência) |
X-Edusati-Event | Tipo do evento — permite rotear antes de parsear o corpo |
X-Edusati-Delivery | Id desta tentativa de entrega (muda a cada retry) |
Exemplo:
X-Edusati-Signature: t=1785849912,v1=8a1c4f3e9b2d7c60a5e8f1b4d3c2a190f7e6d5c4b3a29180e7f6d5c4b3a29180O algoritmo
- Extraia
tev1do header - Rejeite se
|agora - t| > 300segundos (janela anti-replay de 5 minutos) - Calcule
HMAC-SHA256("<t>.<corpo cru>", segredo)em hexadecimal - Compare com
v1usando 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 "", 200php
<?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:
- Rotacione no painel e copie o segredo novo
- Atualize a variável de ambiente do seu servidor e reinicie
- 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.