Cómo verificar un webhook de Qvard
Una guía rápida sobre el encabezado X-Qvard-Signature: qué es y cómo verificarlo con unas pocas líneas de código.
Qué es una firma
Cuando el estado de un pago cambia, Qvard envía una notificación (webhook) a la dirección en tu configuración de tienda. Cualquiera puede enviar una solicitud a esa dirección, por lo que necesitas una forma de distinguir una notificación real de una falsa.
La firma es esa prueba: un código breve que Qvard calcula a partir del mensaje y tu clave secreta. Solo Qvard y tú conocen la clave, por lo que solo Qvard puede producir el código correcto.
Qué recibes
Cada webhook lleva un encabezado extra y un pequeño cuerpo JSON:
X-Qvard-Signature: t=1760950000,v1=3f2a…c91e
{"id":"order_1024","status":"paid"}t es la hora en que se envió el mensaje (Unix, segundos). v1 es el código. Durante 24 horas después de rotar tu clave secreta, el encabezado lleva dos valores v1: uno para la clave nueva y uno para la antigua.
Los campos id y status se mantienen como antes. Al mismo cuerpo firmado se añaden cuatro campos de importe:
- amount y currency: el importe y su moneda, tal como los enviaste en la solicitud.
- amountUsd: el importe contable en USD.
- rate: unidades de moneda por 1 USD en el momento de crear el pago.
- Las integraciones anteriores siguen funcionando: leen id y status e ignoran los campos nuevos. La firma se verifica sobre el mismo cuerpo que antes.
Cómo verificarlo
- 1Toma el cuerpo de la solicitud exactamente como llegó: el texto bruto, antes de cualquier análisis JSON.
- 2Lee t y cada v1 del encabezado X-Qvard-Signature.
- 3Calcula HMAC-SHA256 del texto t + "." + cuerpo con tu clave secreta (qvard_sk_…) y escríbelo en hexadecimal minúsculo.
- 4Si tu resultado coincide con cualquier v1 (compara en tiempo constante), el mensaje es genuino.
- 5Verifica que t no sea mayor a 5 minutos, para que un mensaje antiguo no pueda ser reproducido.
- 6Solo entonces usa los datos y confirma el monto pagado para ese pedido en tu propia base de datos.
Código listo
const crypto = require('crypto'); function verifyQvardSignature(rawBody, header, secret, toleranceSec = 300) { const parts = header.split(',').map((p) => p.trim()); const t = Number((parts.find((p) => p.startsWith('t=')) || '').slice(2)); const sigs = parts.filter((p) => p.startsWith('v1=')).map((p) => p.slice(3)); if (!t || sigs.length === 0) return false; if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest(); return sigs.some((s) => { const got = Buffer.from(s, 'hex'); return got.length === expected.length && crypto.timingSafeEqual(got, expected); }); } // Use the RAW body: express.raw, not express.json app.post('/qvard-webhook', express.raw({ type: 'application/json' }), (req, res) => { const raw = req.body.toString('utf8'); if (!verifyQvardSignature(raw, req.get('X-Qvard-Signature') || '', process.env.QVARD_SECRET_KEY)) { return res.sendStatus(400); } const { id, status } = JSON.parse(raw); // Confirm the paid amount for order `id` in your own database before fulfilling it. res.sendStatus(200); });
<?php function verifyQvardSignature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool { $t = 0; $sigs = []; foreach (explode(',', $header) as $part) { $part = trim($part); if (strpos($part, 't=') === 0) { $t = (int) substr($part, 2); } elseif (strpos($part, 'v1=') === 0) { $sigs[] = substr($part, 3); } } if (!$t || !$sigs || abs(time() - $t) > $tolerance) { return false; } $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret); foreach ($sigs as $s) { if (hash_equals($expected, $s)) { return true; } } return false; } $raw = file_get_contents('php://input'); if (!verifyQvardSignature($raw, $_SERVER['HTTP_X_QVARD_SIGNATURE'] ?? '', getenv('QVARD_SECRET_KEY'))) { http_response_code(400); exit; } $event = json_decode($raw, true); // ['id' => ..., 'status' => ...] http_response_code(200); ?>
Durante 24 horas después de rotar tu clave secreta, el encabezado lleva dos valores v1: acepta el webhook si alguno coincide.
Errores comunes
- Analizar el JSON y serializarlo nuevamente cambia el texto. Siempre usa el cuerpo bruto.
- Usar la clave pública (qvard_pk_…) en lugar de la clave secreta.
- Un reloj del servidor desincronizado hace fallar la verificación de 5 minutos. Mantenlo sincronizado.
- Comparar con == en lugar de una comparación en tiempo constante.
Dónde encontrar tu clave secreta
En el panel: tu tienda, Configuración, Claves API. Guárdalo solo en tu servidor. Si se filtra, rótalo allí: la clave antigua sigue funcionando durante 24 horas para pagos (no para retiros).
Claves antiguas
Si tu tienda aún tiene una clave de formato antiguo (sin el prefijo qvard_sk_), los webhooks también llevan un encabezado x-api-key con esa clave. Después de rotarla, ese encabezado desaparece. Cambia a verificar la firma.