Verificación de la firma del webhook
La notificación de pago llega a una dirección que solo conocen tu tienda y nuestro servicio. Pero la dirección puede filtrarse —desde los logs, la configuración o el historial de desarrollo. Para distinguir una notificación genuina de una falsificación, cada solicitud se firma con el secreto del webhook.
Verifica la firma antes de entregar el pedido o cambiar su estado.
Encabezados de la firma
| Encabezado | Valor |
|---|---|
X-Timestamp | Hora de envío, unix time en segundos |
X-Signature | El prefijo sha256= y el HMAC-SHA256 en hexadecimal |
La cadena firmada es la hora de envío, un punto y el cuerpo de la solicitud:
1756901234.{"wallet":{...},"project":{...},"invoice":{...},"payment":{...}}
La clave de firma es el campo Secreto del webhook (Webhook secret) en la configuración del proyecto. Ahí puedes volver a emitirlo si el valor se ve comprometido.
Pasos de la verificación
- Toma el cuerpo de la solicitud sin procesar, antes de analizar el JSON.
- Concatena el valor de
X-Timestamp, un punto y el cuerpo. - Calcula el HMAC-SHA256 con el secreto del webhook.
- Compara el resultado con
X-Signatureusando una comparación en tiempo constante. - Rechaza la solicitud si
X-Timestampse desvía demasiado de la hora actual.
Ejemplos de implementación
- PHP
- TypeScript
- JavaScript
- Python
<?php
$secret = 'webhook secret from your project settings';
$body = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
// Guards against a replayed request
if (abs(time() - (int)$timestamp) > 300) {
http_response_code(400);
exit;
}
$expected = 'sha256='.hash_hmac('sha256', $timestamp.'.'.$body, $secret);
// hash_equals compares in constant time
if (!hash_equals($expected, $signature)) {
http_response_code(403);
exit;
}
$data = json_decode($body, true);
// Handle the order
http_response_code(200);
import { createHmac, timingSafeEqual } from 'node:crypto';
import express, { type Request, type Response } from 'express';
const app = express();
const secret = 'webhook secret from your project settings';
// The body must stay raw, so express.raw instead of express.json
app.post('/webhook', express.raw({ type: 'application/json' }), (req: Request, res: Response) => {
const timestamp = req.get('X-Timestamp') ?? '';
const signature = req.get('X-Signature') ?? '';
const body = (req.body as Buffer).toString('utf8');
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.sendStatus(400);
}
const expected = 'sha256=' + createHmac('sha256', secret)
.update(`${timestamp}.${body}`, 'utf8')
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
// Check the length first: timingSafeEqual needs buffers of equal size
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.sendStatus(403);
}
const data = JSON.parse(body);
// Handle the order
res.sendStatus(200);
});
El mismo gestor sin tipos —para un proyecto en JavaScript puro.
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';
const app = express();
const secret = 'webhook secret from your project settings';
// The body must stay raw, so express.raw instead of express.json
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.get('X-Timestamp') ?? '';
const signature = req.get('X-Signature') ?? '';
const body = req.body.toString('utf8');
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.sendStatus(400);
}
const expected = 'sha256=' + createHmac('sha256', secret)
.update(`${timestamp}.${body}`, 'utf8')
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
// Check the length first: timingSafeEqual needs buffers of equal size
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.sendStatus(403);
}
const data = JSON.parse(body);
// Handle the order
res.sendStatus(200);
});
import hmac
import hashlib
import time
from flask import Flask, request
app = Flask(__name__)
secret = 'webhook secret from your project settings'
@app.post('/webhook')
def webhook():
timestamp = request.headers.get('X-Timestamp', '')
signature = request.headers.get('X-Signature', '')
body = request.get_data(as_text=True)
if abs(time.time() - int(timestamp or 0)) > 300:
return '', 400
expected = 'sha256=' + hmac.new(
secret.encode(),
f'{timestamp}.{body}'.encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, signature):
return '', 403
data = request.get_json()
# Handle the order
return '', 200
A tener en cuenta
Se firma el cuerpo sin procesar. Si analizas el JSON y lo reconstruyes, el orden de las claves y los espacios cambian —la firma no coincidirá. Calcula el HMAC antes de analizar.
Cada reintento tiene su propia firma. La hora de envío es nueva en cada reintento, así que la firma también difiere. No guardes una firma para compararla con solicitudes futuras.
El secreto puede volver a emitirse. El secreto antiguo deja de funcionar inmediatamente después de la reemisión, así que actualiza el valor en tu tienda al mismo tiempo.
La verificación de la firma no sustituye la idempotencia. La firma demuestra que la solicitud es auténtica, no que la veas por primera vez. Un reintento llega con una firma válida —comprueba contra invoice.id.