Webhooks firmados
Cada entrega de webhook viene firmada con HMAC-SHA256 sobre el cuerpo crudo, usando tu signing secret como clave. Verificar la firma es la única forma de estar seguro de que el POST viene de Emitoo y no de un atacante que descubrió tu URL.
Cabeceras que recibirás
| Header | Descripción |
|---|---|
X-Emitoo-Signature | t={timestamp},v1={hmac-hex}. La firma que debes verificar. |
X-Emitoo-Event | Tipo de evento. Ej: factura.autorizada. |
X-Emitoo-Delivery | ID único de la entrega. Úsalo como idempotency key. |
User-Agent | Emitoo-Webhooks/1.0. |
Algoritmo de verificación
Los SDKs oficiales lo implementan por ti: verificarFirma
(TypeScript), verificar_firma (Python) y
VerificarFirma (Go). Esto es lo que hace el helper — y lo
que debes implementar si usas otro lenguaje:
- Lee el header
X-Emitoo-Signature. Sepáralo por coma en dos partes:tyv1. - Lee el cuerpo crudo (sin parsear JSON antes — la firma es sobre los bytes exactos).
- Calcula
HMAC-SHA256(signing_secret, "{t}.{rawBody}"). - Compara con
timingSafeEqualel resultado contra elv1recibido. - Verifica que
tno tenga más de 5 minutos de antigüedad (anti-replay). - Si todo OK, procesa y responde 2xx rápido.
import { verificarFirma } from "emitoo";
// Express: conserva el body crudo con express.raw()
app.post("/webhooks/emitoo", express.raw({ type: "*/*" }), async (req, res) => {
const valida = await verificarFirma({
header: req.headers["x-emitoo-signature"],
body: req.body, // crudo, sin JSON.parse
secret: process.env.EMITOO_WEBHOOK_SECRET,
// toleranciaSegundos: 300 (default; 0 = sin anti-replay)
});
if (!valida) return res.status(401).end();
const evento = JSON.parse(req.body.toString());
// ... tu lógica ...
res.sendStatus(204);
});import os
from emitoo import verificar_firma
from flask import request, abort
@app.post("/webhooks/emitoo")
def webhook():
raw = request.get_data() # cuerpo crudo, sin parsear
if not verificar_firma(
request.headers.get("X-Emitoo-Signature"),
raw,
os.environ["EMITOO_WEBHOOK_SECRET"],
# tolerancia_segundos=300 (default; 0 = sin anti-replay)
):
abort(401, "bad signature")
event = request.get_json()
# ... tu lógica ...
return "", 204import "github.com/Killkanacode/emitoo-go"
func handleEmitooWebhook(w http.ResponseWriter, r *http.Request) {
// El cuerpo crudo, sin parsear el JSON antes.
raw, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
// Anti-replay de 5 min incluido; ajustable con
// emitoo.VerificarFirmaConTolerancia(header, raw, secret, tolerancia).
if !emitoo.VerificarFirma(r.Header.Get("X-Emitoo-Signature"), raw, os.Getenv("EMITOO_WEBHOOK_SECRET")) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
// ... tu lógica ...
w.WriteHeader(http.StatusNoContent)
}El cuerpo crudo importa
express.raw(),
FastAPI: request.body(), Flask: request.get_data()).
Dónde encuentro el signing secret
Cada suscripción a un webhook tiene su propio secret. Lo encuentras en el dashboard bajo Webhooks → [suscripción] → Signing secret. Puedes rotarlo en cualquier momento; las entregas nuevas se firman con el nuevo secret y las firmas viejas dejan de validarse.
Anti-replay
El timestamp dentro de la firma te protege contra replays: si un atacante captura un POST válido y lo reenvía 10 minutos después, tu verificación falla porque el timestamp es viejo. Recomendamos un margen de 5 minutos para absorber drift de relojes.
Idempotencia con X-Emitoo-Delivery
Si el sistema reintenta una entrega (por ejemplo, tu endpoint tardó más
de 15 s en responder o devolvió un error), recibirás el mismo evento con el
mismo X-Emitoo-Delivery en todos los reintentos.
Guarda los delivery IDs procesados para descartar duplicados.