Webhooks

Cómo proteger los webhooks de pagos cripto

Qué protege el endpoint de un webhook de pagos: verificar la firma del webhook, manejo idempotente, códigos exactos, reintentos y registros. Basado en cómo funcionan realmente los webhooks de Qvard.

El webhook es el momento en que tu tienda decide que un pedido está pagado. Quien pueda llamar a ese endpoint con el cuerpo adecuado puede intentar obtener mercancía gratis, así que merece más cuidado que la mayoría de las rutas. Esta guía describe qué protege un webhook de pagos, según cómo funcionan hoy los webhooks de Qvard, incluida la cabecera X-Qvard-Signature, que permite comprobar que una notificación viene de Qvard.

Qué es un webhook de Qvard y qué no es

Cuando un pago se completa, Qvard envía una solicitud POST a la callback URL guardada en tu proyecto. El cuerpo es pequeño: el id que diste al crear el pago y el status del pago. El webhook se envía cuando el pago está completado, que es la señal para marcar el pedido como pagado.

La solicitud lleva una cabecera X-Qvard-Signature con el formato t=<unix>,v1=<hex>: un HMAC-SHA256 del timestamp y del cuerpo sin procesar, calculado con tu clave secreta. Dos valores v1 se envían durante 24 horas tras una rotación de clave, y cualquiera de los dos es válido. Los comercios con clave antigua (sin el prefijo qvard_sk_) reciben además la cabecera x-api-key; verifica la firma de todos modos. Verifica la firma sobre el cuerpo sin procesar, antes de analizar el JSON. Esta guía describe la verificación de la firma tal como la envía Qvard hoy.

Dos propiedades del diseño condicionan todo lo demás: tu endpoint es una URL pública, y el único secreto que lo protege es tu clave secreta, con la que se verifica la firma.

Amenazas que realmente importan

Una lista corta es más útil que una larga:

  • Una solicitud falsificada. Alguien que encuentra tu endpoint envía { "id": "1234", "status": "completed" } para que un pedido se marque como pagado. La verificación de la firma lo detiene.
  • Una clave filtrada. La misma clave firma el webhook y autentica tus llamadas a la API. Si aparece en un repositorio, un registro o código de front-end, ambos quedan expuestos.
  • Una entrega repetida. No es un ataque, pero es la causa más común de daño real: el mismo evento llega dos veces y el pedido se cumple dos veces.
  • Un endpoint lento o que falla. Las entregas perdidas dejan pedidos pagados sin cumplir.
  • Registros descuidados. Registrar la solicitud completa pone la clave en tus archivos de registro.

Cada una de las siguientes secciones cierra una de estas amenazas.

Comprueba la clave y compárala de forma segura

Lee la cabecera X-Qvard-Signature en cada solicitud y verifícala sobre el cuerpo sin procesar antes de hacer nada más. Importan dos detalles:

  • Comparación en tiempo constante. Un simple === puede filtrar información sobre cuántos caracteres iniciales coincidieron. Usa la función de tiempo constante de tu lenguaje (crypto.timingSafeEqual en Node.js, hash_equals en PHP, hmac.compare_digest en Python). Comprueba antes las longitudes si la función exige tamaños iguales.
  • Rechaza en silencio. Responde 400 sin explicaciones. No indiques si la firma faltaba o era incorrecta.

El ejemplo siguiente muestra el manejador completo: comprobación de la firma, un límite pequeño de cuerpo, validación de los campos, una actualización que solo cambia un pedido aún no pagado y una respuesta exactamente 200. Guarda la clave en una variable de entorno o en un gestor de secretos, nunca en el repositorio ni en código de front-end.

Node.jswebhook.js
import express from 'express';
import { Qvard } from '@qvard/node';

const secret = process.env.QVARD_SECRET_KEY;
const qvard = new Qvard(secret);
const app = express();

app.post('/webhooks/qvard', express.raw({ type: 'application/json', limit: '10kb' }), async (req, res) => {
  let event;
  try {
    event = qvard.webhooks.constructEvent(req.body, req.get('X-Qvard-Signature'), secret);
  } catch {
    return res.sendStatus(400);
  }

  const { id, status } = event;
  if (typeof id !== 'string' || status !== 'completed') return res.sendStatus(200);

  // Idempotent: the update only matches an order that is not paid yet.
  const changed = await db.orders.markPaidIfNot(id);
  if (changed) queue.push({ type: 'fulfil', orderId: id }); // slow work happens after the answer

  return res.sendStatus(200); // exactly 200
});

Haz que el manejador sea idempotente

