Aparência
Verificar a assinatura
Qualquer pessoa que descubra o endereço do seu webhook pode mandar uma requisição para ele. A assinatura é o que prova que o aviso veio mesmo do Festero e que o corpo não foi alterado no caminho. Verifique-a em todo aviso, antes de fazer qualquer coisa com ele.
Como a assinatura é montada
Cada requisição traz o cabeçalho Festero-Signature:
Festero-Signature: t=1792593128,v1=b6de473caad2e5bc13c7df4254a8c1f8f9af543e99cadc5bb19b160083f20eee| Parte | Significado |
|---|---|
t | Instante em que o Festero enviou esta tentativa, em segundos desde 1970 (hora Unix, UTC). Em uma reentrega, é o instante da nova tentativa, não o do evento. |
v1 | Assinatura: HMAC-SHA256, em hexadecimal minúsculo, do texto <t>.<corpo bruto>, usando o segredo do endereço como chave. |
v1_prev | Só aparece durante a troca do segredo: a mesma conta, feita com o segredo anterior. |
O texto assinado é o valor de t, um ponto e o corpo da requisição exatamente como chegou. A chave é o segredo do endereço inteiro, com o prefixo whsec_, lido como texto UTF-8 (não decodifique nem corte nada).
Passo a passo
- Leia o corpo como bytes brutos, antes de qualquer
JSON.parse,json.loadsou desserialização. - Separe o cabeçalho em partes, divididas por
,, e cada parte emchave=valor. - Recuse se
testiver a mais de 5 minutos do relógio do seu servidor, para cima ou para baixo. - Calcule o HMAC-SHA256 de
"<t>.<corpo bruto>"com o segredo. - Compare o resultado com
v1e, se existir, comv1_prev, em tempo constante. Se um dos dois bater, a assinatura é válida. - Só então leia o JSON e processe o evento.
Use o corpo bruto
Se você ler o JSON, serializar de novo e assinar o resultado, a conta falha: espaços, ordem dos campos e escapes mudam os bytes. Em frameworks que leem o corpo automaticamente (Express com express.json(), por exemplo), configure a rota do webhook para entregar os bytes originais, como nos exemplos abaixo.
Compare em tempo constante
Compare as assinaturas com a função própria da linguagem (timingSafeEqual, compare_digest, FixedTimeEquals), nunca com ==. Uma comparação comum vaza, pelo tempo de resposta, quantos caracteres batem.
Código
Os três exemplos têm a mesma estrutura: uma função que valida a assinatura e uma rota que a usa. O segredo vem de uma variável de ambiente; guarde-o como qualquer outra senha.
js
// assinatura.mjs
import crypto from 'node:crypto'
const TOLERANCIA_SEGUNDOS = 5 * 60
/**
* @param {Buffer} corpoBruto o corpo exatamente como chegou, antes de qualquer JSON.parse
* @param {string | undefined} cabecalho o valor do cabeçalho Festero-Signature
* @param {string} segredo o segredo do endereço, inteiro, com o prefixo whsec_
*/
export function assinaturaValida(corpoBruto, cabecalho, segredo, agora = Date.now()) {
if (!cabecalho) return false
const partes = {}
for (const item of cabecalho.split(',')) {
const i = item.indexOf('=')
if (i > 0) partes[item.slice(0, i).trim()] = item.slice(i + 1).trim()
}
const t = Number(partes.t)
if (!Number.isInteger(t)) return false
if (Math.abs(agora / 1000 - t) > TOLERANCIA_SEGUNDOS) return false
const esperada = crypto.createHmac('sha256', segredo).update(`${t}.`).update(corpoBruto).digest()
return [partes.v1, partes.v1_prev].some((candidata) => {
if (!candidata) return false
const recebida = Buffer.from(candidata, 'hex')
return recebida.length === esperada.length && crypto.timingSafeEqual(recebida, esperada)
})
}python
# assinatura.py
import hashlib
import hmac
import time
TOLERANCIA_SEGUNDOS = 5 * 60
def assinatura_valida(corpo_bruto: bytes, cabecalho: str | None, segredo: str, agora: float | None = None) -> bool:
"""corpo_bruto: o corpo exatamente como chegou, antes de qualquer json.loads.
segredo: o segredo do endereço, inteiro, com o prefixo whsec_."""
if not cabecalho:
return False
partes = {}
for item in cabecalho.split(","):
chave, separador, valor = item.partition("=")
if separador:
partes[chave.strip()] = valor.strip()
try:
t = int(partes["t"])
except (KeyError, ValueError):
return False
if abs((time.time() if agora is None else agora) - t) > TOLERANCIA_SEGUNDOS:
return False
esperada = hmac.new(segredo.encode("utf-8"), f"{t}.".encode("utf-8") + corpo_bruto, hashlib.sha256).hexdigest()
return any(
candidata is not None and hmac.compare_digest(esperada.encode("ascii"), candidata.lower().encode("utf-8"))
for candidata in (partes.get("v1"), partes.get("v1_prev"))
)csharp
// AssinaturaFestero.cs
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
public static class AssinaturaFestero
{
private static readonly TimeSpan Tolerancia = TimeSpan.FromMinutes(5);
/// <param name="corpoBruto">O corpo exatamente como chegou, antes de qualquer desserialização.</param>
/// <param name="cabecalho">O valor do cabeçalho Festero-Signature.</param>
/// <param name="segredo">O segredo do endereço, inteiro, com o prefixo whsec_.</param>
public static bool Valida(byte[] corpoBruto, string? cabecalho, string segredo, DateTimeOffset agora)
{
if (string.IsNullOrWhiteSpace(cabecalho)) return false;
var partes = new Dictionary<string, string>();
foreach (var item in cabecalho.Split(','))
{
var i = item.IndexOf('=');
if (i > 0) partes[item[..i].Trim()] = item[(i + 1)..].Trim();
}
if (!partes.TryGetValue("t", out var texto) || !long.TryParse(texto, NumberStyles.None, CultureInfo.InvariantCulture, out var t))
return false;
if (Math.Abs((agora - DateTimeOffset.FromUnixTimeSeconds(t)).TotalSeconds) > Tolerancia.TotalSeconds)
return false;
var assinado = Encoding.UTF8.GetBytes($"{t.ToString(CultureInfo.InvariantCulture)}.").Concat(corpoBruto).ToArray();
var esperada = HMACSHA256.HashData(Encoding.UTF8.GetBytes(segredo), assinado);
foreach (var chave in new[] { "v1", "v1_prev" })
{
if (!partes.TryGetValue(chave, out var candidata)) continue;
byte[] recebida;
try { recebida = Convert.FromHexString(candidata); }
catch (FormatException) { continue; }
if (CryptographicOperations.FixedTimeEquals(recebida, esperada)) return true;
}
return false;
}
}A rota que recebe o webhook
js
import express from 'express'
import { assinaturaValida } from './assinatura.mjs'
const app = express()
const jaVistos = new Set() // em produção, use um banco ou cache com expiração
// express.raw entrega o corpo como Buffer, sem tocar nos bytes.
app.post('/webhooks/festero', express.raw({ type: 'application/json' }), (req, res) => {
if (!assinaturaValida(req.body, req.get('Festero-Signature'), process.env.FESTERO_WEBHOOK_SECRET)) {
return res.sendStatus(400)
}
res.sendStatus(200) // responda primeiro: o Festero espera no máximo 10 segundos
const evento = JSON.parse(req.body.toString('utf8'))
if (jaVistos.has(evento.event_id)) return // o mesmo aviso pode chegar mais de uma vez
jaVistos.add(evento.event_id)
// Enfileire o processamento. Para o estado atual, consulte evento.resource_url com a sua credencial.
})
app.listen(3000)python
import os
from flask import Flask, request
from assinatura import assinatura_valida
app = Flask(__name__)
ja_vistos: set[str] = set() # em produção, use um banco ou cache com expiração
@app.post("/webhooks/festero")
def receber():
# request.get_data() devolve os bytes originais, sem tocar no corpo.
if not assinatura_valida(request.get_data(), request.headers.get("Festero-Signature"), os.environ["FESTERO_WEBHOOK_SECRET"]):
return "", 400
evento = request.get_json()
if evento["event_id"] not in ja_vistos: # o mesmo aviso pode chegar mais de uma vez
ja_vistos.add(evento["event_id"])
# Apenas enfileire o processamento: o Festero espera a resposta por no máximo 10 segundos.
# Para o estado atual, consulte evento["resource_url"] com a sua credencial.
return "", 200csharp
using System.Text.Json;
var app = WebApplication.CreateBuilder(args).Build();
var jaVistos = new HashSet<Guid>(); // em produção, use um banco ou cache com expiração
app.MapPost("/webhooks/festero", async (HttpRequest request) =>
{
// Copie o corpo para bytes antes de qualquer desserialização.
using var memoria = new MemoryStream();
await request.Body.CopyToAsync(memoria);
var corpoBruto = memoria.ToArray();
var segredo = Environment.GetEnvironmentVariable("FESTERO_WEBHOOK_SECRET")!;
if (!AssinaturaFestero.Valida(corpoBruto, request.Headers["Festero-Signature"], segredo, DateTimeOffset.UtcNow))
return Results.BadRequest();
var evento = JsonDocument.Parse(corpoBruto).RootElement;
var eventId = evento.GetProperty("event_id").GetGuid();
if (jaVistos.Add(eventId)) // o mesmo aviso pode chegar mais de uma vez
{
// Apenas enfileire o processamento: o Festero espera a resposta por no máximo 10 segundos.
// Para o estado atual, consulte "resource_url" com a sua credencial.
}
return Results.Ok();
});
app.Run();Teste com valores conhecidos
Use este exemplo para conferir a sua implementação antes de ligar o webhook de verdade. Todos os valores são fictícios.
| Item | Valor |
|---|---|
| Segredo | whsec_exemplo_ficticio_0123456789abcdef |
| Cabeçalho | t=1792593128,v1=b6de473caad2e5bc13c7df4254a8c1f8f9af543e99cadc5bb19b160083f20eee |
| Relógio para o teste | Qualquer instante a até 5 minutos de 1792593128, por exemplo 1792593138 |
Corpo, em uma única linha e sem espaço no fim:
json
{"event_id":"c0a8e5b2-3f41-4d7a-9b16-2e8d5a7f1c04","type":"quote.accepted","occurred_at":"2026-10-21T14:32:08.4519833Z","workspace_id":"0f8fad5b-d9cb-469f-a165-70867728950e","api_version":"v1","resource_url":"https://platform.festero.com.br/v1/quotes/e4f6a9b2-3c71-4d85-9b0e-7a1c5d8f2634","data":{"id":"e4f6a9b2-3c71-4d85-9b0e-7a1c5d8f2634","status":"approved","event_id":"b7e2c4a1-95d3-4f08-a6b2-1c8d3e5f7a90"}}Com esses valores, a verificação deve retornar verdadeiro. Se você alterar um único caractere do corpo, ou usar outro segredo, deve retornar falso. Em Node:
js
const corpo = Buffer.from('{"event_id":"c0a8e5b2-...}', 'utf8') // cole o corpo completo acima
const cabecalho = 't=1792593128,v1=b6de473caad2e5bc13c7df4254a8c1f8f9af543e99cadc5bb19b160083f20eee'
console.log(assinaturaValida(corpo, cabecalho, 'whsec_exemplo_ficticio_0123456789abcdef', 1792593138 * 1000)) // trueTroca do segredo
Quando você usa Gerar novo segredo na seção Webhook da tela API do Festero, o Festero mostra o segredo novo uma única vez e, por 24 horas, assina cada aviso com os dois segredos: o novo em v1 e o anterior em v1_prev.
Festero-Signature: t=1792593128,v1=<assinatura com o segredo novo>,v1_prev=<assinatura com o segredo anterior>Os exemplos acima já aceitam os dois. Na prática:
- Gere o novo segredo.
- Atualize o segredo no seu sistema (variável de ambiente ou cofre) e reinicie.
- Pronto. Quem ainda não trocou continua validando por
v1_prevdurante as 24 horas; depois disso, só o segredo novo vale.
Se o seu código só confere v1, ele passa a falhar assim que você trocar o segredo no Festero, até atualizar. Por isso, confira v1 e v1_prev, como nos exemplos.
Erros comuns
| Sintoma | Causa provável |
|---|---|
| Nunca valida | Assinou o JSON reserializado e não o corpo bruto; ou usou o segredo sem o prefixo whsec_; ou decodificou o segredo como base64. |
| Valida às vezes | Um proxy ou framework mexe no corpo (recompressão, troca de quebras de linha) em parte dos pedidos. |
| Falha em todo reenvio | Comparou t com o instante do evento em vez do relógio atual. Em reentregas, o t é o da tentativa. |
| Recusado por tempo | O relógio do seu servidor está adiantado ou atrasado mais de 5 minutos. Sincronize com um servidor de hora (NTP). |
| Funcionou e parou depois de trocar o segredo | O segredo antigo continuou no ambiente, ou só v1 é conferido. |
Segurança
- Recuse com
400(ou outro status que não seja 2xx) o que não validar. - Não registre o segredo nem o corpo completo em logs.
- Mesmo com assinatura válida, trate o conteúdo como um aviso: busque o estado atual do recurso em
resource_url, com a sua credencial.