Feed del mercado (webhooks)
Recibe cada oferta y cada cambio de estado del P2P en cuanto ocurre, firmado y con reintentos.
Si tu integración pregunta cada pocos segundos si el mercado cambió, el feed es la respuesta: te avisamos nosotros. Un evento por cada oferta publicada, aplicada, pagada, completada o cancelada.
Se contrata por app desde qvapay.com/dev, en la sección Feed del mercado P2P. $10 por 30 días, cobrados del saldo de la cuenta dueña de la app.
Con la misma suscripción tienes también el stream en vivo, que es la alternativa si prefieres no montar un endpoint público.
Eventos
| Evento | Cuándo |
|---|---|
p2p.created | Se publica una oferta |
p2p.applied | Alguien aplica como contraparte |
p2p.paid | El comprador marca el pago |
p2p.completed | Se libera y la operación termina |
p2p.cancelled | La oferta se cancela |
Por defecto llegan todos. Puedes quedarte con los que te interesen desde el panel.
El payload
{
"event": "p2p.created",
"sent_at": "2026-09-20T18:00:00Z",
"data": {
"uuid": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"type": "sell",
"coin": "USDT",
"amount": "100.00",
"receive": "39500.00",
"status": "open",
"updated_at": "2026-09-20T18:00:00Z"
}
}El campo event va siempre: no tienes que deducir el tipo por la forma del objeto.
Verificar la firma
Cada petición llega con x-qvapay-signature (HMAC-SHA256 del cuerpo en crudo) y x-qvapay-timestamp. El secreto está en el panel y es propio del feed: rotarlo no toca las credenciales de tu app.
import crypto from 'node:crypto'
const raw = await request.text() // el cuerpo EN CRUDO, sin parsear
const esperada = crypto.createHmac('sha256', process.env.QVAPAY_FEED_SECRET)
.update(raw)
.digest('hex')
const recibida = (request.headers.get('x-qvapay-signature') || '').replace('sha256=', '')
const valida = crypto.timingSafeEqual(Buffer.from(esperada), Buffer.from(recibida))
if (!valida) return new Response('firma inválida', { status: 401 })Verifica sobre el cuerpo en crudo. Si lo parseas y lo vuelves a serializar, la firma no coincidirá.
Rechaza también lo que llegue sin firma, y considera inválido un x-qvapay-timestamp de hace más de 5 minutos.
Reintentos
| Intento | Espera |
|---|---|
| 1 | inmediato |
| 2 | 30 s |
| 3 | 2 min |
| 4 | 10 min |
| 5 | 1 h |
Reintentamos ante error de red, 5xx y 429. No reintentamos ante otros 4xx: si tu endpoint responde 400 o 404 damos por hecho que entendió y rechazó, y machacarlo no lo arregla.
Responde 2xx en cuanto recibas, y procesa después. Si tardas más de 5 segundos cortamos y lo contamos como fallo.
Agotados los cinco intentos, la entrega queda en failed y ese evento no se reenvía.
Idempotencia
Un mismo evento puede llegarte más de una vez. Usa el par event + data.uuid como clave: si ya lo procesaste, ignóralo.
Depurar sin escribirnos
curl -X GET "https://api.qvapay.com/v2/p2p/feed/deliveries" \
-H "app-id: tu-app-id" -H "app-secret: tu-app-secret"Devuelve las últimas entregas con el código que devolvió tu endpoint y el error exacto. Casi siempre ahí está la respuesta.
Estado de tu suscripción
curl -X GET "https://api.qvapay.com/v2/p2p/feed" \
-H "app-id: tu-app-id" -H "app-secret: tu-app-secret"{
"data": { "active": true, "status": "active", "expires_at": "2026-10-20T18:00:00Z",
"auto_renew": true, "webhook_url": "https://...", "events": ["p2p.created"] },
"meta": { "price_usd": 10, "period_days": 30 }
}Si el cobro de la renovación falla por saldo, el estado pasa a past_due pero el feed sigue funcionando hasta la fecha de vencimiento: el periodo que ya pagaste no se corta. Recarga antes de esa fecha y se renueva solo.