Verificación de Firma
Cada webhook se firma con HMAC-SHA256 usando un webhookSecret único por organización. Tu endpoint debe recalcular la firma para confirmar que el evento es auténtico.
El algoritmo
text
HMAC-SHA256( secret, normalizedBody + timestamp ) → hex → Base64| Header | Descripción |
|---|---|
zb-timestamp | Milisegundos Unix del momento de envío |
zb-signature | Firma generada por Zertiban |
Normalización del body
El body se normaliza antes de firmar: se parsea el JSON y se serializan las propiedades ordenadas alfabéticamente (sin espacios ni saltos de línea). Así, dos payloads equivalentes, {"b":2,"a":1} y {"a":1,"b":2}, producen la misma firma.
Pasos detallados
- Lee el body crudo antes de cualquier parseo automático del framework.
- Normaliza: parsea el JSON → serializa con claves ordenadas alfabéticamente.
- Concatena
normalizedBody + zb-timestamp. HMAC-SHA256(webhookSecret, paso3)→ digest binario.hex(digest)→ bytes → Base64 = valor esperado dezb-signature.
Implementaciones
java
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import org.apache.commons.codec.binary.Hex;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
private static final ObjectMapper MAPPER = JsonMapper.builder()
.enable(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY).build();
public boolean isValidWebhook(String rawBody, String timestamp,
String signature, String secret) throws Exception {
// 1. Normalizar: parsear y serializar con propiedades ordenadas
String normalized = MAPPER.writeValueAsString(MAPPER.readValue(rawBody, Object.class));
// 2. HMAC-SHA256 del mensaje = body normalizado + timestamp
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] digest = mac.doFinal(
(normalized + timestamp).getBytes(StandardCharsets.UTF_8));
// 3. hex → Base64
String expected = Base64.getEncoder()
.encodeToString(Hex.encodeHexString(digest)
.getBytes(StandardCharsets.UTF_8));
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
signature.getBytes(StandardCharsets.UTF_8));
}javascript
const crypto = require('crypto');
function isValidWebhook(rawBody, timestamp, signature, secret) {
const normalized = JSON.stringify(sortKeysDeep(JSON.parse(rawBody)));
const digest = crypto
.createHmac('sha256', secret)
.update(normalized + timestamp, 'utf8')
.digest();
const expected = Buffer.from(digest.toString('hex')).toString('base64');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
function sortKeysDeep(obj) {
if (typeof obj !== 'object' || obj === null) return obj;
if (Array.isArray(obj)) return obj.map(sortKeysDeep);
return Object.keys(obj)
.sort()
.reduce((acc, k) => ({ ...acc, [k]: sortKeysDeep(obj[k]) }), {});
}
// Express: usa express.raw() ANTES de express.json() para preservar el body crudopython
import hmac, hashlib, base64, json
def is_valid_webhook(raw_body, timestamp, signature, secret):
normalized = json.dumps(
json.loads(raw_body), sort_keys=True, separators=(',', ':'))
digest = hmac.new(
secret.encode(), (normalized + timestamp).encode(), hashlib.sha256
).digest()
expected = base64.b64encode(digest.hex().encode()).decode()
return hmac.compare_digest(expected, signature)
# Flask: usa request.get_data(as_text=True) para leer el body crudoBuenas prácticas
- Valida antes de procesar. Nunca proceses un webhook sin verificar primero la firma.
- Comparación constante. Usa comparación constant-time (
hmac.compare_digest,MessageDigest.isEqual,crypto.timingSafeEqual) para evitar ataques de timing. - Rechaza firmas inválidas con
401o403, no proceses el evento y registra el intento para auditoría. - Valida la ventana de tiempo. Acepta solo si
zb-timestampestá dentro de ±5 minutos para evitar ataques de replay.
Solución de problemas: la firma no verifica
| Causa | Solución |
|---|---|
| Body modificado antes de verificar | Lee el body como texto crudo. En Express: express.raw(), no express.json() |
webhookSecret incorrecto | Confirma el secret con Zertiban |
| Orden de propiedades incorrecto | Serializa con claves ordenadas alfabéticamente |
| Falta el paso hex→Base64 | El algoritmo es HMAC → hex → Base64, no HMAC → Base64 directamente |
Se omite zb-timestamp | Concatena siempre normalizedBody + timestamp antes del HMAC |
Arquitectura recomendada
Separa claramente las cuatro fases del consumo del webhook:
- Recepción del request HTTP.
- Validación criptográfica de firma y timestamp.
- Persistencia del evento (para idempotencia y auditoría).
- Procesamiento funcional asíncrono.
Así construyes consumidores idempotentes, auditables y resilientes frente a retries, desacoplados del procesamiento de negocio.