# QuickLink · Pay API — Quickstart (integra en < 10 min)

Una sola integración para **cobrar** y **pagar** sobre todos los rieles bancarios del país. Tú dices *qué* quieres (cobrar/pagar Bs X a Y); QuickLink decide *cómo* y *por cuál banco*.

En esta guía vas a, con `curl` puro y copy-paste:

1. Conseguir tu API key y autenticarte.
2. Hacer tu **primer cobro** (débito inmediato con OTP).
3. **Enviar un pago** a un beneficiario.
4. **Consultar** el estado de una operación.
5. Recibir **webhooks** de cambios de estado.
6. Manejar **errores y rechazos**.

> **Convención.** A lo largo de la guía usamos la variable `{{base_url}}` como host del API. Sustitúyela por la URL que te entregamos (producción: `https://pay.quickoffer.io`). Define una variable de entorno para copiar-pegar sin editar:
>
> ```bash
> export base_url="https://pay.quickoffer.io"
> ```

---

## 1. Obtén tu API key

Toda llamada se autentica con la cabecera **`x-switch-key`** en formato:

```
x-switch-key: <comercio>:<api_key>
```

- `<comercio>` es el identificador de tu comercio (p. ej. `acme`).
- `<api_key>` es la llave secreta. Tienes dos tipos:

| Llave | Prefijo | Qué hace |
|---|---|---|
| **Prueba** | `sk_test_…` | Ambiente de ensayo. **No mueve dinero real** (sandbox simulado en QA). |
| **Producción** | `sk_live_…` | Mueve dinero real sobre los bancos. |

> **El modo lo fija la llave, no tu código.** No envías ningún campo `mode` ni `test=true`: el servidor deriva el modo (`test`/`live`) del prefijo de la API key. La misma petición, con `sk_test_…` corre simulada y con `sk_live_…` mueve dinero. Cambias de ambiente cambiando una sola cosa: la llave.

Guarda tu llave en una variable para el resto de la guía. Empieza siempre en **test**:

```bash
export SWITCH_KEY="acme:sk_test_xxxxxxxxxxxxxxxxxxxxxxxx"
```

Verifica que tu llave funciona listando los bancos habilitados (no mueve dinero):

```bash
curl -s "$base_url/switch/v1/bancos" \
  -H "x-switch-key: $SWITCH_KEY"
```

Respuesta esperada (HTTP 200), un arreglo de bancos y sus capacidades:

```json
[
  { "banco": "banco_plaza", "capabilities": ["cobrar", "pagar", "token", "consultar", "saldo", "movimientos"] },
  { "banco": "bancaribe",   "capabilities": ["cobrar", "pagar", "token", "consultar"] }
]
```

Si recibes `401 {"error":"unauthorized"}`, revisa el formato `<comercio>:<api_key>` y que la llave esté activa.

### Conceptos que vas a usar en todas las llamadas

- **`idempotency_key` (UUID).** Obligatorio en cada operación de dinero. Reintentar con **la misma** llave **nunca** duplica el cobro/pago: te devuelve el resultado original. Genera uno por operación:
  ```bash
  uuidgen | tr 'A-Z' 'a-z'   # o cualquier generador de UUID v4
  ```
- **`Instrumento`.** Origen o destino del dinero. Se expresa igual sea cuenta o teléfono:
  ```jsonc
  {
    "tipo": "cuenta",            // "cuenta" | "telefono"
    "valor": "01380000...",      // nº cuenta (20 díg) o teléfono
    "banco": "0138",             // código BCV de 4 dígitos
    "identificacion": "V13759368", // cédula/RIF con prefijo (V/E/J/P…)
    "nombre": "Ana Pérez"        // ≤ 40 caracteres
  }
  ```
- **Montos en VES.** `{ "valor": 320.00, "moneda": "VES" }` (la moneda es ISO-4217 y por defecto `VES`).
- **El estado se lee del cuerpo, no del HTTP.** Un `200 OK` significa que el banco respondió; el campo `estado` (`ACEPTADA`/`PENDIENTE`/`RECHAZADA`/…) te dice qué pasó con el dinero.

---

