Descargo de responsabilidad: Este artículo es material educativo de ingeniería de software aplicada a integraciones financieras. Las implementaciones de webhooks aquí descritas deben adaptarse a la documentación oficial vigente de cada proveedor de pagos. La seguridad de los endpoints depende de la correcta gestión de secretos y configuración del servidor. El uso de este contenido es bajo exclusiva responsabilidad del desarrollador u operador del sistema.
1. Por Qué los Webhooks Son Críticos en Plataformas de Pago
Los webhooks son el mecanismo de notificación asíncrona que utilizan Stripe, PayPal, Mercado Pago y demás pasarelas para informar eventos transaccionales: pagos confirmados, suscripciones renovadas, chargebacks iniciados o reembolsos procesados. A diferencia de las respuestas síncronas de API, los webhooks llegan fuera del flujo de petición original, lo que introduce desafíos únicos de seguridad, confiabilidad y consistencia de datos.
El error más frecuente en plataformas B2B LATAM es tratar los webhooks como peticiones HTTP normales: procesar la lógica de negocio directamente en el handler del endpoint, sin verificación criptográfica ni desacoplamiento temporal. Esto genera tres problemas críticos: vulnerabilidad a webhooks falsificados, timeouts cuando la lógica de negocio tarda más que el timeout del proveedor (típicamente 5-10 segundos), y pérdida silenciosa de eventos durante picos de carga o fallos temporales.
La arquitectura correcta separa recepción inmediata (validar firma + almacenar evento crudo en <100ms) de procesamiento diferido (worker asíncrono que ejecuta lógica de negocio con reintentos automáticos). Esta guía implementa ambos componentes en PHP 8.4 con tipado estricto y compatibilidad con entornos Nginx + MariaDB.
2. Verificación HMAC-SHA256: Primera Línea de Defensa
Cualquier atacante puede enviar peticiones POST falsas a tu endpoint /webhook/payments. Sin verificación criptográfica, aceptarás eventos fraudulentos que activen suscripciones gratuitas, confirmen pagos inexistentes o cancelen cuentas premium. Todas las pasarelas serias firman los webhooks con HMAC-SHA256 usando un secreto compartido.
La verificación debe cumplir tres requisitos obligatorios:
- Usar payload crudo: Calcular HMAC sobre el body exacto recibido (
file_get_contents('php://input')), nunca sobre datos parseados o normalizados. Cualquier transformación invalida la firma. - Comparación de tiempo constante: Usar siempre
hash_equals()para comparar firmas. El operador===es vulnerable a timing attacks que permiten forjar firmas byte por byte. - Fallar rápido y silencioso: Retornar HTTP 403 sin cuerpo descriptivo si la firma no coincide. Mensajes de error detallados ayudan al atacante a depurar su exploit.
<?php
declare(strict_types=1);
namespace App\Webhooks\Security;
use RuntimeException;
class WebhookSignatureValidator
{
private string $secret;
private string $algorithm;
public function __construct(string $secret, string $algorithm = 'sha256')
{
if (strlen($secret) < 16) {
throw new RuntimeException("Webhook secret demasiado corto (mínimo 16 bytes).");
}
$this->secret = $secret;
$this->algorithm = $algorithm;
}
/**
* Valida firma HMAC con protección contra timing attacks
*/
public function validate(string $rawPayload, string $providedSignature, string $headerName = 'X-Webhook-Signature'): bool
{
// Recalcular firma esperada sobre payload CRUDO
$expectedSignature = hash_hmac($this->algorithm, $rawPayload, $this->secret);
// Comparación de tiempo constante OBLIGATORIA
// hash_equals previene timing attacks al comparar en tiempo fijo
return hash_equals($expectedSignature, $providedSignature);
}
/**
* Extrae y valida firma desde headers HTTP
*/
public function validateFromRequest(string $rawPayload, string $headerName = 'HTTP_X_WEBHOOK_SIGNATURE'): array
{
$providedSignature = $_SERVER[$headerName] ?? '';
if (empty($providedSignature)) {
return ['valid' => false, 'reason' => 'missing_signature_header'];
}
// Algunos proveedores incluyen prefijo "sha256=" o timestamp
// Normalizar según documentación específica del proveedor
$cleanSignature = preg_replace('/^(sha256|v1)=/', '', $providedSignature);
$isValid = $this->validate($rawPayload, $cleanSignature);
return [
'valid' => $isValid,
'reason' => $isValid ? null : 'signature_mismatch'
];
}
}
| Proveedor | Header de Firma | Formato | Algoritmo |
|---|---|---|---|
| Stripe | Stripe-Signature | t={timestamp},v1={signature} | HMAC-SHA256 |
| PayPal | PAYPAL-TRANSMISSION-SIG | JWT RS256 (verificación distinta) | RSA-SHA256 |
| Mercado Pago | X-Signature | HMAC directo | HMAC-SHA256 |
| Kushki | X-Kushki-Signature | HMAC directo | HMAC-SHA256 |
| dLocal | X-dLocal-Signature | HMAC con timestamp | HMAC-SHA256 |
3. Endpoint de Recepción: Respuesta Inmediata <100ms
El endpoint webhook debe responder HTTP 200 en menos de 100ms para evitar que el proveedor marque el evento como fallido y lo reintente prematuramente. Toda lógica de negocio pesada (actualizar BD, enviar emails, generar facturas) debe delegarse a workers asíncronos.
<?php
// Endpoint: POST /webhook/payments
// Objetivo: Validar + Almacenar + Responder en <100ms
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use App\Webhooks\Security\WebhookSignatureValidator;
// 1. Leer payload CRUDO antes de cualquier parsing
$rawBody = file_get_contents('php://input');
if (empty($rawBody)) {
http_response_code(400);
exit;
}
// 2. Validar firma inmediatamente
$validator = new WebhookSignatureValidator(
getenv('PAYMENT_WEBHOOK_SECRET') ?: ''
);
$validation = $validator->validateFromRequest($rawBody);
if (!$validation['valid']) {
// Loguear intento fallido para auditoría forense
error_log(sprintf(
"[WEBHOOK_REJECTED] %s | IP: %s | Reason: %s",
date('c'),
$_SERVER['REMOTE_ADDR'],
$validation['reason']
));
http_response_code(403);
exit; // Sin cuerpo descriptivo
}
// 3. Parsear evento DESPUÉS de validación exitosa
$event = json_decode($rawBody, true);
if (json_last_error() !== JSON_ERROR_NONE) {
http_response_code(400);
exit;
}
// 4. Almacenar evento crudo en tabla de auditoría (inserción rápida)
// Usar PDO con prepared statements para prevenir SQLi
$pdo = new PDO(
getenv('DATABASE_DSN') ?: '',
getenv('DATABASE_USER') ?: '',
getenv('DATABASE_PASS') ?: '',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$stmt = $pdo->prepare(
"INSERT INTO webhook_events
(provider, event_type, event_id, raw_payload, received_at, status)
VALUES (?, ?, ?, ?, NOW(), 'PENDING')"
);
$stmt->execute([
$event['provider'] ?? 'unknown',
$event['type'] ?? 'unknown',
$event['id'] ?? uniqid('evt_', true),
$rawBody
]);
// 5. Responder INMEDIATAMENTE (sin procesar lógica de negocio)
http_response_code(200);
header('Content-Type: application/json');
echo json_encode(['status' => 'received']);
exit;
4. Procesamiento Asíncrono: Workers y Colas
Tras almacenar el evento con estado PENDING, un worker independiente lee registros pendientes y ejecuta la lógica de negocio correspondiente. Este desacoplamiento garantiza que fallos en procesamiento no afecten la recepción de nuevos eventos.
Opciones de implementación según escala:
- Escala baja (<100 eventos/hora): Cron job cada minuto que consulta tabla
webhook_events WHERE status='PENDING'. Simple pero latencia hasta 60 segundos. - Escala media (100-10K eventos/hora): Redis List/RabbitMQ como cola. Worker PHP permanente que consume mensajes en tiempo real. Latencia <1 segundo.
- Escala alta (>10K eventos/hora): Kafka/SQS + múltiples workers escalados horizontalmente. Procesamiento paralelo con particionamiento por merchant_id.
<?php
// Worker simplificado para procesamiento de webhooks pendientes
// Ejecutar vía cron cada minuto o como daemon permanente
declare(strict_types=1);
$pdo = new PDO(
getenv('DATABASE_DSN') ?: '',
getenv('DATABASE_USER') ?: '',
getenv('DATABASE_PASS') ?: '',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
// Obtener lote de eventos pendientes (limitar para evitar bloqueos largos)
$stmt = $pdo->query(
"SELECT id, event_type, raw_payload, retry_count
FROM webhook_events
WHERE status = 'PENDING'
ORDER BY received_at ASC
LIMIT 50"
);
$events = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($events as $event) {
try {
$payload = json_decode($event['raw_payload'], true);
// Router de handlers según tipo de evento
match ($event['event_type']) {
'payment.confirmed' => handlePaymentConfirmed($payload),
'subscription.renewed' => handleSubscriptionRenewed($payload),
'chargeback.created' => handleChargebackCreated($payload),
default => throw new RuntimeException("Evento no soportado: {$event['event_type']}")
};
// Marcar como procesado exitosamente
$update = $pdo->prepare(
"UPDATE webhook_events SET status = 'PROCESSED', processed_at = NOW() WHERE id = ?"
);
$update->execute([$event['id']]);
} catch (Throwable $e) {
// Incrementar contador de reintentos
$retryCount = (int) $event['retry_count'] + 1;
if ($retryCount >= 5) {
// Máximo de reintentos alcanzado: marcar como fallido para revisión manual
$update = $pdo->prepare(
"UPDATE webhook_events SET status = 'FAILED', error_message = ?, retry_count = ? WHERE id = ?"
);
$update->execute([$e->getMessage(), $retryCount, $event['id']]);
// Alertar equipo operativo
error_log("[WEBHOOK_FAILED] Event ID: {$event['id']} | Error: {$e->getMessage()}");
} else {
// Programar reintento con backoff exponencial
$update = $pdo->prepare(
"UPDATE webhook_events SET retry_count = ?, next_retry_at = DATE_ADD(NOW(), INTERVAL ? MINUTE) WHERE id = ?"
);
$backoffMinutes = pow(2, $retryCount); // 2, 4, 8, 16 minutos
$update->execute([$retryCount, $backoffMinutes, $event['id']]);
}
}
}
5. Idempotencia: Prevención de Procesamiento Duplicado
Los proveedores de pago reenvían webhooks cuando no reciben respuesta 200 dentro del timeout. Sin protección de idempotencia, el mismo evento se procesará múltiples veces, generando cobros duplicados, emails repetidos o estados inconsistentes.
La estrategia correcta combina dos mecanismos:
- Deduplicación por event_id: Antes de procesar, verificar si ya existe registro con mismo
event_idy estadoPROCESSED. Si existe, saltar procesamiento. - Claves de idempotencia en operaciones downstream: Al llamar a APIs externas (actualizar suscripción, generar factura), incluir el
event_idcomo clave de idempotencia. Si la operación ya se ejecutó, la API retorna resultado cacheado sin repetir acción.
Este doble mecanismo protege tanto contra reenvíos del proveedor como contra reintentos internos del worker tras fallos transitorios.
6. Auditoría Inmutable y Monitoreo
En entornos financieros regulados, la trazabilidad de cada evento de pago es requisito legal. La tabla webhook_events debe ser inmutable: nunca actualizar ni eliminar registros, solo insertar nuevos estados.
Métricas críticas a monitorear:
7. Conclusión y Referencias Oficiales
Los webhooks seguros son la columna vertebral de la reconciliación financiera en plataformas B2B. Su implementación correcta requiere disciplina en tres frentes: verificación criptográfica rigurosa, procesamiento asíncrono desacoplado y auditoría inmutable. Negligir cualquiera de estos pilares resulta en pérdida directa de ingresos o exposición a fraude sistémico.
Documentación oficial de proveedores:
- Stripe Webhook Signatures: Verificación HMAC y tolerancia a replay.
- PayPal Webhook Verification: Validación JWT RS256.
- Mercado Pago Webhooks: Configuración y seguridad.
- OWASP Web Service Security: Mejores prácticas generales.
Nota técnica: Este artículo utiliza marcado Schema.org Article coexistente con el nodo FinancialService corporativo. Los metadatos semánticos se indexan automáticamente vía The SEO Framework.