QvaPay API
Merchants

TopUp Webhook

Notificación HTTP que QvaPay envía a tu URL cuando un depósito cripto se acredita.

Descripción

Cuando creas un TOPUP con POST /api/topup y proporcionas un webhook_url, QvaPay enviará un POST a esa URL en cuanto el proveedor cripto confirme el pago y QvaPay acredite el saldo del usuario. El webhook contiene la cantidad real recibida en cripto y el USD acreditado después de fees.

Request enviado por QvaPay

Method y headers

POST <tu webhook_url> HTTP/1.1
Content-Type: application/json
x-qvapay-signature: sha256=<hex>
x-qvapay-timestamp: <unix_seconds>
HeaderDescripción
Content-TypeSiempre application/json
x-qvapay-signatureSolo si el TOPUP se creó autenticado por app credentials. Formato sha256=<hex> con HMAC-SHA256 del raw body usando tu app-secret como llave.
x-qvapay-timestampUnix timestamp en segundos (UTC) del momento en que se firmó el body. Útil para mitigar replays.

Si el TOPUP se creó con bearer token de usuario (no con app-id/app-secret), el webhook llega sin x-qvapay-signature. Para integraciones merchant-to-merchant (ej. Numbay), autentica siempre con app credentials y rechaza webhooks sin firma.

Body

{
  "uuid": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "status": "paid",
  "is_partial": false,
  "amount": "100.00",
  "requested_usd": 100,
  "received_crypto": "0.00232558",
  "coin": "BTC",
  "tx_id": "0xabc123...blockchain_hash",
  "paid_at": "2026-04-30T14:32:18.421Z"
}

Campos del payload

CampoTipoDescripción
uuidstringUUID de la transacción (mismo que recibiste en transaction_uuid al crear el TOPUP)
statusstringSiempre "paid" en esta versión del webhook
is_partialbooleantrue si el usuario envió menos cripto del solicitado (el USD acreditado fue menor al solicitado)
amountstringUSD efectivamente acreditado a la cuenta del usuario en QvaPay (después de fee, capeado al requested_usd). Formato decimal con 2 dígitos.
requested_usdnumberUSD originalmente solicitado al crear el TOPUP
received_cryptostringCantidad de cripto recibida en la billetera (string para preservar precisión)
coinstringTick de la moneda (ej. BTC)
tx_idstring | nullHash de la transacción en blockchain
paid_atstringTimestamp ISO 8601 (UTC) del momento de acreditación

Verificar la firma HMAC

Calcula HMAC-SHA256 del raw body (string, antes de parsear JSON) usando tu app-secret como llave, y compáralo con el hex en x-qvapay-signature.

Ejemplo en Node.js

import crypto from 'crypto'

export function verifyQvaPayWebhook(rawBody, signatureHeader, appSecret) {
  if (!signatureHeader?.startsWith('sha256=')) return false
  const provided = signatureHeader.slice('sha256='.length)
  const expected = crypto.createHmac('sha256', appSecret).update(rawBody).digest('hex')
  // Comparación en tiempo constante para evitar timing attacks
  const a = Buffer.from(provided, 'hex')
  const b = Buffer.from(expected, 'hex')
  if (a.length !== b.length) return false
  return crypto.timingSafeEqual(a, b)
}

Importante: usa el body crudo, no JSON.stringify(req.body) después de parsear. En Express puedes usar express.raw({ type: 'application/json' }). En Next.js Route Handlers, usa await req.text() antes de JSON.parse.

Ejemplo en PHP

function verifyQvaPayWebhook(string $rawBody, string $signatureHeader, string $appSecret): bool {
    if (!str_starts_with($signatureHeader, 'sha256=')) return false;
    $provided = substr($signatureHeader, 7);
    $expected = hash_hmac('sha256', $rawBody, $appSecret);
    return hash_equals($expected, $provided);
}

Política de reintentos

QvaPay reintenta el webhook hasta 3 veces con backoff [0s, 5s, 30s]:

Status code de tu endpointAcción de QvaPay
2xxÉxito — no reintenta
3xxNo reintenta (debes responder 2xx, no redirigir)
4xx (excepto 429)No reintenta — error del cliente
429Reintenta
5xxReintenta
Timeout / error de redReintenta

Total máximo: ~35 segundos hasta el último intento. Si todos fallan, el webhook se pierde — debes consultar el estado vía API.

Idempotencia

Tu endpoint debe ser idempotente: el mismo uuid puede llegar 1–3 veces (por reintentos). Recomendación:

  1. Usa uuid como llave única en tu base de datos.
  2. Si ya procesaste ese uuid, responde 200 sin duplicar la acreditación.

Auditoría

Cada intento de webhook (incluyendo reintentos) se registra en la tabla app_logs de QvaPay con:

  • app_id, webhook_url, status_code, duration_ms
  • request_body con flag _attempt: 1|2|3
  • response_body (primeros 2000 chars de la respuesta de tu endpoint)

Puedes consultarlos vía GET /api/v2/app/logs.

Buenas prácticas

  • Rechaza webhooks sin firma si esperas siempre tenerla (TOPUP creado con app credentials).
  • Verifica x-qvapay-timestamp para rechazar requests muy antiguos (> 5 minutos) y mitigar replays.
  • Responde rápido (< 5s): valida y encola para procesamiento asíncrono. Si tardas más, riesgo de timeout y reintentos.
  • No confíes en el orden de llegada — sé idempotente.
  • Para subpagos (is_partial: true), decide tu política: aceptar el monto reducido, o devolver al cliente.