QvaPay API
P2P

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

EventoCuándo
p2p.createdSe publica una oferta
p2p.reopenedUna oferta tomada vuelve a estar disponible: el peer se retiró, se le venció la ventana de pago o fue expulsado. Mismo uuid, status open
p2p.appliedAlguien aplica como contraparte
p2p.paidEl comprador marca el pago
p2p.completedSe libera y la operación termina
p2p.cancelledLa oferta se cancela

Por defecto llegan todos. Puedes quedarte con los que te interesen desde el panel, en Eventos que recibes; la selección vale para el webhook y para el stream.

Para mantener una copia del libro de ofertas te bastan cuatro: p2p.created y p2p.reopened la meten, p2p.applied y p2p.cancelled la sacan. p2p.paid y p2p.completed son de operaciones ya en curso entre dos partes y solo interesan si calculas tasas realizadas.

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",
    "only_vip": false,
    "offer_kind": "fixed",
    "available_amount": null,
    "order_min": null,
    "order_max": null,
    "owner_uuid": "3f1b2a90-5c7d-4e8f-9a01-2b3c4d5e6f70",
    "owner_vip": true,
    "requirements": { "min_completed": 25 }
  }
}

El campo event va siempre: no tienes que deducir el tipo por la forma del objeto.

Las características de la oferta van en todos los eventos, para que filtres antes de aplicar:

CampoSignificado
only_viptrue si solo pueden aplicar cuentas VIP. Si tu cuenta no es VIP, apply la rechaza y te cuesta un intento del límite de peticiones: descártala antes.
offer_kindHoy siempre fixed (se toma entera): las ofertas por rango no se publican en el feed (ver abajo).
available_amountLo que queda por tomar en una oferta flexible. null en las fijas.
order_min / order_maxLímites de cada orden en una oferta flexible. null si no tiene.
requirementsRequisitos del creador para aplicar (min_completed, min_rating, min_account_days, min_trustscore), o null. Si tu cuenta no los cumple, apply responde 403: descártala antes.
owner_uuiduuid de la cuenta que publicó la oferta. Sirve para filtrar por anunciante sin pedir el detalle.
owner_viptrue si la cuenta que publicó la oferta es VIP. No confundir con only_vip, que es quién puede aplicar.

Las ofertas privadas no se publican en el feed. Las ofertas por rango (offer_kind: "flexible") tampoco, por ahora: los integradores que toman ofertas automáticamente desde el feed aplican sin amount. Consúltalas en el listado (filtro offer_kind=flexible); ver Ofertas por rango.

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

IntentoEspera
1inmediato
230 s
32 min
410 min
51 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.