Validação HMAC¶
Toda entrega de webhook traz três headers:
X-Aceitou-Signature: sha256=<hex-lowercase>
X-Aceitou-Event: document_sent
X-Aceitou-Delivery-Id: 1234567890
A assinatura é HMAC-SHA256(secret, raw_body) em hex lowercase, prefixada por sha256=.
X-Aceitou-Delivery-Id é o identificador único da entrega — quando o nosso WebhookRetryWorker retenta um delivery falhada, o mesmo id é reenviado. Use pra deduplicar no seu lado (cache de IDs vistos por N horas e ignorar duplicatas).
Sempre valide
Sem validação, qualquer um que descobrir sua URL pode forjar eventos. Valide antes de qualquer processamento — incluindo logs.
Regras pra validação correta¶
- Use o body cru (
raw bytes, não JSON re-serializado). Reserializar muda o hash. - Comparação de tempo constante (
timingSafeEqual/compare_digest/hash_equals). Comparação==vaza timing. - Resposta
401se inválido. Não retorne200pra eventos não-autenticados.
Implementações¶
import crypto from "node:crypto";
import express from "express";
function verify(rawBody, signatureHeader, secret) {
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(rawBody, "utf8")
.digest("hex");
const a = Buffer.from(signatureHeader);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post(
"/webhooks/aceitou",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.header("X-Aceitou-Signature");
if (!sig || !verify(req.body, sig, process.env.ACEITOU_WEBHOOK_SECRET)) {
return res.status(401).send("invalid signature");
}
const event = req.header("X-Aceitou-Event");
const payload = JSON.parse(req.body.toString("utf8"));
// processe...
res.status(200).send();
},
);
import hmac
import hashlib
import os
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
SECRET = os.environ["ACEITOU_WEBHOOK_SECRET"]
def verify(raw_body: bytes, signature_header: str) -> bool:
expected = "sha256=" + hmac.new(
SECRET.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature_header, expected)
@app.post("/webhooks/aceitou")
async def webhook(request: Request):
raw = await request.body()
sig = request.headers.get("X-Aceitou-Signature", "")
if not verify(raw, sig):
raise HTTPException(401, "invalid signature")
# event = request.headers.get("X-Aceitou-Event")
# payload = json.loads(raw)
return {"ok": True}
function verifyAceitouSignature($rawBody, $signatureHeader, $secret) {
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
return hash_equals($signatureHeader, $expected);
}
$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_ACEITOU_SIGNATURE'] ?? '';
if (!verifyAceitouSignature($raw, $sig, getenv('ACEITOU_WEBHOOK_SECRET'))) {
http_response_code(401);
exit('invalid signature');
}
$payload = json_decode($raw, true);
// processe...
http_response_code(200);
func verify(body []byte, signatureHeader, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(signatureHeader), []byte(expected))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
sig := r.Header.Get("X-Aceitou-Signature")
if !verify(body, sig, os.Getenv("ACEITOU_WEBHOOK_SECRET")) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
// processe...
w.WriteHeader(http.StatusOK)
}
using System.Security.Cryptography;
using System.Text;
app.MapPost("/webhooks/aceitou", async (HttpContext ctx) =>
{
using var ms = new MemoryStream();
await ctx.Request.Body.CopyToAsync(ms);
var raw = ms.ToArray();
var sig = ctx.Request.Headers["X-Aceitou-Signature"].ToString();
var secret = Environment.GetEnvironmentVariable("ACEITOU_WEBHOOK_SECRET")!;
if (!Verify(raw, sig, secret))
return Results.Unauthorized();
// var ev = ctx.Request.Headers["X-Aceitou-Event"].ToString();
return Results.Ok();
});
static bool Verify(byte[] rawBody, string signatureHeader, string secret)
{
var hash = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), rawBody);
var expected = "sha256=" + Convert.ToHexString(hash).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(signatureHeader),
Encoding.UTF8.GetBytes(expected));
}
Testando localmente¶
Use ngrok ou Cloudflare Tunnel pra expor seu localhost com HTTPS:
Cadastre o webhook apontando pra URL do ngrok. Quando estiver verde, troque pelo seu domínio real em homologação.
A Aceitou só entrega em HTTPS. URLs http:// são rejeitadas na criação.