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>| Header | Descripción |
|---|---|
Content-Type | Siempre application/json |
x-qvapay-signature | Solo 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-timestamp | Unix 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 sinx-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
| Campo | Tipo | Descripción |
|---|---|---|
uuid | string | UUID de la transacción (mismo que recibiste en transaction_uuid al crear el TOPUP) |
status | string | Siempre "paid" en esta versión del webhook |
is_partial | boolean | true si el usuario envió menos cripto del solicitado (el USD acreditado fue menor al solicitado) |
amount | string | USD efectivamente acreditado a la cuenta del usuario en QvaPay (después de fee, capeado al requested_usd). Formato decimal con 2 dígitos. |
requested_usd | number | USD originalmente solicitado al crear el TOPUP |
received_crypto | string | Cantidad de cripto recibida en la billetera (string para preservar precisión) |
coin | string | Tick de la moneda (ej. BTC) |
tx_id | string | null | Hash de la transacción en blockchain |
paid_at | string | Timestamp 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 usarexpress.raw({ type: 'application/json' }). En Next.js Route Handlers, usaawait req.text()antes deJSON.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 endpoint | Acción de QvaPay |
|---|---|
2xx | Éxito — no reintenta |
3xx | No reintenta (debes responder 2xx, no redirigir) |
4xx (excepto 429) | No reintenta — error del cliente |
429 | Reintenta |
5xx | Reintenta |
| Timeout / error de red | Reintenta |
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:
- Usa
uuidcomo llave única en tu base de datos. - Si ya procesaste ese
uuid, responde200sin 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_msrequest_bodycon flag_attempt: 1|2|3response_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-timestamppara 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.