Qvard considera una entrega exitosa solo cuando tu endpoint responde exactamente HTTP 200. Cualquier otro estado, un tiempo de espera agotado o una redirección cuenta como fallo y se reintenta: cinco intentos en total durante unos seis minutos. La consecuencia es que tu manejador verá a veces el mismo evento más de una vez, por ejemplo cuando tu servidor hizo el trabajo pero la respuesta se perdió.

Diseña para ello:

  • Haz condicional el cambio de estado: «marcar este pedido como pagado si aún no está pagado», idealmente en una sola sentencia de base de datos, para que dos entregas paralelas no ganen ambas.
  • Inicia el cumplimiento (correos, claves, accesos) solo cuando la actualización haya cambiado realmente una fila.
  • Ignora los eventos de pedidos que no conoces, pero responde igualmente 200; si no, Qvard sigue reintentando algo que nunca vas a gestionar.
  • Actúa solo con completed. Ningún otro estado debe marcar un pedido como pagado.

Esta es también la defensa contra una solicitud repetida: un evento válido repetido no cambia nada la segunda vez.

Responde rápido, trabaja después

Tu endpoint debe responder antes de que Qvard se rinda, así que hazlo corto: comprobar, actualizar, encolar, responder. Las partes lentas, como enviar correos, llamar a otros sistemas y generar archivos, hazlas desde una cola después de responder.

Piensa también en lo que tu endpoint no sabe. El cuerpo lleva solo el id y el status, así que el importe esperado sale de tu propio pedido, no del webhook. Nunca tomes un importe, una moneda o un producto del cuerpo de la solicitud.

Sirve el endpoint solo por HTTPS y asegúrate de que ningún proxy ni redirección esté delante: las redirecciones no se siguen, por lo que un 301 de http a https es una entrega fallida.

Registros, monitorización y recuperación

Trata los webhooks como movimientos de dinero y deja constancia.

  • Registra lo que decidiste, no el secreto. Guarda el id, el status, la hora y tu decisión (aplicado, duplicado, pedido desconocido, clave rechazada). Nunca registres tu clave secreta.
  • Alerta sobre rechazos. Una racha repentina de respuestas 400 significa que alguien está sondeando el endpoint o que rotaste tu clave en un sitio y no en todos.
  • Concilia. Una vez al día, compara los pedidos que consideras pagados con tu saldo y los pagos en el panel de Qvard.
  • Recupera. Si tu servidor estuvo caído más que la ventana de reintentos, reenvía la notificación de ese pago desde el panel y deja que tu manejador idempotente se encargue.

Si sospechas que la clave se ha filtrado, sustitúyela y revisa qué sistemas tuyos la usaban.

Lista de comprobación antes de salir en vivo

  • El endpoint usa HTTPS, es público y accesible desde fuera de tu red.
  • Verifica la firma X-Qvard-Signature sobre el cuerpo sin procesar y responde 400 en caso contrario.
  • Limita el tamaño del cuerpo y valida id y status.
  • Marca un pedido como pagado solo con completed y solo si aún no está pagado.
  • Responde exactamente 200 con rapidez y hace el trabajo lento después.
  • Nunca se fía de importes ni productos del cuerpo.
  • Nunca registra la clave.
  • Has probado una respuesta fallida, una entrega duplicada y un pedido desconocido.

Para el flujo completo alrededor del webhook, consulta cómo aceptar pagos cripto en un sitio web. Los formatos de solicitud y respuesta están en la documentación para desarrolladores.

FAQ

¿Firma Qvard los webhooks con HMAC?

Sí. Cada webhook lleva la cabecera X-Qvard-Signature (t=<unix>,v1=<hex>): un HMAC-SHA256 del timestamp y del cuerpo sin procesar, calculado con tu clave secreta. Verifícala sobre el cuerpo sin procesar antes de analizar el JSON; el SDK lo hace con constructEvent y trata tu clave como el secreto.

¿Qué código de estado debe devolver mi endpoint?

Exactamente 200. Cualquier otro estado, un tiempo de espera agotado o una redirección cuenta como entrega fallida y se reintenta, cinco intentos en total durante unos seis minutos.

¿Y si mi servidor estaba caído cuando se completó el pago?

Los reintentos cubren unos seis minutos. Después, reenvía la notificación del pago desde el panel. Un manejador idempotente lo hace seguro.

¿Puedo fiarme del importe del webhook?

El webhook lleva solo el id del pago y su estado. Usa el importe de tu propio pedido y márcalo como pagado solo con el estado completed.

Artículos relacionados

Artículos relacionados