# Webhooks

Un webhook es un aviso que QuickPay le manda a tu servidor (un `POST` con JSON) cuando pasa algo con tus cobros. Es la forma recomendada de enterarte de un pago: no tienes que consultar.

## Eventos

| Evento | Cuándo llega |
|---|---|
| `link.pagado` | El banco confirmó el pago de un cobro: ya puedes entregar |
| `link.acreditado` | Te transferimos el neto de ese pago a tu cuenta. Llega con la liquidación inmediata, que se está activando por etapas |
| `link.devuelto` | Una devolución que hiciste desde el portal llegó a tu cliente. Las devoluciones están en piloto: si no están activas para tu cuenta, este aviso no llega |
| `transaccion.aprobada` | [API directa](/developers/api-directa): el banco confirmó la operación (`LIQUIDADA`). Llega una sola vez por operación, junto con `transaccion.liquidada` |
| `transaccion.liquidada` | API directa: la operación quedó `LIQUIDADA` (confirmada por el banco) |
| `transaccion.rechazada` | API directa: el banco rechazó la operación (`RECHAZADA`) |

`link.pagado` sale cuando el banco **confirma** que el dinero se movió, no cuando recibe la orden de débito. Según el banco de tu cliente, la confirmación puede llegar en el mismo momento del pago o el cobro pasa primero por `procesando` y el aviso llega cuando el banco lo confirma: suele tardar menos de un minuto, pero a veces tarda bastante más. Si el banco no lo aprueba, el cobro vuelve a `pendiente` y no llega ningún `link.pagado`. Llega una sola vez por cobro. → [Estados de un cobro](/developers/ciclo-de-vida)

`transaccion.aprobada` sigue la misma regla: sale cuando el banco **confirma** la operación (`LIQUIDADA`), no cuando recibe la orden de débito (`ACEPTADA`, "enviada a BCV"). Por eso llega junto con `transaccion.liquidada` y su `data.estado` es `"LIQUIDADA"`. Con una llave de prueba, el cobro simulado avisa `transaccion.aprobada` en el acto con `data.estado: "ACEPTADA"` y `referencia: "SIMULADO"`. El cuerpo de los avisos `transaccion.*` es `{ "type", "sentAt", "data": { "id", "estado", "monto", "moneda", "concepto", "referencia", "operacion" } }`, donde `data.id` es el `id` que te devolvió la operación.

`data.operacion` dice de qué es la transacción, para que un envío de dinero no se confunda con un pago que recibiste: `cobro` (te pagaron), `pago` (salió dinero: por ejemplo, la transferencia del neto a tu cuenta o un reintegro), `reverso`, y las tres de una devolución: `devolucion_debito` (el débito a tu cuenta, con la clave de tu banco, para devolverle a tu cliente), `devolucion` (el dinero que le llega a tu cliente) y `devolucion_retorno` (si el banco de tu cliente rechazó la devolución, te regresamos lo que te debitamos). Entrega mercancía sólo con `operacion: "cobro"`.

## 1. Registra tu URL

Lo más fácil: en tu portal, **Integraciones › Webhooks › Agregar URL** (sirve aunque todavía no tengas llave de producción). O por API, **con una llave `sk_live_`**:

```bash
curl -s https://pay.pagaconquickpay.com/switch/v1/webhooks \
  -H "x-switch-key: $QP_KEY" -H "content-type: application/json" \
  -d '{ "url": "https://tu-dominio.com/webhooks/quickpay", "eventos": ["link.pagado", "link.acreditado"] }'
```

El webhook recibe también los avisos de producción, así que crear, cambiar o borrar webhooks por API exige una `sk_live_`: con una `sk_test_` responde `403` con `{ "error": "requiere_llave_live" }`. Con la llave de prueba sí puedes listarlos y ver sus entregas. La URL debe empezar con `https://`. La respuesta trae el **`secret`** con el que verificas la firma: **se muestra una sola vez**, guárdalo.

Reglas de la URL:

- Tiene que llegar a una **dirección pública de Internet**. Si apunta (o su nombre resuelve) a una red interna —`localhost`, `127.x`, `10.x`, `172.16–31.x`, `192.168.x`, `100.64–127.x`, `169.254.x` o sus equivalentes IPv6— el registro responde `422` con `{ "error": "destino_bloqueado" }`. Lo mismo al cambiar la URL con `PATCH`.
- **No seguimos redirecciones.** Si tu servidor responde `301`, `302`, `307` o `308`, el aviso cuenta como no entregado y se reintenta: registra la URL final.
- Hasta **10 webhooks por comercio**. Con 10, registrar otro responde `409` con `{ "error": "limite_webhooks" }`: borra uno que no uses.

