@retorna-tech/retorna-gateway-sdk
v1.0.4
Published
Retorna Gateway SDK — SDK oficial para integrar Retorna Gateway (B2B API) en aplicaciones Node.js y TypeScript. Autenticación OAuth2 y firma de requests, cotizaciones, órdenes, rutas, billeteras y reportes mediante una interfaz fluida y tipada.
Readme
Retorna Gateway SDK
Versión 2 del SDK para integrar los servicios B2B de Retorna (CPG API) en aplicaciones Node.js y TypeScript. Expone, mediante una interfaz fluida y tipada, las operaciones de billetera, rutas, cotizaciones y órdenes del nuevo b2b-service.
Diferencias con v1:
- Autenticación: OAuth2 client credentials (Basic auth en
/oauth2/token, Bearer en el resto) + firma RSA por request (headerssignatureynonce). Todos los endpoints del canalapiverifican la firma. - Contrato
/me/*token-scoped: cada llamada devuelve los datos de la empresa que autentica el token. No hay parámetroownerId— una credencial = una empresa. - snake_case en todo el límite HTTP. Montos como strings decimales (
"56.00"), nunca números.
Requisitos
- Node.js >= 18.18 (usa
fetchnativo; sin dependencias en runtime). - Se distribuye en ESM y CommonJS con tipos incluidos:
import { … } from "@retorna-tech/retorna-gateway-sdk"oconst { … } = require("@retorna-tech/retorna-gateway-sdk").
Instalación
npm install @retorna-tech/retorna-gateway-sdkRequisitos:
- Node.js >= 18 (native
fetch). Sin dependencias de runtime. RETORNA_CLIENT_ID+RETORNA_CLIENT_SECRET(OAuth2) yRETORNA_PRIVATE_KEY(clave privada RSA en PEM, para firmar cada request). Los tres se provisionan al crear la organización.
Quickstart
import {
RetornaClientBuilder,
RetornaEnvironment,
RetornaB2BError,
} from "@retorna-tech/retorna-gateway-sdk";
import { randomUUID } from "node:crypto";
// 1. Construir el cliente
// scope: dev y sandbox → "sandbox/full_access"; prod → "prod/full_access"
const client = new RetornaClientBuilder()
.clientId(process.env.RETORNA_CLIENT_ID!)
.clientSecret(process.env.RETORNA_CLIENT_SECRET!)
.privateKey(process.env.RETORNA_PRIVATE_KEY!)
.scope("sandbox/full_access") // prod → "prod/full_access"
.environment(RetornaEnvironment.DEVELOP)
.loggingLevel("error")
.buildClient();
// 2. Mi billetera (el balance es el campo `amount`)
const wallet = await client.getMyWallet();
console.log(wallet.status, wallet.currency, wallet.amount);
// wallet.funding_methods → direcciones de depósito para fondear la billetera:
// "funding_methods": [
// { "type": "CRYPTO", "asset_code": "USDC", "network": "BASE_TESTNET", "address": "0x703F…", "status": "ACTIVE" }
// ]
// 3. Elegir un corredor (corridor) entre las rutas de mi empresa
const { routes } = await client.getRoutes();
const route = routes[0]; // { name, method, country, limits }
// 4. Cotizar (source.currency SIEMPRE "USDR"; montos como strings)
const quotation = await client.createQuotation({
source: { currency: "USDR" }, // sólo `currency`: la API rechaza campos extra
destination: {
country: route.country,
currency: "VES",
// payout_method.type: BANK_TRANSFER | P2P_PHONE_TRANSFER | CRYPTO_TRANSFER
// mirar en la doc: https://docs.gateway.retorna.app/glossary/payout-type
payout_method: { type: "P2P_PHONE_TRANSFER" },
},
quote: { mode: "SEND_EXACT", amount: "10" },
});
console.log(quotation.id, quotation.exchange_rate.value, quotation.target.amount);
// quotation.destination espeja el destination que mandaste: sirve para
// verificar, ANTES de crear la orden, que payment_instructions coincide con
// el payout_method cotizado (bank_account ↔ BANK_TRANSFER, bank_phone_account
// ↔ P2P_PHONE_TRANSFER). Si no coincide, POST /orders responde 422 B2B_QUOTATION_MISMATCH.
// country y payout_method.type vienen null sólo si CPG no pudo asociar una
// tasa a la cotización (anomalía del proveedor): no crees una orden sobre ella.
console.log(quotation.destination.country, quotation.destination.payout_method.type);
// 5. Crear una orden con esa cotización
// Pasá un idempotency key explícito para que el reintento sea seguro.
const idempotencyKey = randomUUID();
const order = await client.createOrder(
{
quotation_id: quotation.id,
purpose: "FAMILY_SUPPORT", // obligatorio — catálogo cerrado ORDER_PURPOSES
sender: {
names: "Ana María",
last_names: "Pérez Gómez",
document: { type: "CC", id: "1020304050", country: "CO" },
phone: "+573001234567", // opcional
email: "[email protected]", // opcional
country: "CO",
},
destination: {
receiver: {
names: "Carlos Andrés",
last_names: "Rodríguez López",
document: { type: "V", id: "12345678", country: "VE" },
email: "[email protected]", // opcional
country: "VE",
},
// Polimórfico: enviá EXACTAMENTE UNO de bank_account (BANK_TRANSFER)
// o bank_phone_account (P2P_PHONE_TRANSFER).
payment_instructions: {
// bank_name: catálogo cerrado (FINANCIAL_ENTITY_CODES). Un banco fuera
// del catálogo se rechaza client-side.
bank_phone_account: { bank_name: "BNC_VE", phone_number: "+584121234567", document_id: "12345678" },
},
},
},
idempotencyKey
);
console.log(order.id, order.status);[!NOTE]
idempotency_keyno es un campo del request — lo asigna el servicio (es la idempotency key enviada enX-Idempotency-Key) y la idempotency key vuelve en el response comoorder.idempotency_key.Para un payout P2P por teléfono, usá
bank_phone_accounten lugar debank_account.document_ides opcional: si falta, el servicio usadestination.receiver.document.id.payment_instructions: { bank_phone_account: { bank_name: "BANCO_VENEZUELA_VE", phone_number: "+584121234567" }, }
purpose(concepto de la remesa) es obligatorio y debe pertenecer al catálogoORDER_PURPOSES(FAMILY_SUPPORT,PAYMENT_OF_BILLS, …).
sender.phone(E.164) ysender.emailson opcionales — igual quedestination.receiver.email— y sólo vuelven en la respuesta si los mandaste al crear la orden.
Modelo de autenticación
Dos capas independientes: token OAuth2 + firma RSA por request. Las dos son obligatorias en el canal api.
1. Firma de request (RSA-SHA256)
Cada endpoint del canal api está anotado con @VerifySignature(...) en b2b-service. El SDK firma automáticamente con la privateKey configurada y agrega dos headers:
| Header | Contenido |
|---|---|
| signature | RSA-SHA256 en base64 del mensaje canónico |
| nonce | Timestamp en milisegundos (Date.now()), válido ±5 minutos |
El mensaje canónico depende del método:
- Lecturas (GET / DELETE):
`${path}?${queryString}${nonce}`— elpathes la ruta de la API sin base URL ni stage. - Escrituras (POST / PUT / PATCH):
`${JSON.stringify(rawBody)}${nonce}`— el body crudo re-encodeado, no el objeto serializado una sola vez.
[!WARNING] El
noncetiene que ser numérico. El guard rechaza cualquier cosa queparseIntno pueda leer (el middleware legacy dejaba pasar un UUID por accidente; éste no).
.disableSignature() existe como escape hatch para stubs locales — contra un deployment real la API responde 401.
2. Token OAuth2
OAuth2 client credentials (RFC 6749), transparente:
- En el primer request autenticado, el SDK llama a
POST /oauth2/token(Cognito hosted UI) conAuthorization: Basic base64(clientId:clientSecret)y elscopeconfigurado. - El token se cachea hasta
expires_in - 60s(buffer de seguridad). - Ante un 401/403 con el token cacheado ya expirado, el SDK refresca y reintenta una vez. Si el token aún no había expirado, relanza el error de inmediato (no oculta mal uso de credenciales).
Scopes por entorno
| Entorno | scope |
|---|---|
| DEVELOP | sandbox/full_access |
| SANDBOX | sandbox/full_access |
| PRODUCTION | prod/full_access |
El scope es opcional en el builder: si no lo pasás, cae al default del entorno (la tabla de arriba).
Reintentos seguros (idempotency key)
POST /orders requiere el header X-Idempotency-Key (UUID v4). El SDK genera uno solo si no lo pasás.
[!TIP] Para que reintentar
createOrdersea seguro (que un corte de red nunca produzca un duplicado), pasá tu propia idempotency key y reusala en el reintento:
import { randomUUID } from "node:crypto";
const key = randomUUID();
await client.createOrder(request, key); // misma key en el reintento → sin duplicadoListar órdenes
listOrders usa paginación por cursor:
// Primera página (limit por defecto 20, máx 100)
let page = await client.listOrders({ limit: 20 });
for (const order of page.data) {
console.log(order.id, order.status, order.idempotency_key);
}
// Página siguiente usando el cursor
if (page.pagination.has_more) {
page = await client.listOrders({
cursor: page.pagination.next_cursor!,
limit: 20,
});
}
// Filtrar por estado o por rango de fechas
const pending = await client.listOrders({ status: "PENDING" });
const recent = await client.listOrders({ created_from: "2026-01-01T00:00:00Z" });
// Filtrar por tipo de transacción CPG (catálogo abierto — pertenece a CPG,
// no a este SDK). Un único valor: "type=A,B" es un 400 desde CPG.
const wallet2pog = await client.listOrders({ type: "WALLET_TO_POG" });Transacciones de la billetera
getMyWalletTransactions(type, params?) lista los movimientos de tu billetera. Usa paginación por cursor, igual que listOrders — page ya no existe y mandarlo devuelve 400. El type es el tipo de movimiento (hoy: "top_up"):
// Primera página (máx 100)
let txs = await client.getMyWalletTransactions("top_up", { limit: 20 });
for (const tx of txs.data) {
console.log(tx.id, tx.type, tx.amount, tx.currency, tx.status, tx.created_at);
// tx.status → "PENDING" | "COMPLETED" (ausente si CPP no reportó estado)
// tx.crypto_deposit → { code, network, tx_hash } del depósito on-chain
}
// Página siguiente con el cursor
if (txs.pagination.has_more) {
txs = await client.getMyWalletTransactions("top_up", {
cursor: txs.pagination.next_cursor!,
limit: 20,
});
}El tercer parámetro es la moneda del path (/me/wallets/:currency), por defecto "USDR". Hoy el servidor sólo valida su forma: la organización tiene una única billetera.
Reportes
Los endpoints reports/* son un passthrough a b2b-reports-service. createReport devuelve 202: el reporte queda pedido, no hecho.
// Tipos disponibles y sus columnas
const { data: types } = await client.getReportTypes();
// Pedir un reporte — fechas ISO-8601 CON offset explícito (una fecha pelada se rechaza)
const { id } = await client.createReport({
type: "orders",
date_from: "2026-07-01T00:00:00-04:00",
date_to: "2026-07-31T23:59:59-04:00",
format: "CSV",
});
// Estado y descarga (link pre-firmado de S3, no el archivo)
const detail = await client.getReport(id);
if (detail.status === "COMPLETED") {
const { url, expires_at } = await client.getReportDownloadLink(id, 300);
}
// Listado — este endpoint sí usa paginación por OFFSET
const page = await client.listReports({ page: 1, limit: 50, status: "COMPLETED" });
// Filtrar por origen: true = sólo programados, false = sólo pedidos manualmente,
// ausente = ambos
const scheduled = await client.listReports({ is_scheduled: true });
// Programaciones y entrega por webhook
await client.saveReportGeneration("orders", "DAILY", { format: "CSV", timezone: "America/Caracas" });
await client.saveReportDelivery("orders", "WEBHOOK", {
is_active: true,
config: { url: "https://hooks.example.com/reports", secret: "un-secreto-de-16+" },
});Identificación del SDK
Cada request lleva un header que dice qué SDK y qué versión está llamando. Son automáticos: no hay nada que configurar y un caller no los puede pisar.
| Header | Ejemplo | Para qué |
|---|---|---|
| X-Retorna-Client | typescript-sdk/1.0.0 | Qué SDK y qué versión, en un solo valor <cliente>/<versión>. Contrato común de la flota (java-sdk/…, typescript-sdk/…) |
| User-Agent | typescript-sdk/1.0.0 (node/22.11.0; darwin arm64) | El único que los access logs de ALB/API Gateway ya registran sin cambios en el backend; el paréntesis trae el runtime |
El token typescript-sdk es SDK_CLIENT y la versión es SDK_VERSION (src/core/version.ts). Un test ata la versión a package.json: si bumpeás la versión y te olvidás de la constante, la suite falla en vez de mentir en el header.
Además, cada request lleva Accept: application/json por default (salvo que ya hayas puesto el tuyo): el API Gateway de B2B exige un mínimo de User-Agent + Accept en cada request.
import { SDK_CLIENT, SDK_VERSION, CLIENT_HEADER_VALUE, SDK_IDENTITY_HEADERS } from "@retorna-tech/retorna-gateway-sdk";
// CLIENT_HEADER_VALUE === "typescript-sdk/1.0.0"[!NOTE] El request del token va a Cognito, no a Retorna: ese no lleva estos headers.
Opciones del builder
| Método | Descripción | Default |
|---|---|---|
| .clientId(string) | Client ID | — (requerido salvo disableAuth) |
| .clientSecret(string) | Client Secret | — (requerido salvo disableAuth) |
| .privateKey(string) | Clave privada RSA (PEM) para firmar cada request | — (requerido salvo disableAuth / disableSignature) |
| .scope(string) | OAuth2 scope | env default (ver "Scopes por entorno") |
| .environment(env) | DEVELOP / SANDBOX / PRODUCTION | DEVELOP |
| .baseUrlOverride(url) | URL base personalizada (https:// only) | env default |
| .authUrlOverride(url) | URL del token endpoint (https:// only) | Cognito hosted UI |
| .disableAuth() | Sin token ni headers de auth (transicional) | — |
| .disableSignature() | Sin firma de request (escape hatch: la API real responde 401) | — |
| .loggingLevel(level) | none / error / warn / info / debug | error |
| .disableLogging() | Desactiva todos los logs | — |
| .retries(n) | Reintentos en errores de red y 5xx/429 | 3 |
| .backoffMs(ms) | Backoff inicial en ms (exponencial) | 200 |
Endpoints
| Operación | Método | Path | Método SDK |
|---|---|---|---|
| Token | POST | /oauth2/token | (automático) |
| Mi cliente | GET | /me/client | getMyClient() |
| Mi billetera | GET | /me/wallets/:currency | getMyWallet(currency?) |
| Transacciones de billetera | GET | /me/wallets/:currency/transactions/:type | getMyWalletTransactions(type, params?, currency?) |
| Rutas | GET | /me/routes | getRoutes(params?) |
| Crear cotización | POST | /quotations | createQuotation(req) |
| Cotización por id | GET | /quotations/:id | getQuotation(id) |
| Crear orden | POST | /orders | createOrder(req, idempotencyKey?) |
| Listar órdenes | GET | /orders | listOrders(params?) |
| Orden por id | GET | /orders/:id | getOrder(id) |
| Tipos de reporte | GET | /reports/types | getReportTypes() |
| Pedir reporte | POST | /reports | createReport(req) |
| Listar reportes | GET | /reports | listReports(params?) |
| Reporte por id | GET | /reports/:id | getReport(id) |
| Link de descarga | GET | /reports/:id/download | getReportDownloadLink(id, ttlSeconds?) |
| Preferencias de columnas | GET/PUT/DELETE | /reports/types/:type/preferences | getReportPreferences / saveReportPreferences / deleteReportPreferences |
| Programaciones | GET/PUT/PATCH/DELETE | /reports/types/:type/generations[/:frequency] | getReportGenerations / saveReportGeneration / setReportGenerationActive / deleteReportGeneration |
| Entregas | GET/PUT/DELETE | /reports/types/:type/delivery[/:method] | getReportDeliveries / getReportDelivery / saveReportDelivery / deleteReportDelivery |
Paginación: órdenes y transacciones de billetera usan cursor (pagination.{next_cursor,has_more,limit,total}); el listado de reportes usa offset ({page,limit,total,total_pages}).
Manejo de errores
import { RetornaB2BError, RetornaSdkError } from "@retorna-tech/retorna-gateway-sdk";
try {
await client.createOrder(req);
} catch (err) {
if (err instanceof RetornaB2BError) {
console.error(err.code); // "B2B_INSUFFICIENT_BALANCE"
console.error(err.category); // "PERMANENT" | "TRANSIENT" | "UNKNOWN"
console.error(err.httpStatus); // 422
console.error(err.correlationId); // string | undefined
console.error(err.details); // string[] | undefined — solo en B2B_PROVIDER_REJECTED
} else if (err instanceof RetornaSdkError) {
console.error(err.message); // validación client-side o respuesta no estructurada
}
}details aparece únicamente en B2B_PROVIDER_REJECTED: es el veredicto textual de la red de pagos, campo por campo (p. ej. "members.1.receiver.beneficiary documentType must be one of V, E, J, G"). Sirve para saber QUÉ corregir; no es parte estable del contrato, así que loguealo y mostráselo a operaciones, pero no ramifiques sobre él.
Códigos de error B2B
| Código | HTTP | Categoría |
|---|---|---|
| B2B_UNAUTHORIZED | 401 | PERMANENT |
| B2B_QUOTATION_NOT_FOUND | 404 | PERMANENT |
| B2B_QUOTATION_EXPIRED | 422 | PERMANENT |
| B2B_QUOTATION_MISMATCH | 422 | PERMANENT |
| B2B_QUOTATION_ALREADY_USED | 409 | PERMANENT |
| B2B_INSUFFICIENT_BALANCE | 422 | PERMANENT |
| B2B_BENEFICIARY_INVALID | 422 | PERMANENT |
| B2B_RATE_NOT_FOUND | 422 | PERMANENT |
| B2B_ORDER_NOT_FOUND | 404 | PERMANENT |
| B2B_IDEMPOTENCY_CONFLICT | 409 | PERMANENT |
| B2B_COMPANY_NOT_FOUND | 404 | PERMANENT |
| B2B_WALLET_NOT_CONFIGURED | 409 | PERMANENT |
| B2B_WALLET_NOT_FOUND | 404 | PERMANENT |
| B2B_INVALID_PAGINATION_CURSOR | 400 | PERMANENT |
| B2B_FUNDING_INVALID_REQUEST | 400 | PERMANENT |
| B2B_FUNDING_NOT_ELIGIBLE | 422 | PERMANENT |
| B2B_FUNDING_NOT_AUTHORIZED | 403 | PERMANENT |
| B2B_RATE_LIMITED | 429 | TRANSIENT |
| B2B_PROVIDER_REJECTED | 422 | PERMANENT |
| B2B_PROVIDER_UNAVAILABLE | 503 | TRANSIENT |
| B2B_UNKNOWN | 500 | UNKNOWN |
Validaciones client-side
El SDK valida antes de hacer el request y lanza errores descriptivos:
source.currency→ siempreUSDR(origen de fondos B2B).quote.amounty montos → string decimal con hasta 6 decimales (^\d+(\.\d{1,6})?$).payout_method.type→BANK_TRANSFER|P2P_PHONE_TRANSFER|CRYPTO_TRANSFER(ver glosario).quote.mode→SEND_EXACT|RECEIVE_EXACT.country(destination/sender/receiver/document) → 2 caracteres ISO alpha-2.sourcesólo llevacurrency.purpose→ catálogo cerradoORDER_PURPOSES(obligatorio enPOST /orders).bank_name→ catálogo cerradoFINANCIAL_ENTITY_CODES(exportado por el SDK).quotation_id→ formato UUID.X-Idempotency-Key→ UUID v4 (auto-generado si no se pasa).emaildel receiver → formato válido (opcional).bank_account.type→CHECKING|SAVINGS|CORRIENTE|AHORRO.payment_instructions→ exactamente uno debank_accountobank_phone_account;phone_numberen formato E.164;document_idopcional.currencydel path de billetera → 3 a 5 letras MAYÚSCULAS.limit(1..100);page(≥1) sólo en reportes.- Fechas de reportes → ISO-8601 con offset explícito; URLs de entrega →
https://. - Ids de recursos → entre 1 y 100 caracteres.
Entornos
| Entorno | RetornaEnvironment | Base URL | Scope |
|---|---|---|---|
| Desarrollo | DEVELOP | https://ifomkws6s0.execute-api.us-east-1.amazonaws.com/dev | sandbox/full_access |
| Sandbox | SANDBOX | https://api.gateway.sandbox.retorna.app | sandbox/full_access |
| Producción | PRODUCTION | https://api.gateway.retorna.app | prod/full_access |
baseUrlOverride sirve para apuntar a cualquier otro host (solo https://):
const client = new RetornaClientBuilder()
.clientId("id")
.clientSecret("secret")
.baseUrlOverride("https://ifomkws6s0.execute-api.us-east-1.amazonaws.com/dev")
.buildClient();Entornos del SDK: develop / sandbox / production. En la infra el entorno sandbox se llama stg (stage de API Gateway y dominio Cognito b2b-retorna-stg); el SDK no expone ese nombre.
Publicación (mantenedores)
- Actualizá
versionenpackage.jsonySDK_VERSIONensrc/core/version.ts(un test los ata), y elCHANGELOG.md. - Mergeá a
mainy creá el tag:git tag v1.0.0 && git push origin v1.0.0. - El workflow
Releaseverifica que el tag coincida con la versión, corre typecheck, tests yverify:pack, y publica en npm. Necesita el secretNPM_TOKEN(granular access token con permiso de publicación sobre el paquete) en el environmentnpmdel repo. Sin provenance: npm solo la emite desde repos públicos y este es interno.
Licencia
Software propietario de Retorna. Su uso está permitido únicamente para integrar sistemas con Retorna Gateway bajo un acuerdo comercial vigente con Retorna. No se permite redistribuir, modificar ni sublicenciar. Ver LICENSE.
