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, pausada, reactivada, aplicada, pagada, completada o cancelada, y por cada cambio en lo que queda de una oferta por rango.
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.reopened | Una oferta tomada vuelve a estar disponible: el peer se retiró, se le venció la ventana de pago o fue expulsado. Mismo uuid, status open. Le sigue casi siempre un p2p.paused con reason taker_left: la pausa automática de unos minutos para que el creador revise la tasa |
p2p.paused | La oferta sale del mercado sin cancelarse: su creador la pausó (pause.reason owner, sin fecha de fin) o es la pausa automática tras irse el peer (taker_left, con pause.until). status sigue open; apply responde 409 OFFER_PAUSED mientras dure |
p2p.resumed | Una oferta pausada vuelve al mercado: su creador la reactivó o venció la pausa automática. pause llega null |
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, o su creador la convierte en oferta por rango: en ese caso llega con offer_kind flexible y status open, seguido de p2p.range_created con el mismo uuid |
Ofertas por rango
| Evento | Cuándo |
|---|---|
p2p.range_created | Se publica una oferta por rango |
p2p.range_updated | Cambió lo que queda por tomar (available_amount): alguien tomó una parte, una parte volvió (cancelación o ventana de pago vencida), el creador editó la oferta, una oferta que se había quedado sin cupo vuelve a tenerlo, o una oferta pausada vuelve al mercado (change resumed) |
p2p.range_closed | La oferta sale del mercado: se tomó todo lo que se podía tomar, se canceló, o su creador la pausó (change paused, pause con el motivo). Una oferta pausada sigue open y vuelve con range_updated |
Estos tres solo llegan si los marcas: una oferta por rango se toma por partes y apply exige amount, así que una integración que no las conoce no las recibe. Ver Ofertas por rango en el feed.
Elegir qué llega a cada canal
Por defecto llegan los ocho eventos de ofertas fijas, al webhook y al stream. En el panel, en Eventos que recibes, eliges por separado qué llega a cada canal: por ejemplo, el stream solo con p2p.created, p2p.reopened y p2p.range_created para que tu bot reaccione, y el webhook con todo para analítica. Si un canal se queda sin ninguno marcado, recibe los ocho de ofertas fijas.
Si un evento no puedes perderlo, márcalo también en el webhook. El stream no guarda lo que ocurre mientras reconectas (ver Reconexión); el webhook tiene cola y reintentos.
Para mantener una copia del libro de ofertas fijas te bastan seis: p2p.created, p2p.reopened y p2p.resumed la meten; p2p.applied, p2p.cancelled y p2p.paused la sacan. p2p.paid y p2p.completed son de operaciones ya en curso entre dos partes y solo interesan si calculas tasas realizadas. Para las de rango: p2p.range_created y p2p.range_updated meten o actualizan la oferta, y p2p.range_closed la saca (también cuando se pausa).
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 },
"change": null,
"pause": null
}
}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:
| Campo | Significado |
|---|---|
only_vip | true 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_kind | fixed (se toma entera) en los ocho eventos de ofertas fijas; flexible en los p2p.range_*. |
available_amount | Lo que queda por tomar en una oferta flexible. null en las fijas. |
order_min / order_max | Límites de cada orden en una oferta flexible. null si no tiene. |
requirements | Requisitos 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_uuid | uuid de la cuenta que publicó la oferta. Sirve para filtrar por anunciante sin pedir el detalle. |
owner_vip | true si la cuenta que publicó la oferta es VIP. No confundir con only_vip, que es quién puede aplicar. |
change | Solo en p2p.range_*: qué movió lo disponible. created, taken (alguien tomó una parte), returned (una parte volvió por cancelación o expiración), edited (el creador la editó), cancelled, paused (sale del libro sin cancelarse) o resumed (vuelve). null en el resto. |
pause | La pausa vigente, o null. { "reason": "owner", "until": null } la pausó su creador; { "reason": "taker_left", "until": "2026-10-27T20:10:00.000Z" } es la pausa automática y until la hora (UTC) en que vuelve sola. Una oferta open con pause no se puede tomar: apply responde 409 OFFER_PAUSED. Va en todos los eventos. |
Las ofertas privadas no se publican en el feed.
Ofertas por rango en el feed
{
"event": "p2p.range_updated",
"sent_at": "2026-10-13T23:06:20Z",
"data": {
"uuid": "0d4c57ca-7508-48d6-bcf1-3beb2d609b70",
"type": "sell",
"coin": "CLASICA",
"amount": "660",
"receive": "749.05",
"status": "open",
"updated_at": "2026-10-13T23:06:19Z",
"only_vip": false,
"offer_kind": "flexible",
"available_amount": "545",
"order_min": "20",
"order_max": "660",
"owner_uuid": "b49bb2e6-b4c8-4623-8102-b734fca402c2",
"owner_vip": false,
"requirements": null,
"change": "taken",
"pause": null
}
}- Lo que se puede tomar es
available_amount, noamount(el total con el que se publicó). - La tasa es
receive / amount, igual que en una oferta fija. - Para aplicar, manda
amountentreorder_miny el menor deorder_maxyavailable_amount. Sinamount,applyresponde400 FLEXIBLE_AMOUNT_REQUIREDy no se mueve dinero. - Trata
range_createdyrange_updatedcomo un upsert: guarda la oferta con suavailable_amountactual.range_closedla saca del libro. Una oferta cerrada porque se quedó sin cupo puede volver si una parte se cancela; entonces llega unrange_updatedconstatusopen. - Pueden llegar varios
range_updatedseguidos de la misma oferta. Si se acumulan antes de que tu endpoint los reciba, el webhook entrega solo el más reciente, que es el estado que importa. - Una oferta por rango pausada sale como
range_closedconchangepaused(ypausecon el motivo), aunque sustatussigaopen. Vuelve conrange_updatedychangeresumed. Si una parte vuelve mientras está pausada, esereturnedtambién llega comorange_closed/paused: lo que queda por tomar lo traerá elresumed.
Pausas en el feed
Una oferta pausada no se cancela: sigue open, pero no se puede tomar. El feed lo cuenta con sus propios eventos para que no apliques a ciegas:
| Qué pasó | Oferta fija | Oferta por rango |
|---|---|---|
El creador la pausa (POST /p2p/:uuid/pause con paused: true) | p2p.paused, pause.reason owner | p2p.range_closed, change paused |
| El peer se va y la oferta vuelve al mercado | p2p.reopened y, justo después, p2p.paused con reason taker_left y pause.until | p2p.range_closed, change paused |
El creador la reactiva (paused: false) | p2p.resumed | p2p.range_updated, change resumed |
Vence la pausa automática (pause.until) | p2p.resumed, hasta un minuto después de until | p2p.range_updated, change resumed |
Si llevas un libro, trata paused como «fuera del libro» y resumed como «dentro», igual que applied y reopened. Si prefieres no esperar al evento, pause.until te dice cuándo volverá sola.
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, y una misma oferta puede generar el mismo evento varias veces de verdad (reabre, la toma otra persona, vuelve a reabrir; o cada cambio de una oferta por rango). Usa event + data.uuid + data.updated_at como clave: si ya procesaste esa combinación, ignórala. Si un evento trae un updated_at anterior al que ya tienes para esa oferta, también puedes ignorarlo.
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://...",
"webhook_events": ["p2p.created", "p2p.reopened", "p2p.paused", "p2p.resumed", "p2p.applied", "p2p.paid", "p2p.completed", "p2p.cancelled", "p2p.range_created", "p2p.range_updated", "p2p.range_closed"],
"stream_events": ["p2p.created", "p2p.reopened", "p2p.range_created"],
"events": ["p2p.created", "p2p.reopened", "p2p.paused", "p2p.resumed", "p2p.applied", "p2p.paid", "p2p.completed", "p2p.cancelled", "p2p.range_created", "p2p.range_updated", "p2p.range_closed"] },
"meta": { "price_usd": 10, "period_days": 30,
"all_events": ["…los once…"], "default_events": ["…los ocho de ofertas fijas…"],
"range_events": ["p2p.range_created", "p2p.range_updated", "p2p.range_closed"] }
}webhook_events y stream_events son lo que llega a cada canal. events se mantiene por compatibilidad y es igual a webhook_events.
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.