Skip to content

Verificación de Firmas

Zelta Pay firma cada webhook que envía con una firma HMAC-SHA256, permitiéndote verificar que los eventos provienen de Zelta Pay y no han sido modificados en tránsito.

Por qué verificar firmas

La verificación de firmas proporciona tres garantías fundamentales:

  • Autenticidad — Confirma que el webhook fue enviado por Zelta Pay y no por un tercero.
  • Integridad — Asegura que el contenido del webhook no fue alterado durante la transmisión.
  • Seguridad — Protege tu aplicación contra ataques de suplantación y reenvío de eventos falsos.

Seguridad crítica

Nunca proceses webhooks sin verificar la firma. Sin verificación, un atacante podría enviar eventos falsos a tu endpoint y provocar acciones no autorizadas en tu sistema.

Cómo funciona

Zelta Pay utiliza HMAC-SHA256 para firmar cada webhook. El proceso funciona en cuatro pasos:

  1. Zelta Pay genera la firma — Al enviar un webhook, Zelta Pay crea una firma usando tu secreto de webhook y el contenido del evento.
  2. La firma se incluye en el header — La firma se envía en el header Zeltapay-Signature junto con un timestamp.
  3. Tu servidor recibe el webhook — Tu endpoint recibe el evento con el header de firma.
  4. Tu servidor verifica la firma — Usando el mismo secreto, tu servidor calcula la firma esperada y la compara con la recibida.

Formato de la firma

El header Zeltapay-Signature tiene el siguiente formato:

Zeltapay-Signature: t=1640995200, v1=abc123def456...
ComponenteDescripción
tTimestamp Unix (en segundos) del momento en que se generó la firma.
v1Firma HMAC-SHA256 codificada en hexadecimal.

Algoritmo de verificación

Paso 1: Extraer componentes

Parsea el header Zeltapay-Signature para obtener el timestamp (t) y la firma (v1):

:: tab Node.js

javascript
function parseSignatureHeader(header) {
  const parts = header.split(', ');
  const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
  const signature = parts.find(p => p.startsWith('v1=')).slice(3);
  return { timestamp, signature };
}

::

:: tab Cloudflare Workers

javascript
function parseSignatureHeader(header) {
  const parts = header.split(', ');
  const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
  const signature = parts.find(p => p.startsWith('v1=')).slice(3);
  return { timestamp, signature };
}

::

:: tab Hono

javascript
function parseSignatureHeader(header) {
  const parts = header.split(', ');
  const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
  const signature = parts.find(p => p.startsWith('v1=')).slice(3);
  return { timestamp, signature };
}

::

:: tab Python

python
def parse_signature_header(header):
    parts = header.split(', ')
    timestamp = None
    signature = None
    for part in parts:
        if part.startswith('t='):
            timestamp = part[2:]
        elif part.startswith('v1='):
            signature = part[3:]
    return timestamp, signature

::

:: tab PHP

php
function parseSignatureHeader(string $header): array {
    $parts = explode(', ', $header);
    $timestamp = null;
    $signature = null;
    foreach ($parts as $part) {
        if (str_starts_with($part, 't=')) {
            $timestamp = substr($part, 2);
        } elseif (str_starts_with($part, 'v1=')) {
            $signature = substr($part, 3);
        }
    }
    return ['timestamp' => $timestamp, 'signature' => $signature];
}

::

:: tab Go

go
func parseSignatureHeader(header string) (timestamp, signature string) {
	parts := strings.Split(header, ", ")
	for _, part := range parts {
		if strings.HasPrefix(part, "t=") {
			timestamp = strings.TrimPrefix(part, "t=")
		} else if strings.HasPrefix(part, "v1=") {
			signature = strings.TrimPrefix(part, "v1=")
		}
	}
	return
}

::

Paso 2: Verificar timestamp

Valida que el timestamp no sea demasiado antiguo para prevenir ataques de reenvío:

javascript
const MAX_AGE_SECONDS = 300; // 5 minutos
const currentTime = Math.floor(Date.now() / 1000);
const age = currentTime - parseInt(timestamp);

if (age > MAX_AGE_SECONDS) {
  throw new Error('El timestamp del webhook ha expirado');
}

Paso 3: Crear la firma esperada

Concatena el timestamp y el cuerpo del request con un punto (.) y calcula el HMAC-SHA256:

:: tab Node.js

javascript
const crypto = require('crypto');

function createExpectedSignature(timestamp, body, secret) {
  const payload = `${timestamp}.${body}`;
  return crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');
}

::

:: tab Cloudflare Workers

javascript
async function createExpectedSignature(timestamp, body, secret) {
  const encoder = new TextEncoder();
  const key = await crypto.subtle.importKey(
    'raw',
    encoder.encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign']
  );
  const payload = `${timestamp}.${body}`;
  const signatureBuffer = await crypto.subtle.sign(
    'HMAC',
    key,
    encoder.encode(payload)
  );
  return Array.from(new Uint8Array(signatureBuffer))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');
}

