Docs · Desde tu sistema Descargar .md
SDK de Node / TypeScript
Un solo archivo, sin dependencias, para usar la API de QuickPay desde tu servidor (Node 18 o superior). Cubre enlaces de pago, cobro por lote y la verificación de la firma de los webhooks; para aliados, también la API directa.
Instalar
bash
curl -o quickpay.ts https://docs.pagaconquickpay.com/developers/sdk/node.tsCrear un enlace y consultarlo
js
import { QuickPay } from './quickpay';
const qp = new QuickPay({ apiKey: process.env.QUICKPAY_KEY }); // "tu-comercio:sk_test_…"
const enlace = await qp.crearEnlace({ monto: 150, concepto: 'Pedido #1024', metadata: { pedido: '1024' } });
console.log(enlace.url); // compártela con tu cliente
const estado = await qp.obtenerEnlace(enlace.id);
console.log(estado.estado); // pendiente → pagadoVerificar un webhook
js
import { verificarWebhook } from './quickpay';
// cuerpo: el texto EXACTO que llegó (sin parsear). secreto: el que te mostró el portal al crear el webhook.
const valido = verificarWebhook({
cuerpo,
firma: req.headers['x-quicklink-signature-v2'],
timestamp: req.headers['x-quicklink-timestamp'],
secreto: process.env.QUICKPAY_WEBHOOK_SECRET,
});La llave va siempre en tu servidor, nunca en el navegador ni en una app.
Código fuente
typescript
/**
* API de QuickPay — SDK Node/TypeScript (mínimo, un solo archivo)
* ================================================================
*
* SDK oficial para la **API de QuickPay**: una sola integración para cobrar y
* pagar sobre los rieles bancarios de Venezuela (Pago Móvil, Crédito Inmediato,
* Débito Inmediato/C2P). Sin dependencias: usa `fetch` nativo (Node ≥ 18).
*
* Tipos y comportamiento derivados del contrato real (`contract.ts`, `openapi.ts`,
* `server.ts`). Montos en **VES**.
*
* --------------------------------------------------------------------------
* README
* --------------------------------------------------------------------------
*
* ## Instalación
*
* No tiene dependencias. Cópialo a tu proyecto o publícalo como paquete interno:
*
* ```bash
* npm i typescript --save-dev # solo si aún no usas TS
* # luego importa ./quickpay
* ```
*
* Requisitos: **Node.js ≥ 18** (fetch nativo). En TS, `"target": "ES2022"` o superior.
*
* ## Autenticación
*
* Cada llamada usa la cabecera `x-switch-key` con el formato `<comercio>:<api_key>`.
* El SDK la construye por ti a partir de `apiKey`, que ya debe venir con ese formato:
*
* - `mi_comercio:sk_test_…` → ambiente de prueba (no mueve dinero real)
* - `mi_comercio:sk_live_…` → producción
*
* **El modo (test/live) lo fija la API key, NO el cliente.** No envíes `mode` ni
* `tenant_id`: el servidor los deriva de la llave.
*
* ## Uso rápido
*
* ```ts
* import { QuickPay, nuevaIdempotencyKey } from './quickpay';
*
* const qp = new QuickPay({
* baseUrl: process.env.QUICKLINK_BASE_URL!, // {{base_url}}, ej. https://pay.pagaconquickpay.com
* apiKey: process.env.QUICKLINK_API_KEY!, // "mi_comercio:sk_test_xxx"
* });
*
* // 1) Cobrar (débito inmediato / C2P). Si requiere OTP, pídelo primero (ver abajo).
* const cobro = await qp.cobrar({
* idempotency_key: nuevaIdempotencyKey(),
* monto: { valor: 320.0, moneda: 'VES' },
* concepto: 'Matrícula 2026-1',
* cobrador: { tipo: 'cuenta', valor: '01020000...', banco: '0102', identificacion: 'J123456789', nombre: 'ACME C.A.' },
* pagador: { tipo: 'telefono', valor: '04125551234', banco: '0105', identificacion: 'V12345678', nombre: 'Ana Pérez' },
* otp: '12345678', // si ya lo capturaste del pagador
* origen_ip: '200.1.2.3',
* });
* console.log(cobro.estado, cobro.id);
*
* // 2) Flujo C2P con OTP en 2 pasos
* const intent = {
* idempotency_key: nuevaIdempotencyKey(),
* monto: { valor: 320.0 },
* concepto: 'Matrícula',
* cobrador, pagador,
* };
* await qp.solicitarToken(intent); // el banco del pagador emite el OTP (~120s)
* // ...el pagador te dicta el OTP...
* const ok = await qp.cobrar({ ...intent, idempotency_key: nuevaIdempotencyKey(), otp: '12345678' });
*
* // 3) Pagar (enviar dinero). El riel lo elige `via`. Sólo integraciones habilitadas (si no, 403 pagar_no_habilitado).
* const pago = await qp.pagar({
* idempotency_key: nuevaIdempotencyKey(),
* monto: { valor: 250.0 },
* concepto: 'Reintegro',
* ordenante: { tipo: 'cuenta', valor: '01020000...', banco: '0102', identificacion: 'J...', nombre: 'Mi Comercio' },
* beneficiario: { tipo: 'telefono', valor: '04145556677', banco: '0134', identificacion: 'V87654321', nombre: 'María' },
* via: 'credito_inmediato', // 'credito_inmediato' (CCE, predominante) | 'pago_movil' (P2P por teléfono)
* proposito: 'transferencia',
* });
*
* // 4) Estados asíncronos: consulta hasta liquidación
* const estado = await qp.consultar({ id: pago.id });
* // ACEPTADA/PENDIENTE → vuelve a consultar; LIQUIDADA es el final OK.
*
* // saldo/movimientos son de uso interno: con una llave de comercio responden 403 cuenta_no_permitida.
* ```
*
* ## Manejo de errores
*
* Toda respuesta de error lanza `QuickPayError`. Cuando el banco **rechaza** un
* cobro/pago, el resultado llega con `estado: 'RECHAZADA'` y un `motivo_rechazo`
* normalizado; el SDK lo lanza también como `QuickPayError` con `.motivo` y
* `.resultado` para que puedas ramificar:
*
* ```ts
* import { QuickPayError } from './quickpay';
*
* try {
* await qp.cobrar(req);
* } catch (e) {
* if (e instanceof QuickPayError) {
* console.error(e.motivo); // p.ej. 'SALDO_INSUFICIENTE' | 'OTP_INVALIDO' | 'BLOQUEO_RIESGO'
* console.error(e.statusCode); // HTTP (0 si fue rechazo de negocio con 200)
* console.error(e.resultado); // el Resultado cuando aplica
* }
* }
* ```
*
* Si prefieres inspeccionar el estado tú mismo (sin try/catch en rechazos de
* negocio), pasa `{ lanzarEnRechazo: false }` al constructor: entonces `cobrar`,
* `pagar`, etc. devuelven el `Resultado` incluso si `estado === 'RECHAZADA'`, y
* solo lanzan ante errores HTTP/red.
*
* ## Idempotencia
*
* Envía siempre un `idempotency_key` (UUID) único por operación. Reintentar con
* la misma llave nunca duplica el cobro/pago: devuelve el resultado original.
* Usa `nuevaIdempotencyKey()` (UUID v4) que el SDK exporta.
*
* --------------------------------------------------------------------------
* (fin README)
*/
import { createHmac, timingSafeEqual } from 'node:crypto';
// ─────────────────────────────────────────────────────────────────────────
// Tipos del contrato (derivados de contract.ts)
// ─────────────────────────────────────────────────────────────────────────
/** Instrumento: origen o destino del dinero. Todos los bancos soportan cuenta o teléfono. */
export interface Instrumento {
/** "cuenta" (nº de 20 dígitos) o "telefono". */
tipo: 'cuenta' | 'telefono';
/** Nº de cuenta o teléfono. */
valor: string;
/** Código BCV del banco (4 dígitos), ej. "0102". */
banco: string;
/** Cédula/RIF con prefijo de persona (V/E/J/P…), ej. "V12345678". */
identificacion: string;
/** Nombre del titular (≤ 40 caracteres). */
nombre: string;
}
/** Monto a mover. `moneda` por defecto "VES" (ISO-4217). */
export interface Monto {
valor: number;
/** ISO-4217, 3 letras. Por defecto "VES". */
moneda?: string;
}
/** Canal por el que entra la operación. */
export type Canal = 'boton_pago' | 'billetera_digital' | 'pos' | 'merchant' | 'vpos';
/** Estado normalizado de una transacción (la liquidación interbancaria es asíncrona). */
export type EstadoTransaccion =
| 'ACEPTADA' // el banco recibió y aprobó (síncrono o "enviada a BCV")
| 'PENDIENTE' // aceptada, en liquidación; requiere consultar/webhook
| 'LIQUIDADA' // confirmada y liquidada (final OK)
| 'RECHAZADA' // rechazo de negocio (ver motivo_rechazo)
| 'ERROR' // fallo técnico/transporte (reintentable)
| 'DESCONOCIDA'; // sin respuesta; consultar antes de reintegrar
/** Enum común de rechazos, igual para todos los bancos. */
export type MotivoRechazo =
| 'SALDO_INSUFICIENTE'
| 'OTP_INVALIDO'
| 'CUENTA_NO_EXISTE'
| 'CUENTA_BLOQUEADA'
| 'CUENTA_NO_DEBITO'
| 'CUENTA_NO_CREDITO'
| 'MONTO_INVALIDO'
| 'EXCEDE_LIMITE'
| 'BENEFICIARIO_NO_COINCIDE'
| 'NO_AFILIADO_SERVICIO'
| 'BANCO_NO_DISPONIBLE'
| 'DUPLICADA'
| 'FUERA_DE_HORARIO'
| 'CANCELADA_POR_PAGADOR'
| 'PENDIENTE_BCV'
| 'RECHAZO_TECNICO'
| 'FIRMA_AUTH'
| 'NO_SOPORTADO'
| 'BLOQUEO_RIESGO' // bloqueado por el motor de riesgo/fraude
| 'SIN_DEBITO_EN_EXTRACTO' // pagar: el banco no respondió y el débito no está en el estado de cuenta
| 'DESCONOCIDO';
/** Resultado: forma de respuesta de toda operación de dinero. */
export interface Resultado {
/** Id interno del switch (idempotente). */
id: string;
estado: EstadoTransaccion;
/** Referencia del banco (referencia_c / numeroReferencia / secuencial…), o null. */
referencia_banco: string | null;
/** Id BCV cuando aplica, o null. */
endtoend: string | null;
/** Motivo normalizado si estado === 'RECHAZADA'; null en caso contrario. */
motivo_rechazo: MotivoRechazo | null;
/** Nº de intentos del switch. */
intentos: number;
}
// ── Campos comunes a toda operación de dinero. `tenant_id` y `mode` los fija el
// servidor desde la API key: el cliente NO los envía (omitidos a propósito).
interface BaseMonetario {
/** UUID único por operación (idempotencia). Usa `nuevaIdempotencyKey()`. */
idempotency_key: string;
monto: Monto;
/** Concepto/descripción (≤ 120 caracteres). */
concepto: string;
/** Por defecto "boton_pago". */
canal?: Canal;
/** IP del usuario final (varios bancos la exigen). */
origen_ip?: string;
/** Hint de enrutamiento: banco preferido. */
banco_preferido?: string;
}
/** Request de `cobrar` (débito inmediato / C2P). */
export interface CobrarRequest extends BaseMonetario {
/** Quién cobra (tu cuenta). */
cobrador: Instrumento;
/** Quién paga (a quien se debita). */
pagador: Instrumento;
/** OTP del banco emisor, si ya se capturó. Si no, pídelo con `solicitarToken`. */
otp?: string;
}
/** Request de `solicitarToken` (paso previo: pide OTP al banco del pagador). */
export interface TokenRequest extends BaseMonetario {
cobrador: Instrumento;
pagador: Instrumento;
}
/** Request de `pagar` (enviar dinero a un beneficiario). */
export interface PagarRequest extends BaseMonetario {
/** Origen del dinero (tu tesorería). */
ordenante: Instrumento;
/** Destinatario. */
beneficiario: Instrumento;
/** Por defecto "transferencia". */
proposito?: 'transferencia' | 'nomina' | 'proveedor';
/**
* Riel de envío. Por defecto "credito_inmediato" (CCE, predominante; admite
* teléfono o cuenta; liquida asíncrono BCV → ACEPTADA y luego LIQUIDADA).
* "pago_movil" es P2P por teléfono, instantáneo → LIQUIDADA.
* A cuenta siempre es crédito inmediato.
*/
via?: 'credito_inmediato' | 'pago_movil';
}
/** Request de `consultar` (estado/liquidación de una operación previa). */
export interface ConsultarRequest {
/** Id devuelto por cobrar/pagar/token. */
id: string;
}
/** Query de `saldo` (read-only). */
export interface SaldoQuery {
cuenta: string;
moneda?: string;
/** Banco específico (hint). */
banco?: string;
}
/** Query de `movimientos` (read-only, conciliación). */
export interface MovimientosQuery {
cuenta: string;
/** Fecha inicio (requerida), ej. "2026-06-01". */
desde: string;
/** Fecha fin (opcional). */
hasta?: string;
/** Banco específico (hint). */
banco?: string;
}
// ─────────────────────────────────────────────────────────────────────────
// Error
// ─────────────────────────────────────────────────────────────────────────
/**
* Error de cualquier operación. Si fue un **rechazo de negocio** (estado
* RECHAZADA), `motivo` trae el `motivo_rechazo` normalizado y `resultado` el
* Resultado completo.
*/
export class QuickPayError extends Error {
/** Motivo normalizado de rechazo, cuando aplica. */
readonly motivo: MotivoRechazo | null;
/** Código HTTP (0 si fue un rechazo de negocio devuelto con 200). */
readonly statusCode: number;
/** Resultado crudo del switch, cuando la respuesta lo trae. */
readonly resultado?: Resultado;
/** Cuerpo de error tal como lo devolvió el servidor, cuando aplica. */
readonly detalle?: unknown;
constructor(
message: string,
opts: {
motivo?: MotivoRechazo | null;
statusCode?: number;
resultado?: Resultado;
detalle?: unknown;
} = {},
) {
super(message);
this.name = 'QuickPayError';
this.motivo = opts.motivo ?? null;
this.statusCode = opts.statusCode ?? 0;
if (opts.resultado !== undefined) this.resultado = opts.resultado;
if (opts.detalle !== undefined) this.detalle = opts.detalle;
}
}
// ─────────────────────────────────────────────────────────────────────────
// Cliente
// ─────────────────────────────────────────────────────────────────────────
// ── Enlaces y lotes (comercios) ───────────────────────────────────────────
export interface CrearEnlaceRequest {
monto: number;
moneda?: 'VES';
/** Lo que ve tu cliente al pagar (obligatorio, hasta 140 caracteres). */
concepto: string;
/** Horas hasta que vence: de 1 a 720 (por omisión 24). */
expira_horas?: number;
/** Vencimiento exacto (ISO 8601, futuro). Si lo envías, manda sobre expira_horas. */
expira_en?: string;
/** A dónde vuelve el cliente al terminar. No es prueba de pago: confirma con el webhook o con obtenerEnlace. */
success_url?: string;
/** A dónde vuelve el cliente si el pago no puede continuar (venció, lo cancelaste o eligió "Cancelar y volver"); llega con `?estado=expirado|cancelado|rechazado`. */
failure_url?: string;
/** Tus datos (hasta 20 claves). */
metadata?: Record<string, string | number | boolean>;
/** Si reintentas con la misma llave, recibes el mismo enlace (no se duplica). */
idempotency_key?: string;
}
export interface Enlace {
id: string;
url: string;
wa_deeplink?: string;
qr_png?: string;
monto: number;
moneda: string;
concepto: string | null;
estado: 'pendiente' | 'cobrando' | 'procesando' | 'revision' | 'pagado' | 'expirado' | 'cancelado';
expires_at: string;
/** Sólo en consultar y listar. */
comercio?: string;
txn_id?: string | null;
created_at?: string;
paid_at?: string | null;
/** Propina que agregó quien pagó (Bs; 0 si no agregó). `monto` no la incluye. Sólo en consultar y listar. */
propina?: number;
/** Lo debitado: monto + propina. Sólo en consultar y listar. */
total?: number;
/** Código del comprobante (QP-XXXXX-XXXXX), sólo si el enlace está pagado (al consultar y al listar). Se verifica en pagaconquickpay.com/verificar. */
codigo_verificacion?: string | null;
/** Quien pagó: alta_cliente = true si marcó "Quiero ser cliente de <comercio> y recibir sus novedades". Sólo en consultar y listar. */
pagador?: { alta_cliente: boolean };
/** Sólo en la respuesta de crear. */
mode?: 'test' | 'live';
metadata?: Record<string, string | number | boolean> | null;
/** true si repetiste la creación con la misma idempotency_key. */
idempotent_replay?: boolean;
}
export interface FiltroEnlaces {
estado?: Enlace['estado'] | Enlace['estado'][];
desde?: Date | string | number;
hasta?: Date | string | number;
/** Concepto, nombre de quien pagó, referencia, id o código de verificación. */
q?: string;
/** Monto del enlace en Bs (sin propina), incluidos. */
min?: number;
max?: number;
con_propina?: boolean;
mode?: 'test' | 'live';
origen?: 'api' | 'panel' | 'mi_pagina' | 'boton' | 'whatsapp';
/** Por omisión 50, máximo 200. */
limit?: number;
offset?: number;
}
export interface CrearLoteRequest {
nombre?: string;
expira_horas?: number;
idempotency_key?: string;
items: { monto: number; concepto: string; referencia_externa?: string; cliente?: { nombre?: string; telefono?: string }; idempotency_key?: string }[];
}
export interface Lote {
id: string;
total: number;
mode: 'test' | 'live';
items: { n: number; link_id: string; url: string; referencia_externa: string | null; monto: number; concepto: string; estado: string; paid_at: string | null }[];
[k: string]: unknown;
}
export interface QuickPayOptions {
/** Base URL del API. Por omisión "https://pay.pagaconquickpay.com". Sin barra final. */
baseUrl?: string;
/** Llave en formato "<comercio>:<api_key>". La key (sk_test_/sk_live_) fija el modo. */
apiKey: string;
/**
* Si true (por defecto), las operaciones de dinero lanzan `QuickPayError`
* cuando `estado === 'RECHAZADA'`. Si false, devuelven el Resultado y solo
* lanzan ante errores HTTP/red.
*/
lanzarEnRechazo?: boolean;
/** Timeout por request en ms (por defecto 30000). */
timeoutMs?: number;
/** `fetch` a usar (por defecto el global). Útil para tests o proxies. */
fetch?: typeof fetch;
}
const API_BASE = '/switch/v1';
export class QuickPay {
private readonly baseUrl: string;
private readonly apiKey: string;
private readonly lanzarEnRechazo: boolean;
private readonly timeoutMs: number;
private readonly _fetch: typeof fetch;
constructor(opts: QuickPayOptions) {
if (!opts?.apiKey) throw new QuickPayError('Falta apiKey (formato "<comercio>:<api_key>").');
if (!opts.apiKey.includes(':')) {
throw new QuickPayError('apiKey debe tener el formato "<comercio>:<api_key>".');
}
this.baseUrl = (opts.baseUrl ?? 'https://pay.pagaconquickpay.com').replace(/\/+$/, '');
this.apiKey = opts.apiKey;
this.lanzarEnRechazo = opts.lanzarEnRechazo ?? true;
this.timeoutMs = opts.timeoutMs ?? 30_000;
const f = opts.fetch ?? globalThis.fetch;
if (typeof f !== 'function') {
throw new QuickPayError('fetch no disponible: usa Node ≥ 18 o pasa { fetch } en las opciones.');
}
this._fetch = f.bind(globalThis);
}
// ── Comercios: enlaces de pago y cobro por lote ─────────────────────────
/** Crea un enlace de pago (POST /links). El cliente paga en la `url` que devuelve. */
async crearEnlace(req: CrearEnlaceRequest): Promise<Enlace> {
return this.request<Enlace>('POST', '/links', { body: { moneda: 'VES', ...req } });
}
/** Consulta un enlace (GET /links/:id): su `estado` dice si ya se pagó. */
async obtenerEnlace(id: string): Promise<Enlace> {
return this.request<Enlace>('GET', `/links/${encodeURIComponent(id)}`, {});
}
/**
* Lista tus enlaces (GET /links), del más nuevo al más viejo. Todos los filtros son opcionales y se combinan.
* `estado` acepta uno o varios; `desde`/`hasta` (Date, ISO o milisegundos) van sobre la fecha del pago o, si no se pagó,
* la de creación: `desde` entra y `hasta` no.
*/
async listarEnlaces(f: FiltroEnlaces = {}): Promise<Enlace[]> {
const fecha = (v: Date | string | number | undefined) => (v instanceof Date ? v.toISOString() : v === undefined ? undefined : String(v));
return this.request<Enlace[]>('GET', '/links', { query: {
estado: Array.isArray(f.estado) ? f.estado.join(',') : f.estado, desde: fecha(f.desde), hasta: fecha(f.hasta), q: f.q,
min: f.min?.toString(), max: f.max?.toString(), con_propina: f.con_propina === undefined ? undefined : String(f.con_propina),
mode: f.mode, origen: f.origen, limit: f.limit?.toString(), offset: f.offset?.toString(),
} });
}
/** Cancela un enlace (POST /links/:id/cancelar). Sólo si sigue `pendiente`; si no, lanza un error 409 `no_cancelable`. */
async cancelarEnlace(id: string): Promise<{ id: string; estado: 'cancelado' }> {
return this.request<{ id: string; estado: 'cancelado' }>('POST', `/links/${encodeURIComponent(id)}/cancelar`, {});
}
/**
* Fuerza el resultado de un enlace de PRUEBA (POST /links/:id/simular) sin abrir la página de pago. Sólo con llave
* `sk_test_`: en un enlace de producción el servidor responde 403.
*/
async simularEnlace(id: string, resultado: 'pagado' | 'rechazado' | 'expirado'): Promise<{ id: string; estado: string; resultado: string; simulado: true }> {
return this.request('POST', `/links/${encodeURIComponent(id)}/simular`, { body: { resultado } });
}
/** Crea hasta 500 enlaces en una sola llamada (POST /lotes). */
async crearLote(req: CrearLoteRequest): Promise<Lote> {
return this.request<Lote>('POST', '/lotes', { body: { moneda: 'VES', ...req } });
}
/** Consulta un lote y el estado de cada enlace (GET /lotes/:id). */
async obtenerLote(id: string): Promise<Lote> {
return this.request<Lote>('GET', `/lotes/${encodeURIComponent(id)}`, {});
}
// ── Aliados (API directa): operaciones de dinero ───────────────────────
/** Cobrar (debitar al pagador): débito inmediato / C2P. Incluye `otp` si ya lo capturaste. */
async cobrar(req: CobrarRequest): Promise<Resultado> {
return this.dinero('/cobrar', req);
}
/** Solicitar OTP al banco del pagador (paso previo a `cobrar` con token, TTL ~120s). */
async solicitarToken(req: TokenRequest): Promise<Resultado> {
return this.dinero('/cobrar/token', req);
}
/** Pagar (enviar dinero a un beneficiario). El riel lo elige `via`. Debita una cuenta de QuickPay: sólo para
* integraciones habilitadas; con una llave de comercio sin habilitación el switch responde 403 `pagar_no_habilitado`. */
async pagar(req: PagarRequest): Promise<Resultado> {
return this.dinero('/pagar', req);
}
/** Consultar el estado actualizado de una operación previa (PENDIENTE/LIQUIDADA/RECHAZADA). */
async consultar(req: ConsultarRequest): Promise<Resultado> {
// /consultar lanza ante RECHAZADA solo si así está configurado; por defecto
// devolvemos el Resultado tal cual: consultar es informativo, no ejecuta dinero.
const res = await this.request<Resultado>('POST', '/consultar', { body: req });
return res;
}
// ── Consultas read-only ────────────────────────────────────────────────
/** Saldo de una cuenta cobradora (no lo filtran reglas de pago). Uso interno: 403 `cuenta_no_permitida` con una llave de comercio. */
async saldo(q: SaldoQuery): Promise<unknown> {
return this.request('GET', '/saldo', { query: { cuenta: q.cuenta, moneda: q.moneda, banco: q.banco } });
}
/** Movimientos de una cuenta para conciliación. `desde` es requerido. Uso interno: 403 `cuenta_no_permitida` con una llave de comercio. */
async movimientos(q: MovimientosQuery): Promise<unknown> {
if (!q?.desde) throw new QuickPayError("'desde' es requerido para movimientos.");
return this.request('GET', '/movimientos', {
query: { cuenta: q.cuenta, desde: q.desde, hasta: q.hasta, banco: q.banco },
});
}
// ── Internos ───────────────────────────────────────────────────────────
/** Ejecuta una operación de dinero (POST → Resultado) y aplica la política de rechazo. */
private async dinero(path: string, body: unknown): Promise<Resultado> {
const res = await this.request<Resultado>('POST', path, { body });
if (this.lanzarEnRechazo && res?.estado === 'RECHAZADA') {
throw new QuickPayError(
`Operación rechazada: ${res.motivo_rechazo ?? 'DESCONOCIDO'}`,
{ motivo: res.motivo_rechazo, statusCode: 0, resultado: res },
);
}
return res;
}
private async request<T>(
method: 'GET' | 'POST',
path: string,
opts: { body?: unknown; query?: Record<string, string | undefined> },
): Promise<T> {
const url = new URL(this.baseUrl + API_BASE + path);
if (opts.query) {
for (const [k, v] of Object.entries(opts.query)) {
if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v));
}
}
const headers: Record<string, string> = {
'x-switch-key': this.apiKey,
accept: 'application/json',
};
const init: RequestInit = { method, headers };
if (opts.body !== undefined && method !== 'GET') {
headers['content-type'] = 'application/json';
init.body = JSON.stringify(opts.body);
}
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
init.signal = ctrl.signal;
let resp: Response;
try {
resp = await this._fetch(url.toString(), init);
} catch (e) {
if ((e as Error)?.name === 'AbortError') {
throw new QuickPayError(`Timeout (${this.timeoutMs}ms) en ${method} ${path}.`, {
motivo: 'DESCONOCIDO',
});
}
throw new QuickPayError(`Error de red en ${method} ${path}: ${String(e)}`, {
motivo: 'BANCO_NO_DISPONIBLE',
});
} finally {
clearTimeout(timer);
}
const text = await resp.text();
let data: unknown = undefined;
if (text) {
try {
data = JSON.parse(text);
} catch {
data = text;
}
}
if (!resp.ok) {
const d = (data ?? {}) as { error?: string; detail?: unknown; motivo_rechazo?: MotivoRechazo };
const motivo =
(d.motivo_rechazo as MotivoRechazo | undefined) ?? statusToMotivo(resp.status);
throw new QuickPayError(
`HTTP ${resp.status} en ${method} ${path}: ${d.error ?? resp.statusText}`,
{ statusCode: resp.status, motivo, detalle: d.detail ?? data },
);
}
return data as T;
}
}
// ─────────────────────────────────────────────────────────────────────────
// Utilidades
// ─────────────────────────────────────────────────────────────────────────
/** Mapea códigos HTTP de error de transporte a un motivo normalizado aproximado. */
function statusToMotivo(status: number): MotivoRechazo | null {
if (status === 401 || status === 403) return 'FIRMA_AUTH';
if (status === 429) return 'EXCEDE_LIMITE';
if (status === 404) return null;
if (status >= 500) return 'BANCO_NO_DISPONIBLE';
return null;
}
/** Genera un UUID v4 para usar como `idempotency_key`. */
export function nuevaIdempotencyKey(): string {
const c = (globalThis as { crypto?: Crypto }).crypto;
if (c && typeof c.randomUUID === 'function') return c.randomUUID();
// Fallback (sin crypto): suficiente para idempotencia best-effort.
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
const r = (Math.random() * 16) | 0;
const v = ch === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
export default QuickPay;
// ─────────────────────────────────────────────────────────────────────────
// Webhooks: verificar la firma
// ─────────────────────────────────────────────────────────────────────────
/**
* Verifica un webhook con la firma v2: HMAC-SHA256(secreto, `${timestamp}.${cuerpo}`), cabeceras
* `x-quicklink-signature-v2: v2=<hex>` y `x-quicklink-timestamp` (segundos). Rechaza avisos con más de
* `toleranciaSeg` de antigüedad (por omisión 300 s) para que nadie reenvíe uno viejo.
* `cuerpo` es el texto EXACTO recibido, antes de parsearlo.
*/
export function verificarWebhook(o: { cuerpo: string; firma: string | undefined; timestamp: string | undefined; secreto: string; toleranciaSeg?: number }): boolean {
if (!o.firma || !o.timestamp || !o.secreto) return false;
const ts = Number(o.timestamp);
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > (o.toleranciaSeg ?? 300)) return false;
const esperado = Buffer.from('v2=' + createHmac('sha256', o.secreto).update(`${o.timestamp}.${o.cuerpo}`).digest('hex'));
const recibido = Buffer.from(String(o.firma));
return esperado.length === recibido.length && timingSafeEqual(esperado, recibido);
}
/** Compatibilidad con el nombre anterior del SDK (QuickLink). */
export { QuickPay as QuickLink, QuickPayError as QuickLinkError };