## 2. Tu primer cobro (débito inmediato / C2P)

`cobrar` **debita al pagador** (le cobras a una persona). Es un **débito inmediato** y suele requerir un **OTP** que el banco del pagador le envía. El flujo son **2 pasos**:

```
POST /cobrar/token   →   (el pagador recibe un OTP de su banco, ~120s de validez)
POST /cobrar         →   (envías ese OTP y se ejecuta el cobro)
```

### Paso 2.1 — Solicitar el OTP

`POST /switch/v1/cobrar/token` con el mismo cuerpo del cobro **sin** el `otp`. `cobrador` eres tú (recibes el dinero); `pagador` es quien paga.

```bash
IDEM=$(uuidgen | tr 'A-Z' 'a-z')

curl -s -X POST "$base_url/switch/v1/cobrar/token" \
  -H "x-switch-key: $SWITCH_KEY" \
  -H "content-type: application/json" \
  -d "{
    \"idempotency_key\": \"$IDEM\",
    \"monto\": { \"valor\": 320.00, \"moneda\": \"VES\" },
    \"concepto\": \"Matricula 2026-1\",
    \"canal\": \"boton_pago\",
    \"cobrador\": {
      \"tipo\": \"cuenta\", \"valor\": \"01380000011234567890\", \"banco\": \"0138\",
      \"identificacion\": \"J00378944781\", \"nombre\": \"ACME C.A.\"
    },
    \"pagador\": {
      \"tipo\": \"telefono\", \"valor\": \"04125551234\", \"banco\": \"0105\",
      \"identificacion\": \"V13759368\", \"nombre\": \"Ana Perez\"
    }
  }"
```

Respuesta (`Resultado`):

```json
{
  "id": "qp_txn_018f3a...",
  "estado": "ACEPTADA",
  "banco_usado": "banco_plaza",
  "referencia_banco": "12345678",
  "endtoend": null,
  "motivo_rechazo": null,
  "intentos": 1
}
```

`ACEPTADA` aquí significa "OTP solicitado": el pagador ya recibió el código en su teléfono. **El token vive ~120 segundos.**

> En modo **test** el OTP es simulado; usa `12345678` en el paso siguiente.

### Paso 2.2 — Ejecutar el cobro con el OTP

Toma el OTP que te da el pagador y llama a `POST /switch/v1/cobrar`. Es el **mismo cuerpo** del paso anterior **más** el campo `otp`. Reutiliza el **mismo `idempotency_key`** del flujo de cobro:

```bash
curl -s -X POST "$base_url/switch/v1/cobrar" \
  -H "x-switch-key: $SWITCH_KEY" \
  -H "content-type: application/json" \
  -d "{
    \"idempotency_key\": \"$IDEM\",
    \"monto\": { \"valor\": 320.00, \"moneda\": \"VES\" },
    \"concepto\": \"Matricula 2026-1\",
    \"canal\": \"boton_pago\",
    \"otp\": \"12345678\",
    \"cobrador\": {
      \"tipo\": \"cuenta\", \"valor\": \"01380000011234567890\", \"banco\": \"0138\",
      \"identificacion\": \"J00378944781\", \"nombre\": \"ACME C.A.\"
    },
    \"pagador\": {
      \"tipo\": \"telefono\", \"valor\": \"04125551234\", \"banco\": \"0105\",
      \"identificacion\": \"V13759368\", \"nombre\": \"Ana Perez\"
    }
  }"
```

Respuesta:

```json
{
  "id": "qp_txn_018f3a...",
  "estado": "LIQUIDADA",
  "banco_usado": "banco_plaza",
  "referencia_banco": "98765432",
  "endtoend": "0000001234567890",
  "motivo_rechazo": null,
  "intentos": 1
}
```

- Si `estado` es `LIQUIDADA` o `ACEPTADA`, el cobro fue exitoso.
- Si es `RECHAZADA`, mira `motivo_rechazo` (sección 6). El error típico aquí es `OTP_INVALIDO` (código vencido o mal escrito → pide un token nuevo en 2.1).

> **Atajo:** si ya capturaste el OTP por tu cuenta (p. ej. el banco del pagador lo entrega in-band), puedes llamar directo a `/cobrar` con el `otp`, sin el paso 2.1.

