# 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 `.`, usando o segredo do endereço como chave. | | `v1_prev` | Só aparece durante a [troca do segredo](#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 1. Leia o corpo como **bytes brutos**, antes de qualquer `JSON.parse`, `json.loads` ou desserialização. 2. Separe o cabeçalho em partes, divididas por `,`, e cada parte em `chave=valor`. 3. Recuse se `t` estiver a mais de **5 minutos** do relógio do seu servidor, para cima ou para baixo. 4. Calcule o HMAC-SHA256 de `"."` com o segredo. 5. Compare o resultado com `v1` e, se existir, com `v1_prev`, **em tempo constante**. Se um dos dois bater, a assinatura é válida. 6. Só então leia o JSON e processe o evento. > **Cuidado**: 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. > **Atenção**: 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. **Node.js** ```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** ```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")) ) ``` **C#** ```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); /// O corpo exatamente como chegou, antes de qualquer desserialização. /// O valor do cabeçalho Festero-Signature. /// O segredo do endereço, inteiro, com o prefixo whsec_. public static bool Valida(byte[] corpoBruto, string? cabecalho, string segredo, DateTimeOffset agora) { if (string.IsNullOrWhiteSpace(cabecalho)) return false; var partes = new Dictionary(); 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 **Node.js (Express)** ```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 (Flask)** ```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 "", 200 ``` **C# (ASP.NET Core)** ```csharp using System.Text.Json; var app = WebApplication.CreateBuilder(args).Build(); var jaVistos = new HashSet(); // 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)) // true ``` ## Troca 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=,v1_prev= ``` Os exemplos acima já aceitam os dois. Na prática: 1. Gere o novo segredo. 2. Atualize o segredo no seu sistema (variável de ambiente ou cofre) e reinicie. 3. Pronto. Quem ainda não trocou continua validando por `v1_prev` durante 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.