::

:: tab Hono

javascript
async function createExpectedSignature(timestamp, body, secret) {
  const encoder = new TextEncoder();
  const key = await crypto.subtle.importKey(
    'raw',
    encoder.encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign']
  );
  const payload = `${timestamp}.${body}`;
  const signatureBuffer = await crypto.subtle.sign(
    'HMAC',
    key,
    encoder.encode(payload)
  );
  return Array.from(new Uint8Array(signatureBuffer))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');
}

::

:: tab Python

python
import hmac
import hashlib

def create_expected_signature(timestamp, body, secret):
    payload = f"{timestamp}.{body}"
    return hmac.new(
        secret.encode('utf-8'),
        payload.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()

::

:: tab PHP

php
function createExpectedSignature(string $timestamp, string $body, string $secret): string {
    $payload = "{$timestamp}.{$body}";
    return hash_hmac('sha256', $payload, $secret);
}

::

:: tab Go

go
import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
)

func createExpectedSignature(timestamp, body, secret string) string {
	payload := fmt.Sprintf("%s.%s", timestamp, body)
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(payload))
	return hex.EncodeToString(mac.Sum(nil))
}

::

Paso 4: Comparar firmas

Compara la firma recibida con la esperada usando una comparación de tiempo constante para prevenir ataques de timing:

:: tab Node.js

javascript
const crypto = require('crypto');