---

## 3. Envía un pago (payout)

`pagar` **envía dinero** desde tu tesorería a un beneficiario. Aquí `ordenante` eres tú (de dónde sale el dinero) y `beneficiario` es quien recibe.

La pieza clave es **`via`**, que elige el riel de envío:

| `via` | Riel | Cuándo | Destino | Resultado |
|---|---|---|---|---|
| `credito_inmediato` *(default)* | Crédito Inmediato (CCE) | Predominante | **teléfono o cuenta** | Liquida en BCV de forma **asíncrona** → `ACEPTADA`, luego `LIQUIDADA` por consulta/webhook |
| `pago_movil` | Pago Móvil P2P | Alterno (p. ej. reintegros) | **solo teléfono** | Instantáneo → `LIQUIDADA` |

Reglas de oro:

- A **cuenta** siempre es `credito_inmediato` (aunque envíes `via: "pago_movil"`, a cuenta va por CCE).
- A **teléfono** es `credito_inmediato` por defecto; usa `pago_movil` solo si quieres el riel P2P instantáneo.

```bash
IDEM=$(uuidgen | tr 'A-Z' 'a-z')

curl -s -X POST "$base_url/switch/v1/pagar" \
  -H "x-switch-key: $SWITCH_KEY" \
  -H "content-type: application/json" \
  -d "{
    \"idempotency_key\": \"$IDEM\",
    \"monto\": { \"valor\": 250.00, \"moneda\": \"VES\" },
    \"concepto\": \"Reintegro\",
    \"proposito\": \"transferencia\",
    \"via\": \"credito_inmediato\",
    \"canal\": \"billetera_digital\",
    \"ordenante\": {
      \"tipo\": \"cuenta\", \"valor\": \"01380000011234567890\", \"banco\": \"0138\",
      \"identificacion\": \"J00378944781\", \"nombre\": \"ACME C.A.\"
    },
    \"beneficiario\": {
      \"tipo\": \"telefono\", \"valor\": \"04145556677\", \"banco\": \"0134\",
      \"identificacion\": \"V14589678\", \"nombre\": \"Maria Gomez\"
    }
  }"
```

Respuesta:

```json
{
  "id": "qp_txn_018f4b...",
  "estado": "ACEPTADA",
  "banco_usado": "banco_plaza",
  "referencia_banco": "55512345",
  "endtoend": "0000009876543210",
  "motivo_rechazo": null,
  "intentos": 1
}
```

Por `credito_inmediato` lo normal es recibir **`ACEPTADA`** (enviada a BCV) y que confirme a **`LIQUIDADA`** poco después. Para enterarte de ese cambio: consulta (sección 4) o suscríbete a webhooks (sección 5).

> Campos opcionales de `pagar`: `proposito` (`transferencia` | `nomina` | `proveedor`, default `transferencia`) y `via` (default `credito_inmediato`). Para Pago Móvil P2P por teléfono usa `"via": "pago_movil"`.

---

## 4. Consulta el estado (ACEPTADA → LIQUIDADA)

La liquidación interbancaria es **asíncrona**. Una operación puede quedar `PENDIENTE`/`ACEPTADA` y confirmarse después. Usa `POST /switch/v1/consultar` con el `id` que te devolvió `cobrar`/`pagar`:

```bash
curl -s -X POST "$base_url/switch/v1/consultar" \
  -H "x-switch-key: $SWITCH_KEY" \
  -H "content-type: application/json" \
  -d '{ "id": "qp_txn_018f4b..." }'
```

Respuesta (estado **actualizado**):

```json
{
  "id": "qp_txn_018f4b...",
  "estado": "LIQUIDADA",
  "banco_usado": "banco_plaza",
  "referencia_banco": "55512345",
  "endtoend": "0000009876543210",
  "motivo_rechazo": null,
  "intentos": 1
}
```

### Ciclo de vida de los estados