## 2. Lo que te llega

Cabeceras:

| Cabecera | Qué es |
|---|---|
| `x-quicklink-event` | El evento, p. ej. `link.pagado` |
| `x-quicklink-timestamp` | Segundos desde 1970 (UTC) en que se firmó |
| `x-quicklink-signature-v2` | `v2=<hex>`: HMAC-SHA256 de `timestamp + "." + cuerpo` |
| `x-quicklink-signature` | `sha256=<hex>`: HMAC-SHA256 del cuerpo (firma anterior, se mantiene por compatibilidad) |

Cuerpo de `link.pagado`:

```json
{
  "type": "link.pagado",
  "sentAt": "2026-10-08T14:03:22.000Z",
  "data": {
    "link_id": "3f6c2a9e-8b1d-4c7a-9e2f-5a1b7c3d9e40",
    "txn_id": "b639fac6-1d2e-4f00-9a1b-3c4d5e6f7a8b",
    "estado": "pagado",
    "monto": 150,
    "moneda": "VES",
    "concepto": "Pedido #1024",
    "referencia": "10242808",
    "mode": "live",
    "codigo_verificacion": "QP-7K3M9-XQ4TA",
    "propina": 0,
    "total": 150,
    "pagador": { "alta_cliente": false }
  }
}
```

`monto` es lo que pidió el cobro. Si activaste propinas y tu cliente agregó una, viene en `propina` y `total` es lo que se le debitó (`monto` + `propina`, en un solo débito). Sin propina, `propina` es `0` y `total` es igual a `monto`.