function signaturesMatch(received, expected) {
  const a = Buffer.from(received, 'hex');
  const b = Buffer.from(expected, 'hex');
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

::

:: tab Cloudflare Workers

javascript
function signaturesMatch(received, expected) {
  if (received.length !== expected.length) return false;
  const encoder = new TextEncoder();
  const a = encoder.encode(received);
  const b = encoder.encode(expected);
  let result = 0;
  for (let i = 0; i < a.length; i++) {
    result |= a[i] ^ b[i];
  }
  return result === 0;
}

::

:: tab Hono

javascript
function signaturesMatch(received, expected) {
  if (received.length !== expected.length) return false;
  const encoder = new TextEncoder();
  const a = encoder.encode(received);
  const b = encoder.encode(expected);
  let result = 0;
  for (let i = 0; i < a.length; i++) {
    result |= a[i] ^ b[i];
  }
  return result === 0;
}

::

:: tab Python

python
import hmac

def signatures_match(received, expected):
    return hmac.compare_digest(received, expected)

::

:: tab PHP

php
function signaturesMatch(string $received, string $expected): bool {
    return hash_equals($expected, $received);
}

::

:: tab Go

go
import "crypto/hmac"

func signaturesMatch(received, expected string) bool {
	return hmac.Equal([]byte(received), []byte(expected))
}

::

Implementación completa

A continuación se muestra la implementación completa de verificación de firmas para cada lenguaje:

:: tab Node.js

javascript
const crypto = require('crypto');

function verifyWebhookSignature(payload, signatureHeader, secret) {
  // Extraer componentes
  const parts = signatureHeader.split(', ');
  const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
  const signature = parts.find(p => p.startsWith('v1=')).slice(3);

  // Verificar timestamp (5 minutos de tolerancia)
  const currentTime = Math.floor(Date.now() / 1000);
  if (currentTime - parseInt(timestamp) > 300) {
    throw new Error('El timestamp del webhook ha expirado');
  }

  // Crear firma esperada
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${payload}`)
    .digest('hex');

  // Comparar firmas de forma segura
  const a = Buffer.from(signature, 'hex');
  const b = Buffer.from(expectedSignature, 'hex');
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    throw new Error('La firma del webhook es inválida');
  }

  return true;
}

// Uso con Express
const express = require('express');
const app = express();

app.post('/webhooks/zelta', express.raw({ type: 'application/json' }), (req, res) => {
  const signatureHeader = req.headers['zeltapay-signature'];
  const payload = req.body.toString();

  try {
    verifyWebhookSignature(payload, signatureHeader, process.env.WEBHOOK_SECRET);
    const event = JSON.parse(payload);

    // Procesar el evento
    console.log('Evento verificado:', event.type);
    res.status(200).json({ received: true });
  } catch (error) {
    console.error('Verificación fallida:', error.message);
    res.status(401).json({ error: 'Firma inválida' });
  }
});

::

:: tab Cloudflare Workers

javascript
export default {
  async fetch(request, env) {
    if (request.method !== 'POST') {
      return new Response('Method not allowed', { status: 405 });
    }

    const signatureHeader = request.headers.get('zeltapay-signature');
    if (!signatureHeader) {
      return Response.json({ error: 'Header de firma faltante' }, { status: 401 });
    }

    const payload = await request.text();

    try {
      await verifyWebhookSignature(payload, signatureHeader, env.WEBHOOK_SECRET);
      const event = JSON.parse(payload);

      // Procesar el evento
      console.log('Evento verificado:', event.type);
      return Response.json({ received: true }, { status: 200 });
    } catch (error) {
      console.error('Verificación fallida:', error.message);
      return Response.json({ error: 'Firma inválida' }, { status: 401 });
    }
  }
};

async function verifyWebhookSignature(payload, signatureHeader, secret) {
  const parts = signatureHeader.split(', ');
  const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
  const signature = parts.find(p => p.startsWith('v1=')).slice(3);

  // Verificar timestamp
  const currentTime = Math.floor(Date.now() / 1000);
  if (currentTime - parseInt(timestamp) > 300) {
    throw new Error('El timestamp del webhook ha expirado');
  }

  // Crear firma esperada con Web Crypto API
  const encoder = new TextEncoder();
  const key = await crypto.subtle.importKey(
    'raw',
    encoder.encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign']
  );
  const payloadToSign = `${timestamp}.${payload}`;
  const signatureBuffer = await crypto.subtle.sign(
    'HMAC',
    key,
    encoder.encode(payloadToSign)
  );
  const expectedSignature = Array.from(new Uint8Array(signatureBuffer))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');

  // Comparar firmas
  if (signature.length !== expectedSignature.length) {
    throw new Error('La firma del webhook es inválida');
  }
  const a = encoder.encode(signature);
  const b = encoder.encode(expectedSignature);
  let result = 0;
  for (let i = 0; i < a.length; i++) {
    result |= a[i] ^ b[i];
  }
  if (result !== 0) {
    throw new Error('La firma del webhook es inválida');
  }

  return true;
}

::

:: tab Hono

javascript
import { Hono } from 'hono';

const app = new Hono();

app.post('/webhooks/zelta', async (c) => {
  const signatureHeader = c.req.header('zeltapay-signature');
  if (!signatureHeader) {
    return c.json({ error: 'Header de firma faltante' }, 401);
  }

  const payload = await c.req.text();

  try {
    await verifyWebhookSignature(payload, signatureHeader, c.env.WEBHOOK_SECRET);
    const event = JSON.parse(payload);

    // Procesar el evento
    console.log('Evento verificado:', event.type);
    return c.json({ received: true }, 200);
  } catch (error) {
    console.error('Verificación fallida:', error.message);
    return c.json({ error: 'Firma inválida' }, 401);
  }
});

async function verifyWebhookSignature(payload, signatureHeader, secret) {
  const parts = signatureHeader.split(', ');
  const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
  const signature = parts.find(p => p.startsWith('v1=')).slice(3);

  // Verificar timestamp
  const currentTime = Math.floor(Date.now() / 1000);
  if (currentTime - parseInt(timestamp) > 300) {
    throw new Error('El timestamp del webhook ha expirado');
  }

  // Crear firma esperada con Web Crypto API
  const encoder = new TextEncoder();
  const key = await crypto.subtle.importKey(
    'raw',
    encoder.encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign']
  );
  const payloadToSign = `${timestamp}.${payload}`;
  const signatureBuffer = await crypto.subtle.sign(
    'HMAC',
    key,
    encoder.encode(payloadToSign)
  );
  const expectedSignature = Array.from(new Uint8Array(signatureBuffer))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');

  // Comparar firmas
  if (signature.length !== expectedSignature.length) {
    throw new Error('La firma del webhook es inválida');
  }
  const a = encoder.encode(signature);
  const b = encoder.encode(expectedSignature);
  let result = 0;
  for (let i = 0; i < a.length; i++) {
    result |= a[i] ^ b[i];
  }
  if (result !== 0) {
    throw new Error('La firma del webhook es inválida');
  }

  return true;
}

export default app;

::

:: tab Python

python
import hmac
import hashlib
import time
import json

def verify_webhook_signature(payload, signature_header, secret):
    # Extraer componentes
    parts = signature_header.split(', ')
    timestamp = None
    signature = None
    for part in parts:
        if part.startswith('t='):
            timestamp = part[2:]
        elif part.startswith('v1='):
            signature = part[3:]

    if not timestamp or not signature:
        raise ValueError('Formato de firma inválido')

    # Verificar timestamp (5 minutos de tolerancia)
    current_time = int(time.time())
    if current_time - int(timestamp) > 300:
        raise ValueError('El timestamp del webhook ha expirado')

    # Crear firma esperada
    payload_to_sign = f"{timestamp}.{payload}"
    expected_signature = hmac.new(
        secret.encode('utf-8'),
        payload_to_sign.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()

    # Comparar firmas de forma segura
    if not hmac.compare_digest(signature, expected_signature):
        raise ValueError('La firma del webhook es inválida')

    return True


# Uso con Flask
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route('/webhooks/zelta', methods=['POST'])
def handle_webhook():
    signature_header = request.headers.get('Zeltapay-Signature')
    payload = request.get_data(as_text=True)

    try:
        verify_webhook_signature(payload, signature_header, os.environ['WEBHOOK_SECRET'])
        event = json.loads(payload)

        # Procesar el evento
        print(f"Evento verificado: {event['type']}")
        return jsonify({'received': True}), 200
    except ValueError as e:
        print(f"Verificación fallida: {e}")
        return jsonify({'error': 'Firma inválida'}), 401

::

:: tab PHP

php
<?php

function verifyWebhookSignature(string $payload, string $signatureHeader, string $secret): bool {
    // Extraer componentes
    $parts = explode(', ', $signatureHeader);
    $timestamp = null;
    $signature = null;
    foreach ($parts as $part) {
        if (str_starts_with($part, 't=')) {
            $timestamp = substr($part, 2);
        } elseif (str_starts_with($part, 'v1=')) {
            $signature = substr($part, 3);
        }
    }

    if (!$timestamp || !$signature) {
        throw new Exception('Formato de firma inválido');
    }

    // Verificar timestamp (5 minutos de tolerancia)
    if (time() - intval($timestamp) > 300) {
        throw new Exception('El timestamp del webhook ha expirado');
    }

    // Crear firma esperada
    $payloadToSign = "{$timestamp}.{$payload}";
    $expectedSignature = hash_hmac('sha256', $payloadToSign, $secret);

    // Comparar firmas de forma segura
    if (!hash_equals($expectedSignature, $signature)) {
        throw new Exception('La firma del webhook es inválida');
    }

    return true;
}

// Uso
$payload = file_get_contents('php://input');
$signatureHeader = $_SERVER['HTTP_ZELTAPAY_SIGNATURE'] ?? '';

try {
    verifyWebhookSignature($payload, $signatureHeader, getenv('WEBHOOK_SECRET'));
    $event = json_decode($payload, true);

    // Procesar el evento
    error_log("Evento verificado: " . $event['type']);
    http_response_code(200);
    echo json_encode(['received' => true]);
} catch (Exception $e) {
    error_log("Verificación fallida: " . $e->getMessage());
    http_response_code(401);
    echo json_encode(['error' => 'Firma inválida']);
}

::

:: tab Go

go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
	"io"
)

func verifyWebhookSignature(payload, signatureHeader, secret string) error {
	// Extraer componentes
	parts := strings.Split(signatureHeader, ", ")
	var timestamp, signature string
	for _, part := range parts {
		if strings.HasPrefix(part, "t=") {
			timestamp = strings.TrimPrefix(part, "t=")
		} else if strings.HasPrefix(part, "v1=") {
			signature = strings.TrimPrefix(part, "v1=")
		}
	}

	if timestamp == "" || signature == "" {
		return fmt.Errorf("formato de firma inválido")
	}

	// Verificar timestamp (5 minutos de tolerancia)
	ts, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil {
		return fmt.Errorf("timestamp inválido")
	}
	if time.Now().Unix()-ts > 300 {
		return fmt.Errorf("el timestamp del webhook ha expirado")
	}

	// Crear firma esperada
	payloadToSign := fmt.Sprintf("%s.%s", timestamp, payload)
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(payloadToSign))
	expectedSignature := hex.EncodeToString(mac.Sum(nil))

	// Comparar firmas de forma segura
	if !hmac.Equal([]byte(signature), []byte(expectedSignature)) {
		return fmt.Errorf("la firma del webhook es inválida")
	}

	return nil
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	signatureHeader := r.Header.Get("Zeltapay-Signature")
	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "Error leyendo el body", http.StatusBadRequest)
		return
	}

	err = verifyWebhookSignature(string(body), signatureHeader, os.Getenv("WEBHOOK_SECRET"))
	if err != nil {
		fmt.Printf("Verificación fallida: %s\n", err)
		w.WriteHeader(http.StatusUnauthorized)
		json.NewEncoder(w).Encode(map[string]string{"error": "Firma inválida"})
		return
	}

	var event map[string]interface{}
	json.Unmarshal(body, &event)
	fmt.Printf("Evento verificado: %s\n", event["type"])

	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(map[string]bool{"received": true})
}

func main() {
	http.HandleFunc("/webhooks/zelta", webhookHandler)
	http.ListenAndServe(":8080", nil)
}

::

Validación de timestamp

Por qué validar el timestamp

La validación de timestamp previene ataques de reenvío (replay attacks). Sin esta validación, un atacante que intercepte un webhook válido podría reenviarlo múltiples veces a tu endpoint.

TIP

Se recomienda una tolerancia de 5 minutos (300 segundos). Esto permite variaciones normales de latencia de red sin dejar una ventana demasiado amplia para ataques.

Verificación completa con timestamp

:: tab Node.js

javascript
function verifyTimestamp(timestamp, toleranceSeconds = 300) {
  const currentTime = Math.floor(Date.now() / 1000);
  const webhookTime = parseInt(timestamp);
  const age = currentTime - webhookTime;

  if (age < 0) {
    throw new Error('El timestamp del webhook está en el futuro');
  }

  if (age > toleranceSeconds) {
    throw new Error(`El webhook expiró hace ${age - toleranceSeconds} segundos`);
  }

  return true;
}

::

:: tab Cloudflare Workers

javascript
function verifyTimestamp(timestamp, toleranceSeconds = 300) {
  const currentTime = Math.floor(Date.now() / 1000);
  const webhookTime = parseInt(timestamp);
  const age = currentTime - webhookTime;

  if (age < 0) {
    throw new Error('El timestamp del webhook está en el futuro');
  }

  if (age > toleranceSeconds) {
    throw new Error(`El webhook expiró hace ${age - toleranceSeconds} segundos`);
  }

  return true;
}

::

:: tab Hono

javascript
function verifyTimestamp(timestamp, toleranceSeconds = 300) {
  const currentTime = Math.floor(Date.now() / 1000);
  const webhookTime = parseInt(timestamp);
  const age = currentTime - webhookTime;

  if (age < 0) {
    throw new Error('El timestamp del webhook está en el futuro');
  }

  if (age > toleranceSeconds) {
    throw new Error(`El webhook expiró hace ${age - toleranceSeconds} segundos`);
  }

  return true;
}

::

:: tab Python

python
import time

def verify_timestamp(timestamp, tolerance_seconds=300):
    current_time = int(time.time())
    webhook_time = int(timestamp)
    age = current_time - webhook_time

    if age < 0:
        raise ValueError('El timestamp del webhook está en el futuro')

    if age > tolerance_seconds:
        raise ValueError(f'El webhook expiró hace {age - tolerance_seconds} segundos')

    return True

::

Manejo de errores

Errores comunes de verificación

ErrorCausaSolución
Firma inválidaEl secreto de webhook no coincide o el payload fue modificado.Verifica que usas el secreto correcto y que el body no se parsea antes de verificar.
Timestamp expiradoEl webhook tardó más de 5 minutos en llegar.Verifica la conectividad de tu servidor. Considera aumentar la tolerancia temporalmente.
Header faltanteEl header Zeltapay-Signature no está presente.Asegúrate de que tu proxy o balanceador de carga no elimine headers personalizados.
Formato inválidoEl header no sigue el formato t=..., v1=....Verifica que estás leyendo el header correcto.

Manejo robusto de errores

:: tab Node.js

javascript
function verifyWebhookWithErrorHandling(req, secret) {
  const signatureHeader = req.headers['zeltapay-signature'];

  if (!signatureHeader) {
    return { valid: false, error: 'MISSING_HEADER', message: 'Header Zeltapay-Signature no encontrado' };
  }

  if (!signatureHeader.includes('t=') || !signatureHeader.includes('v1=')) {
    return { valid: false, error: 'INVALID_FORMAT', message: 'Formato de firma inválido' };
  }

  try {
    const parts = signatureHeader.split(', ');
    const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
    const signature = parts.find(p => p.startsWith('v1=')).slice(3);

    // Verificar timestamp
    const currentTime = Math.floor(Date.now() / 1000);
    const age = currentTime - parseInt(timestamp);
    if (age > 300) {
      return { valid: false, error: 'EXPIRED', message: `Webhook expirado (${age}s de antigüedad)` };
    }
    if (age < 0) {
      return { valid: false, error: 'FUTURE_TIMESTAMP', message: 'Timestamp en el futuro' };
    }

    // Verificar firma
    const payload = req.body.toString();
    const expectedSignature = crypto
      .createHmac('sha256', secret)
      .update(`${timestamp}.${payload}`)
      .digest('hex');

    const a = Buffer.from(signature, 'hex');
    const b = Buffer.from(expectedSignature, 'hex');
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return { valid: false, error: 'INVALID_SIGNATURE', message: 'La firma no coincide' };
    }

    return { valid: true, timestamp: parseInt(timestamp) };
  } catch (error) {
    return { valid: false, error: 'VERIFICATION_ERROR', message: error.message };
  }
}

::

:: tab Cloudflare Workers

javascript
async function verifyWebhookWithErrorHandling(request, secret) {
  const signatureHeader = request.headers.get('zeltapay-signature');

  if (!signatureHeader) {
    return { valid: false, error: 'MISSING_HEADER', message: 'Header Zeltapay-Signature no encontrado' };
  }

  if (!signatureHeader.includes('t=') || !signatureHeader.includes('v1=')) {
    return { valid: false, error: 'INVALID_FORMAT', message: 'Formato de firma inválido' };
  }

  try {
    const parts = signatureHeader.split(', ');
    const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
    const signature = parts.find(p => p.startsWith('v1=')).slice(3);

    // Verificar timestamp
    const currentTime = Math.floor(Date.now() / 1000);
    const age = currentTime - parseInt(timestamp);
    if (age > 300) {
      return { valid: false, error: 'EXPIRED', message: `Webhook expirado (${age}s de antigüedad)` };
    }
    if (age < 0) {
      return { valid: false, error: 'FUTURE_TIMESTAMP', message: 'Timestamp en el futuro' };
    }

    // Verificar firma con Web Crypto API
    const payload = await request.text();
    const encoder = new TextEncoder();
    const key = await crypto.subtle.importKey(
      'raw',
      encoder.encode(secret),
      { name: 'HMAC', hash: 'SHA-256' },
      false,
      ['sign']
    );
    const signatureBuffer = await crypto.subtle.sign(
      'HMAC',
      key,
      encoder.encode(`${timestamp}.${payload}`)
    );
    const expectedSignature = Array.from(new Uint8Array(signatureBuffer))
      .map(b => b.toString(16).padStart(2, '0'))
      .join('');

    if (signature.length !== expectedSignature.length) {
      return { valid: false, error: 'INVALID_SIGNATURE', message: 'La firma no coincide' };
    }
    const a = encoder.encode(signature);
    const b = encoder.encode(expectedSignature);
    let result = 0;
    for (let i = 0; i < a.length; i++) {
      result |= a[i] ^ b[i];
    }
    if (result !== 0) {
      return { valid: false, error: 'INVALID_SIGNATURE', message: 'La firma no coincide' };
    }

    return { valid: true, timestamp: parseInt(timestamp) };
  } catch (error) {
    return { valid: false, error: 'VERIFICATION_ERROR', message: error.message };
  }
}

::

:: tab Hono

javascript
async function verifyWebhookWithErrorHandling(c, secret) {
  const signatureHeader = c.req.header('zeltapay-signature');

  if (!signatureHeader) {
    return { valid: false, error: 'MISSING_HEADER', message: 'Header Zeltapay-Signature no encontrado' };
  }

  if (!signatureHeader.includes('t=') || !signatureHeader.includes('v1=')) {
    return { valid: false, error: 'INVALID_FORMAT', message: 'Formato de firma inválido' };
  }

  try {
    const parts = signatureHeader.split(', ');
    const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
    const signature = parts.find(p => p.startsWith('v1=')).slice(3);

    // Verificar timestamp
    const currentTime = Math.floor(Date.now() / 1000);
    const age = currentTime - parseInt(timestamp);
    if (age > 300) {
      return { valid: false, error: 'EXPIRED', message: `Webhook expirado (${age}s de antigüedad)` };
    }
    if (age < 0) {
      return { valid: false, error: 'FUTURE_TIMESTAMP', message: 'Timestamp en el futuro' };
    }

    // Verificar firma con Web Crypto API
    const payload = await c.req.text();
    const encoder = new TextEncoder();
    const key = await crypto.subtle.importKey(
      'raw',
      encoder.encode(secret),
      { name: 'HMAC', hash: 'SHA-256' },
      false,
      ['sign']
    );
    const signatureBuffer = await crypto.subtle.sign(
      'HMAC',
      key,
      encoder.encode(`${timestamp}.${payload}`)
    );
    const expectedSignature = Array.from(new Uint8Array(signatureBuffer))
      .map(b => b.toString(16).padStart(2, '0'))
      .join('');

    if (signature.length !== expectedSignature.length) {
      return { valid: false, error: 'INVALID_SIGNATURE', message: 'La firma no coincide' };
    }
    const a = encoder.encode(signature);
    const b = encoder.encode(expectedSignature);
    let result = 0;
    for (let i = 0; i < a.length; i++) {
      result |= a[i] ^ b[i];
    }
    if (result !== 0) {
      return { valid: false, error: 'INVALID_SIGNATURE', message: 'La firma no coincide' };
    }

    return { valid: true, timestamp: parseInt(timestamp) };
  } catch (error) {
    return { valid: false, error: 'VERIFICATION_ERROR', message: error.message };
  }
}

::

:: tab Python

python
def verify_webhook_with_error_handling(request, secret):
    signature_header = request.headers.get('Zeltapay-Signature')

    if not signature_header:
        return {'valid': False, 'error': 'MISSING_HEADER', 'message': 'Header Zeltapay-Signature no encontrado'}

    if 't=' not in signature_header or 'v1=' not in signature_header:
        return {'valid': False, 'error': 'INVALID_FORMAT', 'message': 'Formato de firma inválido'}

    try:
        parts = signature_header.split(', ')
        timestamp = None
        signature = None
        for part in parts:
            if part.startswith('t='):
                timestamp = part[2:]
            elif part.startswith('v1='):
                signature = part[3:]

        # Verificar timestamp
        current_time = int(time.time())
        age = current_time - int(timestamp)
        if age > 300:
            return {'valid': False, 'error': 'EXPIRED', 'message': f'Webhook expirado ({age}s de antigüedad)'}
        if age < 0:
            return {'valid': False, 'error': 'FUTURE_TIMESTAMP', 'message': 'Timestamp en el futuro'}

        # Verificar firma
        payload = request.get_data(as_text=True)
        payload_to_sign = f"{timestamp}.{payload}"
        expected_signature = hmac.new(
            secret.encode('utf-8'),
            payload_to_sign.encode('utf-8'),
            hashlib.sha256
        ).hexdigest()

        if not hmac.compare_digest(signature, expected_signature):
            return {'valid': False, 'error': 'INVALID_SIGNATURE', 'message': 'La firma no coincide'}

        return {'valid': True, 'timestamp': int(timestamp)}
    except Exception as e:
        return {'valid': False, 'error': 'VERIFICATION_ERROR', 'message': str(e)}

::

Probar la verificación

Probar con un ping de webhook

La forma más sencilla de probar tu implementación es enviar un ping desde el dashboard de Zelta Pay:

  1. Ve a Configuración > Webhooks en tu dashboard.
  2. Selecciona el endpoint que quieres probar.
  3. Haz clic en Enviar ping de prueba.
  4. Verifica en los logs de tu servidor que el evento se recibió y la firma se verificó correctamente.

Scripts de prueba manual

:: tab Node.js

javascript
const crypto = require('crypto');

// Simular un webhook firmado
const secret = 'whsec_test_secret';
const payload = JSON.stringify({
  id: 'evt_test_123',
  type: 'payment.completed',
  data: { amount: 5000, currency: 'USD' }
});

const timestamp = Math.floor(Date.now() / 1000).toString();
const signature = crypto
  .createHmac('sha256', secret)
  .update(`${timestamp}.${payload}`)
  .digest('hex');

const signatureHeader = `t=${timestamp}, v1=${signature}`;

console.log('Header:', signatureHeader);
console.log('Payload:', payload);

// Verificar
try {
  verifyWebhookSignature(payload, signatureHeader, secret);
  console.log('Verificación exitosa');
} catch (error) {
  console.error('Verificación fallida:', error.message);
}

::

:: tab Cloudflare Workers

javascript
// Script de prueba para ejecutar localmente con wrangler
const secret = 'whsec_test_secret';
const payload = JSON.stringify({
  id: 'evt_test_123',
  type: 'payment.completed',
  data: { amount: 5000, currency: 'USD' }
});

const timestamp = Math.floor(Date.now() / 1000).toString();

async function generateTestSignature() {
  const encoder = new TextEncoder();
  const key = await crypto.subtle.importKey(
    'raw',
    encoder.encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign']
  );
  const signatureBuffer = await crypto.subtle.sign(
    'HMAC',
    key,
    encoder.encode(`${timestamp}.${payload}`)
  );
  const signature = Array.from(new Uint8Array(signatureBuffer))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');

  console.log(`Header: t=${timestamp}, v1=${signature}`);
  console.log(`Payload: ${payload}`);
}

generateTestSignature();

::

:: tab Hono

javascript
// Script de prueba para ejecutar con wrangler o bun
const secret = 'whsec_test_secret';
const payload = JSON.stringify({
  id: 'evt_test_123',
  type: 'payment.completed',
  data: { amount: 5000, currency: 'USD' }
});

const timestamp = Math.floor(Date.now() / 1000).toString();

async function generateTestSignature() {
  const encoder = new TextEncoder();
  const key = await crypto.subtle.importKey(
    'raw',
    encoder.encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign']
  );
  const signatureBuffer = await crypto.subtle.sign(
    'HMAC',
    key,
    encoder.encode(`${timestamp}.${payload}`)
  );
  const signature = Array.from(new Uint8Array(signatureBuffer))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');

  console.log(`Header: t=${timestamp}, v1=${signature}`);
  console.log(`Payload: ${payload}`);
}

generateTestSignature();

::

:: tab Python

python
import hmac
import hashlib
import time
import json

# Simular un webhook firmado
secret = 'whsec_test_secret'
payload = json.dumps({
    'id': 'evt_test_123',
    'type': 'payment.completed',
    'data': {'amount': 5000, 'currency': 'USD'}
})

timestamp = str(int(time.time()))
signature = hmac.new(
    secret.encode('utf-8'),
    f"{timestamp}.{payload}".encode('utf-8'),
    hashlib.sha256
).hexdigest()

signature_header = f"t={timestamp}, v1={signature}"

print(f"Header: {signature_header}")
print(f"Payload: {payload}")

# Verificar
try:
    verify_webhook_signature(payload, signature_header, secret)
    print("Verificación exitosa")
except ValueError as e:
    print(f"Verificación fallida: {e}")

::

Buenas prácticas de seguridad

Usa comparación de tiempo constante

Siempre usa funciones de comparación de tiempo constante (timingSafeEqual, hmac.compare_digest, hash_equals) en lugar de === o ==. Las comparaciones directas pueden filtrar información sobre la firma a través de diferencias en el tiempo de respuesta.

Valida el formato de entrada

Antes de procesar el header de firma, verifica que tenga el formato esperado. Un header malformado podría causar errores inesperados en tu aplicación.

Maneja casos extremos

  • Body vacío — Rechaza webhooks sin body.
  • Header duplicado — Usa solo la primera ocurrencia del header.
  • Encoding — Asegúrate de usar el body raw (sin parsear) para la verificación.

WARNING

No parsees el JSON del body antes de verificar la firma. La verificación debe hacerse sobre el string raw del body exactamente como llegó. Cualquier diferencia en formato, espacios o encoding causará que la firma no coincida.

Registra eventos de seguridad

Registra los intentos fallidos de verificación para detectar posibles ataques:

javascript
function logSecurityEvent(event) {
  console.log(JSON.stringify({
    type: 'webhook_security',
    event: event.type,
    ip: event.ip,
    timestamp: new Date().toISOString(),
    error: event.error || null
  }));
}

Solución de problemas

Problemas comunes

La firma no coincide

  • Verifica el secreto — Asegúrate de usar el secreto de webhook correcto. Puedes encontrarlo en Configuración > Webhooks en tu dashboard.
  • Usa el body raw — La verificación debe hacerse sobre el body sin parsear. Si usas Express, asegúrate de usar express.raw() en lugar de express.json().
  • Revisa el encoding — El body debe ser tratado como UTF-8.

La validación de timestamp falla

  • Sincroniza el reloj — Asegúrate de que el reloj de tu servidor esté sincronizado con NTP.
  • Ajusta la tolerancia — Si tu servidor tiene latencia alta, considera aumentar la tolerancia de timestamp temporalmente.

El header no se encuentra

  • Revisa tu proxy — Algunos proxies y balanceadores de carga eliminan headers personalizados. Configúralos para pasar el header Zeltapay-Signature.
  • Verifica el nombre — El header es Zeltapay-Signature (sensible a mayúsculas en algunos frameworks).

Modo debug

Agrega un modo debug temporal para diagnosticar problemas de verificación:

:: tab Node.js

javascript
function debugVerification(payload, signatureHeader, secret) {
  console.log('=== Debug de verificación ===');
  console.log('Header recibido:', signatureHeader);
  console.log('Longitud del payload:', payload.length);
  console.log('Primeros 100 chars del payload:', payload.substring(0, 100));

  const parts = signatureHeader.split(', ');
  const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
  const receivedSignature = parts.find(p => p.startsWith('v1=')).slice(3);

  console.log('Timestamp:', timestamp);
  console.log('Firma recibida:', receivedSignature);

  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${payload}`)
    .digest('hex');

  console.log('Firma esperada:', expectedSignature);
  console.log('Coinciden:', receivedSignature === expectedSignature);
  console.log('============================');
}