| Estado | Significado | ¿Final? |
|---|---|---|
| `ACEPTADA` | El banco recibió y aprobó (síncrono o "enviada a BCV") | No (espera liquidación) |
| `PENDIENTE` | Aceptada, en liquidación interbancaria; **consulta** o espera webhook | No |
| `LIQUIDADA` | Confirmada y liquidada | **Sí (OK)** |
| `RECHAZADA` | Rechazo de negocio (ver `motivo_rechazo`) | **Sí (fallo)** |
| `ERROR` | Fallo técnico interno (reintentable) | No |
| `DESCONOCIDA` | Sin respuesta / timeout post-envío | No |

El camino feliz típico es **`ACEPTADA` → `LIQUIDADA`**.

> **Regla de seguridad.** Ante `DESCONOCIDA`, **consulta antes de reintegrar**: el banco pudo haber movido el dinero. **Nunca** reenvíes la operación original a ciegas (por eso existe `idempotency_key`: reintentar la misma operación es seguro y no duplica).

No necesitas hacer polling agresivo: configura un webhook (siguiente sección) y QuickLink te avisa cuando el estado cambie.

---

## 5. Webhooks salientes

En vez de consultar en bucle, registra una **URL** y QuickLink te envía un `POST` cada vez que una transacción cambia de estado.

### Eventos que recibes

| Evento (`type`) | Se dispara cuando la transacción pasa a… |
|---|---|
| `transaccion.aprobada` | `ACEPTADA` |
| `transaccion.liquidada` | `LIQUIDADA` |
| `transaccion.rechazada` | `RECHAZADA` |

### Cuerpo del webhook

QuickLink hace `POST` a tu URL con este JSON:

```json
{
  "type": "transaccion.liquidada",
  "sentAt": "2026-06-10T14:32:07.114Z",
  "data": {
    "id": "qp_txn_018f4b...",
    "estado": "LIQUIDADA",
    "monto": 250.00,
    "moneda": "VES",
    "concepto": "Reintegro",
    "banco": "banco_plaza",
    "referencia": "55512345"
  }
}
```

Cabeceras que llegan en cada entrega:

| Cabecera | Contenido |
|---|---|
| `x-quicklink-event` | Nombre del evento (`transaccion.liquidada`, …) |
| `x-quicklink-signature` | Firma **HMAC-SHA256** del cuerpo crudo: `sha256=<hex>` |
| `content-type` | `application/json` |

### Verifica la firma (obligatorio)

Cada endpoint tiene un **secreto** (te lo damos al registrarlo). Firma el **cuerpo crudo** del request con HMAC-SHA256 y compara con `x-quicklink-signature`. Ejemplo en Node:

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