`codigo_verificacion` es el código que lleva el comprobante de tu cliente: sirve para [verificar que es auténtico](/developers/enlaces-de-pago#verificar-un-comprobante). Puede venir `null`; ignóralo en ese caso.

`pagador.alta_cliente` es `true` si quien pagó marcó en la página de pago "Quiero ser cliente de *tu comercio* y recibir sus novedades". Esa persona ya aparece en **Clientes** del portal; el aviso no trae su cédula, teléfono ni nombre. → [Página de pago](/developers/enlaces-de-pago#quiero-ser-cliente)

`mode` dice si el aviso es de un cobro de **producción** (`live`) o de **prueba** (`test`). En `link.acreditado` viene igual.

`metodo` sólo viene cuando tu cliente pagó con [tarjeta](/developers/enlaces-de-pago#pagar-con-tarjeta-piloto) (piloto): `"metodo": "tarjeta"`, `recargo_tarjeta` es el recargo por pago con tarjeta que pagó tu cliente (no se te liquida) y `referencia` es la del recibo de la tarjeta. `monto`, `propina` y `total` son los del cobro, sin el recargo. Con débito inmediato el aviso no trae `metodo` ni `recargo_tarjeta`.

Cuerpo de `link.devuelto` (piloto):

```json
{
  "type": "link.devuelto",
  "sentAt": "2026-10-14T21:33:59.304Z",
  "data": {
    "link_id": "4e38874a-eb60-457b-ba53-371a74dbaff4",
    "devolucion_id": "786d2085-00d3-4f61-95c4-b7509cfb5276",
    "estado": "completada",
    "monto": 1,
    "monto_devuelto": 1,
    "moneda": "VES",
    "referencia_bancaria": "SIMULADO",
    "mode": "test"
  }
}
```

Con una llave de prueba `referencia_bancaria` llega como `"SIMULADO"`; en producción es la referencia del crédito a tu cliente.

`monto` es lo que se devolvió en esta devolución y `monto_devuelto` lo devuelto en total de ese cobro (una devolución puede ser parcial y un cobro puede tener varias). Llega una vez por devolución, cuando el dinero ya salió hacia tu cliente.

`link.acreditado` trae además `liquidacion_txn_id`, `monto_bruto`, `comision` y `monto_acreditado`. Con [pago dividido](/developers/pago-dividido) (piloto) llega cuando todo el reparto terminó (cada parte pagada o cerrada sin pagar) y trae además `reparto`; `monto_acreditado` es sólo tu parte. Con propina, `monto_bruto` es el `total` (la comisión se calcula sobre lo debitado y la propina te llega con el resto del pago). Si devolviste parte de ese pago desde el portal antes de que te lo acreditáramos (piloto de devoluciones), `monto_acreditado` llega ya descontado: lo devuelto y el costo del banco de la devolución. En ese caso `monto_acreditado` es menor que `monto_bruto − comision`. Si devolviste todo lo que te tocaba, no llega `link.acreditado`. → [Glosario](/developers/glosario)

## 3. Verifica la firma (siempre)

Usa la firma **v2** y rechaza avisos con más de 5 minutos: así nadie te reenvía uno viejo. Calcula sobre el cuerpo **exacto** que llegó, antes de parsearlo.

### Node.js

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

function verificar(cuerpo, firma, timestamp, secreto) {
  if (!firma || !timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const esperado = Buffer.from('v2=' + createHmac('sha256', secreto).update(`${timestamp}.${cuerpo}`).digest('hex'));
  const recibido = Buffer.from(String(firma));
  return esperado.length === recibido.length && timingSafeEqual(esperado, recibido);
}

// Express: app.post('/webhooks/quickpay', express.text({ type: '*/*' }), (req, res) => { … })
//   verificar(req.body, req.get('x-quicklink-signature-v2'), req.get('x-quicklink-timestamp'), process.env.QUICKPAY_WEBHOOK_SECRET)
```

El [SDK](/developers/sdk/node) trae esto listo: `verificarWebhook({ cuerpo, firma, timestamp, secreto })`.

### PHP

```php
$cuerpo = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_QUICKLINK_TIMESTAMP'] ?? '';
$firma = $_SERVER['HTTP_X_QUICKLINK_SIGNATURE_V2'] ?? '';
$esperado = 'v2=' . hash_hmac('sha256', $ts . '.' . $cuerpo, getenv('QUICKPAY_WEBHOOK_SECRET'));
if (!$ts || abs(time() - (int)$ts) > 300 || !hash_equals($esperado, $firma)) { http_response_code(401); exit; }
```

## 4. Responde rápido

Responde `200` en menos de 8 segundos y procesa después. Si no respondemos con `2xx`, **reintentamos hasta 6 veces**: de inmediato, a los 30 segundos, 2 minutos, 5 minutos, 15 minutos y 1 hora.

## 5. No entregues dos veces

Un aviso puede llegar más de una vez (por los reintentos). Guarda qué `link_id` (en los avisos `transaccion.*`, `data.id`; en `link.devuelto`, `devolucion_id`) ya procesaste para cada evento y, si llega repetido, responde `200` sin volver a entregar.

## 6. Prueba y revisa las entregas

- **Probar:** en el portal, **Integraciones › Webhooks › Probar** envía a tu URL un aviso `webhook.test` firmado igual que los reales (mismas cabeceras, firma v2) y te dice si respondió `2xx`. Ese aviso no trae `data`: respóndele `200` sin procesarlo.
- **Eliminar:** en el portal, **Integraciones › Webhooks › Eliminar** borra la URL aunque todavía no tengas llave de producción (por API, `DELETE /switch/v1/webhooks/:id` exige una `sk_live_`).
- **Historial:** `GET /switch/v1/webhooks/:id/deliveries` te da las últimas entregas con su estado, el HTTP que respondió tu servidor y los intentos. Si en el momento de entregar tu URL resolvía a una red interna, la entrega queda `fallido` sin reintentos, con `status: null` y `error: "destino_bloqueado"`; en las demás, `error` viene `null`. Con una `sk_test_` sólo salen las entregas de avisos de prueba (`data.mode: "test"`); las de producción, y los avisos `transaccion.*`, se ven con una `sk_live_`.

## 7. Administrar por API

| Método | Ruta | Para |
|---|---|---|
| GET | `/webhooks` | Listar (sin el secreto). Sirve con cualquier llave |
| GET | `/webhooks/:id` | Ver uno |
| PATCH | `/webhooks/:id` | Cambiar `url`, `eventos` o `estado` (`activo` / `inactivo`). Sólo `sk_live_` |
| DELETE | `/webhooks/:id` | Borrar. Sólo `sk_live_` |

> **Revisa `data.mode` antes de entregar.** Llegan los avisos de prueba y los de producción al mismo webhook: si `mode` es `test`, el pago fue simulado y no debes entregar nada de verdad. → [Modo prueba](/developers/datos-de-prueba)