::

:: tab Cloudflare Workers

javascript
async function debugVerification(payload, signatureHeader, secret) {
  console.log('=== Debug de verificación ===');
  console.log('Header recibido:', signatureHeader);
  console.log('Longitud del payload:', payload.length);
  console.log('Primeros 100 chars del payload:', payload.substring(0, 100));

  const parts = signatureHeader.split(', ');
  const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
  const receivedSignature = parts.find(p => p.startsWith('v1=')).slice(3);

  console.log('Timestamp:', timestamp);
  console.log('Firma recibida:', receivedSignature);

  const encoder = new TextEncoder();
  const key = await crypto.subtle.importKey(
    'raw',
    encoder.encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign']
  );
  const signatureBuffer = await crypto.subtle.sign(
    'HMAC',
    key,
    encoder.encode(`${timestamp}.${payload}`)
  );
  const expectedSignature = Array.from(new Uint8Array(signatureBuffer))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');

  console.log('Firma esperada:', expectedSignature);
  console.log('Coinciden:', receivedSignature === expectedSignature);
  console.log('============================');
}

::

:: tab Hono

javascript
async function debugVerification(payload, signatureHeader, secret) {
  console.log('=== Debug de verificación ===');
  console.log('Header recibido:', signatureHeader);
  console.log('Longitud del payload:', payload.length);
  console.log('Primeros 100 chars del payload:', payload.substring(0, 100));

  const parts = signatureHeader.split(', ');
  const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
  const receivedSignature = parts.find(p => p.startsWith('v1=')).slice(3);

  console.log('Timestamp:', timestamp);
  console.log('Firma recibida:', receivedSignature);

  const encoder = new TextEncoder();
  const key = await crypto.subtle.importKey(
    'raw',
    encoder.encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign']
  );
  const signatureBuffer = await crypto.subtle.sign(
    'HMAC',
    key,
    encoder.encode(`${timestamp}.${payload}`)
  );
  const expectedSignature = Array.from(new Uint8Array(signatureBuffer))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');

  console.log('Firma esperada:', expectedSignature);
  console.log('Coinciden:', receivedSignature === expectedSignature);
  console.log('============================');
}

::

DANGER

Elimina el modo debug antes de pasar a producción. Los logs de debug pueden exponer información sensible como firmas y payloads completos.

Siguientes pasos

  • — Aprende a configurar y recibir webhooks.
  • — Conoce cómo Zelta Pay entrega los webhooks y el sistema de reintentos.
  • — Implementa procesamiento idempotente de eventos.
  • — Consulta todos los tipos de eventos disponibles.
  • — Explora ejemplos prácticos de integración.

Documentación oficial de Zelta