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:
LlavePrefijoQué hace
Pruebask_test_…Ambiente de ensayo. No mueve dinero real (sandbox simulado en QA).
Producciónsk_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:

viaRielCuándoDestinoResultado
credito_inmediato (default)Crédito Inmediato (CCE)Predominanteteléfono o cuentaLiquida en BCV de forma asíncronaACEPTADA, luego LIQUIDADA por consulta/webhook
pago_movilPago Móvil P2PAlterno (p. ej. reintegros)solo teléfonoInstantá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

EstadoSignificado¿Final?
ACEPTADAEl banco recibió y aprobó (síncrono o "enviada a BCV")No (espera liquidación)
PENDIENTEAceptada, en liquidación interbancaria; consulta o espera webhookNo
LIQUIDADAConfirmada y liquidadaSí (OK)
RECHAZADARechazo de negocio (ver motivo_rechazo)Sí (fallo)
ERRORFallo técnico interno (reintentable)No
DESCONOCIDASin respuesta / timeout post-envíoNo

El camino feliz típico es ACEPTADALIQUIDADA.

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.aprobadaACEPTADA
transaccion.liquidadaLIQUIDADA
transaccion.rechazadaRECHAZADA

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:

CabeceraContenido
x-quicklink-eventNombre del evento (transaccion.liquidada, …)
x-quicklink-signatureFirma HMAC-SHA256 del cuerpo crudo: sha256=<hex>
content-typeapplication/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)

HTTPSignificadoQué hacer
400Validación del cuerpo. Devuelve { "error": "VALIDATION", "detail": {...} } con los campos inválidosCorrige el payload
401x-switch-key ausente o inválidaRevisa formato <comercio>:<api_key> y que la llave esté activa
404id no encontrado (en /consultar)Verifica el id y el comercio
200El 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_rechazoQué pasóAcción sugerida
SALDO_INSUFICIENTEEl pagador/ordenante no tiene fondosAvisa y reintenta luego
OTP_INVALIDOOTP vencido o incorrectoPide token nuevo (/cobrar/token) y reintenta
CUENTA_NO_EXISTECuenta destino/origen inexistenteCorrige el instrumento
CUENTA_BLOQUEADACuenta bloqueadaNo reintentable; contacta al titular
CUENTA_NO_DEBITO / CUENTA_NO_CREDITOLa cuenta no admite débito/créditoUsa otro instrumento
MONTO_INVALIDOMonto fuera de rango/formatoCorrige el monto
EXCEDE_LIMITESupera un tope (banco o riesgo)Reduce monto o revisa límites
BENEFICIARIO_NO_COINCIDENombre/ID no coincide con la cuentaVerifica datos del destino
NO_AFILIADO_SERVICIONo afiliado al servicio de pagoEl titular debe afiliarse
BANCO_NO_DISPONIBLEBanco caído o no procesóReintenta más tarde (QuickLink puede reenrutar en pagar)
DUPLICADAOperación ya procesadaConsulta el estado de la original
FUERA_DE_HORARIOFuera de ventana operativaReintenta en horario
CANCELADA_POR_PAGADOREl pagador cancelóNo reintentable
PENDIENTE_BCVEn liquidación BCVConsulta más tarde
RECHAZO_TECNICOFallo técnico del bancoReintentable con backoff
FIRMA_AUTHCredencial/config del bancoNo reintentable; reportar (no reenrutar dinero)
NO_SOPORTADOOperación no soportada (p. ej. reverso)Maneja por flujo alterno
BLOQUEO_RIESGOBloqueado por el motor de riesgo/fraudeRevisa listas/velocidad; no reintentar a ciegas
DESCONOCIDOSin clasificarConsulta 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: ACEPTADALIQUIDADA. 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