Saltar al contenido
Developer Docs

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
HeaderDescripción
zb-timestampMilisegundos Unix del momento de envío
zb-signatureFirma 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

  1. Lee el body crudo antes de cualquier parseo automático del framework.
  2. Normaliza: parsea el JSON → serializa con claves ordenadas alfabéticamente.
  3. Concatena normalizedBody + zb-timestamp.
  4. HMAC-SHA256(webhookSecret, paso3) → digest binario.
  5. hex(digest) → bytes → Base64 = valor esperado de zb-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 crudo
python
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 crudo

Buenas 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 401 o 403, no proceses el evento y registra el intento para auditoría.
  • Valida la ventana de tiempo. Acepta solo si zb-timestamp está dentro de ±5 minutos para evitar ataques de replay.

Solución de problemas: la firma no verifica

CausaSolución
Body modificado antes de verificarLee el body como texto crudo. En Express: express.raw(), no express.json()
webhookSecret incorrectoConfirma el secret con Zertiban
Orden de propiedades incorrectoSerializa con claves ordenadas alfabéticamente
Falta el paso hex→Base64El algoritmo es HMAC → hex → Base64, no HMAC → Base64 directamente
Se omite zb-timestampConcatena siempre normalizedBody + timestamp antes del HMAC

Arquitectura recomendada

Separa claramente las cuatro fases del consumo del webhook:

  1. Recepción del request HTTP.
  2. Validación criptográfica de firma y timestamp.
  3. Persistencia del evento (para idempotencia y auditoría).
  4. Procesamiento funcional asíncrono.

Así construyes consumidores idempotentes, auditables y resilientes frente a retries, desacoplados del procesamiento de negocio.