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:
- Conseguir tu API key y autenticarte.
- Hacer tu primer cobro (débito inmediato con OTP).
- Enviar un pago a un beneficiario.
- Consultar el estado de una operación.
- Recibir webhooks de cambios de estado.
- 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:bashexport 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
modenitest=true: el servidor deriva el modo (test/live) del prefijo de la API key. La misma petición, consk_test_…corre simulada y consk_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:
export SWITCH_KEY="acme:sk_test_xxxxxxxxxxxxxxxxxxxxxxxx"Verifica que tu llave funciona listando los bancos habilitados (no mueve dinero):
curl -s "$base_url/switch/v1/bancos" \
-H "x-switch-key: $SWITCH_KEY"Respuesta esperada (HTTP 200), un arreglo de bancos y sus capacidades:
[
{ "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 defectoVES). - El estado se lee del cuerpo, no del HTTP. Un
200 OKsignifica que el banco respondió; el campoestado(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.
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):
{
"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
12345678en 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:
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:
{
"id": "qp_txn_018f3a...",
"estado": "LIQUIDADA",
"banco_usado": "banco_plaza",
"referencia_banco": "98765432",
"endtoend": "0000001234567890",
"motivo_rechazo": null,
"intentos": 1
}- Si
estadoesLIQUIDADAoACEPTADA, el cobro fue exitoso. - Si es
RECHAZADA, miramotivo_rechazo(sección 6). El error típico aquí esOTP_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
/cobrarcon elotp, 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íesvia: "pago_movil", a cuenta va por CCE). - A teléfono es
credito_inmediatopor defecto; usapago_movilsolo si quieres el riel P2P instantáneo.
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:
{
"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, defaulttransferencia) yvia(defaultcredito_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:
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):
{
"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 existeidempotency_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:
{
"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:
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
2xxrá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).
{
"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. AnteDESCONOCIDAo 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_…vssk_live_…). - Cobrar = débito inmediato en 2 pasos:
/cobrar/token→ OTP →/cobrar. - Pagar = payout;
viaelige el riel (credito_inmediatopor defecto;pago_movilP2P por teléfono). - Estados asíncronos:
ACEPTADA→LIQUIDADA. Confírmalos con/consultaro webhooks. idempotency_key(UUID) en cada operación: reintentar nunca duplica.- Rechazos normalizados en
motivo_rechazo; el crudo del banco enraw.
¿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