function verificar(secret, rawBody, signatureHeader) {
  const esperado = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
  const a = Buffer.from(esperado);
  const b = Buffer.from(signatureHeader || '');
  return a.length === b.length && timingSafeEqual(a, b);
}
```

Notas de operación:

- **Responde `2xx` rápido.** Si tu endpoint no responde OK, QuickLink **reintenta con backoff** (inmediato, 30 s, 2 min, 5 min, 15 min, 1 h — hasta 6 intentos). La cola es durable y sobrevive reinicios.
- **Sé idempotente al recibir.** Un mismo cambio de estado podría entregarse más de una vez; deduplica por `data.id` + `type`.

> El alta del endpoint (URL + eventos a suscribir) se gestiona desde tu panel/onboarding de QuickLink. Si necesitas el alta vía API, contáctanos.

---

## 6. Manejo de errores y rechazos

Distingue dos planos:

### 6.1 Errores de protocolo (código HTTP)

| HTTP | Significado | Qué hacer |
|---|---|---|
| `400` | Validación del cuerpo. Devuelve `{ "error": "VALIDATION", "detail": {...} }` con los campos inválidos | Corrige el payload |
| `401` | `x-switch-key` ausente o inválida | Revisa formato `<comercio>:<api_key>` y que la llave esté activa |
| `404` | `id` no encontrado (en `/consultar`) | Verifica el `id` y el comercio |
| `200` | El banco respondió | **Lee `estado`** del cuerpo para saber qué pasó con el dinero |

### 6.2 Rechazos de negocio (`motivo_rechazo`)

Cuando `estado` es `RECHAZADA`, el campo **`motivo_rechazo`** te dice por qué, con un enum **normalizado igual para todos los bancos**. El código crudo del banco siempre se preserva en `raw` (auditable).

```json
{
  "id": "qp_txn_018f5c...",
  "estado": "RECHAZADA",
  "banco_usado": "banco_plaza",
  "referencia_banco": null,
  "motivo_rechazo": "SALDO_INSUFICIENTE",
  "intentos": 1,
  "raw": { "codigo": "AM04", "descripcion": "Fondos insuficientes" }
}
```

Valores de `motivo_rechazo` y cómo reaccionar:

| `motivo_rechazo` | Qué pasó | Acción sugerida |
|---|---|---|
| `SALDO_INSUFICIENTE` | El pagador/ordenante no tiene fondos | Avisa y reintenta luego |
| `OTP_INVALIDO` | OTP vencido o incorrecto | Pide token nuevo (`/cobrar/token`) y reintenta |
| `CUENTA_NO_EXISTE` | Cuenta destino/origen inexistente | Corrige el instrumento |
| `CUENTA_BLOQUEADA` | Cuenta bloqueada | No reintentable; contacta al titular |
| `CUENTA_NO_DEBITO` / `CUENTA_NO_CREDITO` | La cuenta no admite débito/crédito | Usa otro instrumento |
| `MONTO_INVALIDO` | Monto fuera de rango/formato | Corrige el monto |
| `EXCEDE_LIMITE` | Supera un tope (banco o riesgo) | Reduce monto o revisa límites |
| `BENEFICIARIO_NO_COINCIDE` | Nombre/ID no coincide con la cuenta | Verifica datos del destino |
| `NO_AFILIADO_SERVICIO` | No afiliado al servicio de pago | El titular debe afiliarse |
| `BANCO_NO_DISPONIBLE` | Banco caído o no procesó | Reintenta más tarde (QuickLink puede reenrutar en `pagar`) |
| `DUPLICADA` | Operación ya procesada | Consulta el estado de la original |
| `FUERA_DE_HORARIO` | Fuera de ventana operativa | Reintenta en horario |
| `CANCELADA_POR_PAGADOR` | El pagador canceló | No reintentable |
| `PENDIENTE_BCV` | En liquidación BCV | Consulta más tarde |
| `RECHAZO_TECNICO` | Fallo técnico del banco | Reintentable con backoff |
| `FIRMA_AUTH` | Credencial/config del banco | No reintentable; reportar (no reenrutar dinero) |
| `NO_SOPORTADO` | Operación no soportada (p. ej. reverso) | Maneja por flujo alterno |
| `BLOQUEO_RIESGO` | Bloqueado por el motor de riesgo/fraude | Revisa listas/velocidad; no reintentar a ciegas |
| `DESCONOCIDO` | Sin clasificar | Consulta y revisa `raw` |

> **Reintentos seguros.** Para reintentar una operación, usa el **mismo `idempotency_key`**: si la original sí se ejecutó, recibes su resultado en vez de un cobro/pago duplicado. Ante `DESCONOCIDA` o un timeout, **consulta primero** — nunca reenvíes con una llave nueva.

---

## Recapitulando

- **Una cabecera:** `x-switch-key: <comercio>:<api_key>`. El **modo lo fija la llave** (`sk_test_…` vs `sk_live_…`).
- **Cobrar** = débito inmediato en 2 pasos: `/cobrar/token` → OTP → `/cobrar`.
- **Pagar** = payout; `via` elige el riel (`credito_inmediato` por defecto; `pago_movil` P2P por teléfono).
- **Estados asíncronos:** `ACEPTADA` → `LIQUIDADA`. Confírmalos con `/consultar` o webhooks.
- **`idempotency_key` (UUID)** en cada operación: reintentar nunca duplica.
- **Rechazos** normalizados en `motivo_rechazo`; el crudo del banco en `raw`.

¿Listo para producción? Cambia tu `sk_test_…` por `sk_live_…` — nada más en tu código cambia.

**Documentación interactiva:** `{{base_url}}/docs` (Scalar) · **OpenAPI:** `{{base_url}}/openapi.json`
