npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@utilia-os/sdk-js

v4.1.0

Published

SDK JavaScript/TypeScript para UTILIA OS External Integrations

Readme

@utilia-os/sdk-js

SDK JavaScript/TypeScript para integrar aplicaciones externas con UTILIA OS.

Instalación

npm install @utilia-os/sdk-js

Configuración

import { UtiliaSDK } from '@utilia-os/sdk-js';

const sdk = new UtiliaSDK({
  baseURL: 'https://os.utilia.ai/api',
  apiKey: 'tu-api-key',
  timeout: 30000,  // opcional, default: 30000ms
  debug: false,    // opcional, habilita logs de debug
});

Catálogo de scopes (familia B, X-Api-Key)

Cada ExternalApp declara los permisos concedidos por el operador desde el panel de administración. Sin el scope adecuado, los endpoints responden 403 con errorCode: 'INSUFFICIENT_SCOPE'. Desde la versión 3.0.0, el SDK exporta los 56 scopes válidos como tipos y constantes tipadas, espejo exacto del catálogo del backend.

Dos listas marcan lo blindado, y no dicen lo mismo: RGPD_SENSITIVE_SCOPES reúne lo que expone datos personales o hace algo irreversible; FINANCIAL_CRITICAL_SCOPES reúne lo que mueve dinero o corta un ingreso. Conceder cualquiera de las dos familias exige rol de administrador general y motivo razonado, pero quien revisa la solicitud necesita saber por qué está blindada la capacidad.

import {
  type ExternalApiScope,
  EXTERNAL_API_SCOPES,
  EXTERNAL_API_SCOPE_DESCRIPTIONS,
  RGPD_SENSITIVE_SCOPES,
  FINANCIAL_CRITICAL_SCOPES,
  INSUFFICIENT_SCOPE_MESSAGE,
  isExternalApiScope,
} from '@utilia-os/sdk-js';

// 56 scopes válidos del catálogo
console.log(EXTERNAL_API_SCOPES.length); // 56

// Metadatos canónicos (label, descripción, sensibilidad RGPD)
const meta = EXTERNAL_API_SCOPE_DESCRIPTIONS['crm:invoices:cancel'];
console.log(meta.label, meta.requiresSuperAdmin); // 'Cancelar facturas', true

// Validar un valor recibido por configuración o webhook
if (!isExternalApiScope(unknownScope)) {
  throw new Error('Scope no válido');
}

| Dominio | Scopes | Notas | | ------------------------ | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | | Contactos CRM | crm:contacts:read, :write, :read:secondary-email, :read:notes, :read:birthday, :read:opted-out, crm:clients:read | Notas y opted-out son RGPD CRÍTICOS (SUPER_ADMIN). | | Leads CRM | crm:leads:read, :write, :read:scoring, :read:notes, :convert | Notas y convert son RGPD CRÍTICOS. | | Clientes CRM | crm:clients:write, :read:fiscal-data, :read:notes | Fiscal-data y notes son RGPD CRÍTICOS. | | Usuarios CRM | crm:users:read | Sprint 2026-05-25. Directorio interno. | | Facturación | crm:invoices:read, :write, :issue, :cancel | Sprint 2026-05-25. cancel es RGPD CRÍTICO. | | Tickets de soporte | support:tickets:read, :write, :ai | Sprint 2026-05-25. ai es RGPD CRÍTICO (IA sobre contenido sensible). | | File Manager | fm:files:read, :write, :delete | Sprint 2026-05-25. delete es RGPD CRÍTICO (irreversible). | | Billing | billing:payment-methods:read, :write, billing:charge | Sprint 2026-05-25. charge es RGPD CRÍTICO (mueve dinero). |

Los 10 scopes RGPD CRÍTICOS están listados en RGPD_SENSITIVE_SCOPES. Asignar cualquiera de ellos a una ExternalApp exige rol SUPER_ADMIN y motivo razonado de mínimo 20 caracteres, registrado en el AuditLog inmutable (Ley 11/2021).

Mensaje OPACO del 403: cuando el backend rechaza una llamada por falta de scope, devuelve el texto literal expuesto como INSUFFICIENT_SCOPE_MESSAGE: "Esta operación requiere permisos adicionales. Contacta con el administrador del espacio de trabajo donde está instalada tu app." NO incluye el nombre del scope ni deep-link al panel admin. La app integradora NO debe parsearlo para descubrir qué scope falta; debe instruir al usuario final a contactar con el administrador. Razón: filtrar el nombre del scope facilitaría a un atacante con acceso parcial mapear los recursos accesibles vía API.

Uso

Identificar Usuario

Antes de crear tickets, identifica al usuario en tu sistema:

const user = await sdk.users.identify({
  externalId: 'user-123',           // ID único en tu sistema (requerido)
  email: '[email protected]',        // opcional
  name: 'Juan Perez',               // opcional
  avatarUrl: 'https://...',         // opcional
  metadata: {                       // opcional, datos adicionales
    plan: 'premium',
    company: 'Acme Inc',
  },
});

Crear Ticket

const ticket = await sdk.tickets.create({
  user: {
    externalId: 'user-123',
    email: '[email protected]',
    name: 'Juan Perez',
  },
  title: 'Problema con la facturación',
  description: 'No puedo ver mis facturas del mes pasado...',
  category: 'BUG',  // BUG | CONSULTA | SUGERENCIA
  priority: 'MEDIA',     // BAJA | MEDIA | ALTA | CRÍTICA
  context: {             // opcional
    url: window.location.href,
    appVersion: '1.2.3',
    browserInfo: navigator.userAgent,
  },
});

console.log(ticket.ticketKey);  // APP-0001
console.log(ticket.id);         // uuid del ticket

Listar Tickets

const result = await sdk.tickets.list('user-123', {
  status: 'OPEN',  // OPEN | IN_REVIEW | RESOLVED | CLOSED
  page: 1,
  limit: 20,
});

console.log(result.data);              // Array de tickets
console.log(result.pagination.total);  // Total de tickets

Obtener Detalle de Ticket

const ticket = await sdk.tickets.get('ticket-uuid', 'user-123');

console.log(ticket.title);
console.log(ticket.messages);  // Array de mensajes

Agregar Mensaje

const message = await sdk.tickets.addMessage('ticket-uuid', 'user-123', {
  content: 'Gracias por la respuesta, el problema ya fue resuelto.',
});

Cerrar/Reabrir Ticket

// Cerrar
await sdk.tickets.close('ticket-uuid', 'user-123');

// Reabrir
await sdk.tickets.reopen('ticket-uuid', 'user-123');

Obtener Mensajes No Leídos

const { count } = await sdk.tickets.getUnreadCount('user-123');
if (count > 0) {
  console.log(`Tienes ${count} mensajes sin leer`);
}

Actualizaciones en Tiempo Real (SSE)

Recibe notificaciones cuando un agente responde, cambia el estado, resuelve o cierra un ticket:

const stream = sdk.tickets.streamUpdates('user-123', {
  onTicketUpdated: (event) => {
    console.log(`Ticket ${event.ticketKey} actualizado: ${event.type}`);
    // event.type: 'comment-added' | 'status-changed'
    // Refrescar la UI del ticket
  },
  onError: (error) => {
    console.error('Error en conexion SSE:', error);
  },
});

// Para cerrar la conexion cuando ya no se necesite:
stream.close();

Nota: En Node.js se requiere un polyfill como eventsource (npm install eventsource).

Reportar Error

const result = await sdk.errors.report({
  message: 'Error al procesar pago',
  module: 'payment-processor',
  severity: 'critical',
  stack: error.stack,
  endpoint: '/api/payments',
  method: 'POST',
  context: {
    gatewayId: 'stripe',
    orderId: 'order-456',
  },
});

console.log(result.hash);          // Hash único del error
console.log(result.deduplicated);   // true si el error ya existía

Listar Errores

const result = await sdk.errors.list({
  severity: ['critical', 'high'],
  resolved: false,
  limit: 20,
});

console.log(result.errors);
console.log(result.pagination.total);

Estadísticas de Errores

const stats = await sdk.errors.stats();

console.log(stats.total);        // Total de errores
console.log(stats.unresolved);   // Errores sin resolver
console.log(stats.bySeverity);   // { critical: 5, high: 10, ... }
console.log(stats.byModule);     // { auth: 3, payments: 7, ... }

Configuración pública de la organización

Desde la versión 1.10.0, puedes leer los datos públicos de la organización (branding, valores fiscales por defecto, contacto) sin necesidad de permisos de administración. Útil para auto-rellenar formularios de presupuestos, conocer el impuesto local del tenant o mostrar el logo en un portal externo:

const settings = await sdk.organizationSettings.getPublicSettings();

console.log(settings.companyName);        // "UTILIA"
console.log(settings.defaultCurrency);    // "EUR"
console.log(settings.defaultTaxName);     // "IVA"
console.log(settings.defaultTaxRate);     // 21
console.log(settings.country);            // "ES"
console.log(settings.timezone);           // "Atlantic/Canary"
console.log(settings.logoUrl);            // URL presignada fresca

Campos incluidos: identidad corporativa, domicilio social y operativo, canales de contacto dinámicos, datos bancarios, configuración fiscal por defecto (impuesto, tasa, país, moneda), timezone, branding (colores, logos) y la configuración visual personalizada para PDFs de presupuestos.

Los metadatos internos (id, createdAt, updatedAt, updatedById) y los emails legacy (emailSupport, emailHr, etc.) no se exponen en esta respuesta; los emails legacy se transforman automáticamente a contactChannels por retrocompatibilidad.

Breaking change en v2.0.0: se ha eliminado el campo businessActivity de OrganizationPublicSettings. La actividad económica ya no forma parte de la configuración general de la organización; se ha migrado al modelo FiscalConfig.activityDesc, disponible vía el endpoint finance/fiscal-config (se añadirá un método dedicado al SDK en próximas versiones). Si tu integración leía settings.businessActivity, debes migrar a la nueva ubicación.

Comentarios y firmas de presupuestos

Desde la versión 2.1.0, el SDK cubre los dos dominios centrales del flujo de aprobación de presupuestos: la conversación entre equipo y cliente y la firma electrónica con magic link.

Comentarios (sdk.budgetComments)

Dos visibilidades: INTERNAL (solo equipo) y CLIENT (visible también en el portal del cliente). Las menciones con mentionedUserIds solo tienen efecto cuando la visibilidad es INTERNAL.

import { UtiliaSDK } from '@utilia-os/sdk-js';

const sdk = new UtiliaSDK({ baseURL: 'https://os.utilia.ai/api', apiKey: '...' });

// Listar comentarios dirigidos al cliente
const page = await sdk.budgetComments.list(budgetId, {
  visibility: 'CLIENT',
  page: 1,
  limit: 20,
});

// Crear un comentario interno con menciones
const comment = await sdk.budgetComments.create(budgetId, {
  body: 'Revisar el descuento del item 2 antes de enviar al cliente.',
  visibility: 'INTERNAL',
  mentionedUserIds: ['6d1a4c6b-3f8b-4a0e-a0d1-b29f1f1c21cb'],
});

// Editar el propio comentario
await sdk.budgetComments.update(budgetId, comment.id, {
  body: 'Revisar descuento y condiciones de pago.',
});

// Crear varios comentarios en lote, tolerante a fallos
const result = await sdk.budgetComments.bulkCreate(
  budgetId,
  [
    { body: 'Primer comentario' },
    { body: 'Segundo comentario', clientOperationId: 'op-2' },
  ],
  { idempotencyKey: 'migration-2026-04-17' },
);
console.log(result.summary); // { total: 2, ok: 2, failed: 0 }

Firmas y magic link (sdk.budgetSignatures)

El flujo completo cubre la emisión del magic link, el listado y revocación, la verificación de integridad de una firma ya registrada y la descarga del certificado legal.

// Emitir un magic link (idempotente por email)
const link = await sdk.budgetSignatures.generateSigningLink(budgetId, {
  signerEmail: '[email protected]',
  signerName: 'María García',
  expiresInHours: 72,
  sendEmail: true,
});

if (!link.reused && link.signingUrl) {
  console.log('Enviar al cliente:', link.signingUrl);
}

// Listar los magic links vivos
const activeLinks = await sdk.budgetSignatures.listSigningLinks(budgetId);

// Revocar un link
await sdk.budgetSignatures.revokeSigningLink(budgetId, link.tokenId);

// Verificar la integridad de una firma ya registrada
const signatures = await sdk.budgetSignatures.list(budgetId);
const verification = await sdk.budgetSignatures.verify(
  budgetId,
  signatures[0].id,
);
if (!verification.valid) {
  console.warn('El documento ha cambiado después de la firma.');
}

// Descargar el certificado legal
const pdf = await sdk.budgetSignatures.downloadCertificate(
  budgetId,
  signatures[0].id,
);

Audit trail

Historial paginado de eventos del proceso de firma (emisión, visualización, descarga del PDF, aprobación, rechazo, expiración, revocación y fallos):

const events = await sdk.budgetSignatures.getAuditTrail(budgetId, {
  tokenId: link.tokenId,
  limit: 100,
});

Contactos para apps externas de envío de correos

Desde la versión 2.12.1, el SDK expone sdk.externalContacts para que aplicativos de envío de correos (Instantly, Smartlead, Apollo, Mailerlite, etc.) sincronicen el directorio de contactos del CRM, registren bajas y reactivaciones, y reciban los cambios en tiempo real vía webhooks. La autenticación es por X-Api-Key de la ExternalApp, con scopes específicos por campo.

Documentación completa de la API REST subyacente: docs/integrations/external-contacts-api.md.

import { UtiliaSDK } from '@utilia-os/sdk-js';

const sdk = new UtiliaSDK({
  baseURL: 'https://os.utilia.ai/api',
  apiKey: process.env.UTILIA_API_KEY!,
});

// 1) Sincronización inicial del catálogo
let cursor: string | null = null;
let hasMore = true;
while (hasMore) {
  const page = await sdk.externalContacts.sync({
    cursor: cursor ?? undefined,
    updatedAfter: cursor ? undefined : '1970-01-01T00:00:00.000Z',
    limit: 200,
    include: ['secondaryEmail'],
  });
  for (const contact of page.data) {
    await miAppExterna.upsertSuscriptor(contact);
  }
  cursor = page.nextCursor;
  hasMore = page.hasMore;
}

// 2) Buscar por email (timing-uniform; null si no existe)
const contact = await sdk.externalContacts.byEmail('[email protected]');

// 3) Registrar baja (idempotente)
const optOut = await sdk.externalContacts.unsubscribe(contact!.id, {
  reason: 'Solicitud del propio contacto desde el footer del correo',
  source: 'external_app',
});
if (!optOut.changed) {
  console.log('El contacto ya estaba dado de baja');
}

// 4) Reactivar tras nuevo consentimiento (prueba documental obligatoria)
await sdk.externalContacts.resubscribe(contact!.id, {
  reason: 'El contacto se ha vuelto a registrar desde la web',
  proof: {
    type: 'double_opt_in',
    capturedAt: new Date().toISOString(),
    sourceUrl: 'https://aplicativo-externo.com/proofs/abc123',
  },
});

list() y search() devuelven el mismo shape paginado { data, pagination }; unsubscribe() y resubscribe() devuelven { contact, changed, recordedAt } para soportar idempotencia desde el aplicativo externo. Si la ExternalApp no tiene crm:clients:read, clientIds llega como [] y primaryClient como null; primaryClientId se mantiene siempre para correlación opaca.

Importante: respeta el campo canReceiveEmails en TODAS tus listas. Una baja en UTILIA OS llega como webhook CONTACT_OPTED_OUT (con anti-bucle: la app origen no recibe su propio evento) y debe materializarse como exclusión inmediata en las listas del receptor.

Leads para apps externas

Desde la versión 2.13.0, el SDK expone sdk.externalLeads para que aplicativos de prospección y plataformas de sincronización CRM bidireccional operen sobre los leads del CRM sin acceder al CRM interno. La autenticación es por X-Api-Key de la ExternalApp, con scopes específicos por campo (crm:leads:read, crm:leads:write, crm:leads:read:scoring, crm:leads:read:notes, crm:leads:convert).

import { UtiliaSDK } from '@utilia-os/sdk-js';

const sdk = new UtiliaSDK({
  baseURL: 'https://os.utilia.ai/api',
  apiKey: process.env.UTILIA_API_KEY!,
});

// 1) Sincronización inicial del catálogo de leads activos
let cursor: string | null = null;
let hasMore = true;
while (hasMore) {
  const page = await sdk.externalLeads.sync({
    cursor: cursor ?? undefined,
    updatedAfter: cursor ? undefined : '1970-01-01T00:00:00.000Z',
    limit: 100,
    include: ['scoring'],
  });
  for (const lead of page.data) {
    await miAppExterna.upsertLead(lead);
  }
  cursor = page.nextCursor;
  hasMore = page.hasMore;
}

// 2) Buscar lead por email (timing-uniform; null si no existe)
const lead = await sdk.externalLeads.byEmail('[email protected]');

// 3) Cualificar el lead (transición NEW → QUALIFIED)
if (lead) {
  await sdk.externalLeads.qualify(lead.id, {
    reason: 'El lead ha solicitado una demo concreta del producto',
    source: 'external_app',
  });
}

// 4) Descalificar por rebote permanente
await sdk.externalLeads.disqualify('ld-42', {
  reason: 'Rebote permanente notificado por el proveedor SMTP',
  source: 'bounce',
});

// 5) Convertir a Contact + Opportunity (operación sensible)
const conv = await sdk.externalLeads.convert('ld-77', {
  reason:
    'El lead ha aceptado el presupuesto y solicita formalizar el contrato',
  createOpportunity: true,
});
console.log('Contact creado:', conv.contactId, 'Cliente:', conv.clientId);

qualify, disqualify y convert devuelven { lead, changed, recordedAt } (la conversión añade contactId, opportunityId y clientId) para soportar idempotencia desde el aplicativo externo. La conversión exige reason con al menos 20 caracteres; el SDK valida la longitud antes de enviar la petición.

Importante: por diseño esta API NO expone metadata, tags, consentSnapshot, estimatedValue ni la identidad de los usuarios internos (createdBy*, assignedTo*). Los campos score, temperature y notes solo aparecen con los scopes correspondientes y include explícito.

Clientes para apps externas

Desde la versión 2.13.0, el SDK expone sdk.externalClients para que aplicativos de facturación, plataformas de sincronización CRM y herramientas de prospección operen sobre los clientes (empresas / cuentas) del CRM sin acceder al CRM interno. La autenticación es por X-Api-Key de la ExternalApp, con scopes específicos por campo (crm:clients:read, crm:clients:write, crm:clients:read:fiscal-data, crm:clients:read:notes).

import { UtiliaSDK } from '@utilia-os/sdk-js';

const sdk = new UtiliaSDK({
  baseURL: 'https://os.utilia.ai/api',
  apiKey: process.env.UTILIA_API_KEY!,
});

// 1) Listado paginado (taxId NUNCA aparece en listados)
const page = await sdk.externalClients.list({
  status: ['ACTIVE'],
  size: ['PEQUENA_EMPRESA', 'MEDIANA'],
  sortBy: 'updatedAt',
});

// 2) Detalle con datos fiscales (requiere scope crm:clients:read:fiscal-data)
const client = await sdk.externalClients.get('cli-42', {
  include: ['fiscalData', 'notes'],
});
console.log(client.taxId, client.fiscalAddress);

// 3) Búsqueda exacta por NIF/CIF (timing-uniform, rate limit 100 req/h)
const matched = await sdk.externalClients.byTaxId('B12345678');
if (matched === null) {
  console.log('NIF no indexado');
}

// 4) Contactos vinculados al cliente
const contacts = await sdk.externalClients.listContacts('cli-42', {
  canReceiveEmails: true,
  limit: 50,
});

// 5) Desactivar el cliente (única forma de retirarlo vía API externa)
const result = await sdk.externalClients.deactivate('cli-42', {
  reason: 'Solicitud explícita del cliente desde la app externa',
  source: 'external_app',
});
if (!result.changed) {
  console.log('El cliente ya estaba inactivo');
}

deactivate devuelve { client, changed, recordedAt } para soportar idempotencia. Si el cliente ya estaba inactivo el backend responde 409 CLIENT_ALREADY_INACTIVE. NO existe un equivalente a DELETE: la única forma de retirar un cliente vía API externa es deactivate.

Importante: por diseño esta API NO expone IBAN ni datos financieros. El taxId SOLO aparece en get() y byTaxId() y exige scope crm:clients:read:fiscal-data. En listados y búsquedas viaja como null aunque la app tenga el scope, por política de mínima exposición.

Facturación y cobros

Desde la versión 3.0.0 el ciclo completo de facturación y cobro de una aplicación externa se recorre con la clave de API, sin salir del SDK. Las facturas que emite tu aplicación son facturas de UTILIA OS: misma numeración, misma fiscalidad, mismo sellado VeriFactu y mismo motor de envío que las del equipo.

Catálogo de servicios y su credencial

| Servicio | Qué hace | Credencial | |---|---|---| | sdk.users | Da de alta al usuario final y lo vincula con su ficha de cliente | X-Api-Key | | sdk.paymentMethods | Guarda y gestiona las tarjetas del usuario, y dice si la organización puede cobrar | X-Api-Key | | sdk.paymentAuthorizations | Recoge y registra el permiso para cobrar sin el usuario delante | X-Api-Key | | sdk.invoices | Emite, lista, envía por correo, cobra, marca cobrada y devuelve | X-Api-Key | | sdk.invoices.scheduledCharges | Programa el cobro de una factura a fecha futura | X-Api-Key | | sdk.invoices.refunds | Devuelve dinero ya cobrado | X-Api-Key | | sdk.subscriptions | Cuotas que se repiten y se cobran solas | X-Api-Key | | sdk.payments | Cobro CON el usuario delante, montando la pasarela en tu interfaz | X-Api-Key | | sdk.webhooks | Verifica la firma de los avisos entrantes (no hace red) | ninguna | | sdk.mcp.* | Herramientas para copilotos | sesión OAuth |

Todo lo demás del producto que no aparezca en esa tabla vive en rutas internas que exigen una sesión de usuario iniciada, y el SDK no las alcanza.

El recorrido completo

1. El usuario y su ficha de cliente. Todo cuelga de aquí: facturas, tarjetas, autorizaciones y suscripciones.

const usuario = await sdk.users.identify({
  externalId: 'user_01HXYZ',
  email: '[email protected]',
  name: 'Estudio Marea SL',
  taxId: 'B76543210',
  billingAddress: {
    street: 'Avenida de Canarias 12, planta 3',
    city: 'Las Palmas de Gran Canaria',
    postalCode: '35001',
    country: 'España',
  },
});
console.log(usuario.clientId); // ficha del cliente en el CRM

taxId y billingAddress se propagan a la ficha del CRM y a la de la pasarela de pago: son los datos que salen impresos en el PDF de las facturas, y el NIF es lo que mira primero el puente para reconocer una ficha que ya existía. Mándalos antes de emitir la primera factura; corregirlos después obliga a rectificarla.

2. La tarjeta y la autorización de cobro. Guardar una tarjeta NO autoriza cobros. La autorización exige que el usuario acepte el texto de consentimiento canónico, que sirve el backend y debes mostrar TAL CUAL: es prueba legal.

Antes de enseñar nada de esto, pregunta si la organización puede cobrar. Si su pasarela no está lista, un botón de pagar es una promesa que fallará:

const estado = await sdk.paymentMethods.getReadiness();
if (!estado.organizationCanCharge) {
  // `PLATFORM_DISABLED` no lo resuelve nadie desde tu aplicación.
  // Los otros cuatro motivos los resuelve la organización en
  // Ajustes de Finanzas, Pagos con tarjeta.
  mostrarAviso(estado.reason);
  return;
}
const intent = await sdk.paymentMethods.createSetupIntent({
  userId: 'user_01HXYZ',
});
// ... tu interfaz confirma el SetupIntent con la pasarela ...

const consentimiento = await sdk.paymentAuthorizations.getConsentText({
  scope: 'RECURRING',
});
// ... muestras `consentimiento.text` sin cambiar ni una coma ...

const autorizacion = await sdk.paymentAuthorizations.create({
  userId: 'user_01HXYZ',
  setupIntentId: intent.setupIntentId,
  consentText: consentimiento.text,
  consentVersion: consentimiento.version,
  acceptedAt: new Date().toISOString(),
  acceptedIp: peticion.ip,
  acceptedUserAgent: peticion.headers['user-agent'],
  scope: 'RECURRING',
});

3. La factura y su correo. La factura se numera y se sella al crearla. Con sendEmail: true sale por correo en el mismo acto, con su PDF adjunto, usando la plantilla y el remitente de la organización.

const factura = await sdk.invoices.create({ /* ... */, sendEmail: true });
if (factura.emailSent === false) {
  // La factura ESTÁ emitida y numerada: solo falló el correo.
  // Reenvíala; no vuelvas a crearla.
  await sdk.invoices.send(factura.id, { userId: 'user_01HXYZ' });
}

4. La suscripción. Con chargeMode: 'AUTO_STRIPE' y sin autorización, el alta NO se degrada a cobro manual: falla con PAYMENT_AUTHORIZATION_REQUIRED, para que no creas que has montado un cobro que nunca se ejecutará.

const suscripcion = await sdk.subscriptions.create({
  userId: 'user_01HXYZ',
  name: 'Plan Profesional mensual',
  startDate: '2026-10-01',
  frequency: 'MONTHLY_FIXED_DAY',
  dayOfMonth: 1,
  chargeMode: 'AUTO_STRIPE',
  paymentAuthorizationId: autorizacion.id,
  sendEmail: true,
  lines: [{ name: 'Plan Profesional', unitPrice: 49.95, taxType: 'IGIC_7' }],
});

5. Cobros y reintentos. charge cobra sin el usuario delante; scheduledCharges.schedule deja el cobro programado para una fecha futura. Un status: 'REQUIRES_ACTION' no es un error: el banco pide autenticación reforzada y hay que llevar al usuario al publicUrl de la respuesta.

Para programar un cobro sobre una factura suelta hace falta una autorización de alcance SCHEDULED_INVOICE. La de alcance RECURRING cubre las cuotas de una suscripción y el backend la rechaza con 409 PAYMENT_AUTHORIZATION_NOT_ACTIVE. Si tu aplicación hace las dos cosas, recoge las dos autorizaciones.

scheduledCharges.retryNow tiene un tope de cinco reintentos manuales por factura en 24 horas, no por cobro programado. Al superarlo responde 409 con el mensaje del límite, y ese 409 no trae errorCode: se reconoce por el estado y el mensaje.

const resultado = await sdk.invoices.charge(factura.id, {
  userId: 'user_01HXYZ',
  idempotencyKey: `cuota-${factura.id}`,
});
if (resultado.status === 'REQUIRES_ACTION') {
  enviarAlUsuario(resultado.publicUrl);
}

6. Los avisos. El resultado de un cobro programado NO llega en la respuesta de la llamada que lo creó: llega por webhook. parse verifica la firma y devuelve el evento tipado.

app.post('/webhooks/utilia', express.raw({ type: 'application/json' }), (req, res) => {
  const evento = sdk.webhooks.parse(
    req.body.toString('utf8'),
    req.headers,
    process.env.UTILIA_WEBHOOK_SECRET,
  );
  switch (evento.event) {
    case 'CHARGE_SUCCEEDED':
      marcarComoPagado(evento.data.invoiceId);
      break;
    case 'CHARGE_REQUIRES_ACTION':
      // `publicUrl` llega con valor si el cobro lo lanzaste tú, y NULO
      // si viene del motor de cobros programados: ahí hay que emitir un
      // enlace nuevo y entregárselo al usuario.
      avisarAlUsuario(
        evento.data.publicUrl ??
          (await sdk.invoices.regeneratePublicLink(evento.data.invoiceId, {
            userId: evento.data.externalUserId,
          })).publicUrl,
      );
      break;
    case 'CHARGE_RETRIES_EXHAUSTED':
      suspenderServicio(evento.data.externalUserId);
      break;
  }
  res.sendStatus(200);
});

7. El reembolso. Anular una factura no devuelve dinero; esto sí.

await sdk.invoices.refunds.create(factura.id, {
  userId: 'user_01HXYZ',
  reason: 'requested_by_customer',
  idempotencyKey: `devolucion-${factura.id}`,
});

El PDF nunca sale de tu servidor, y el enlace público se entrega una vez

La ruta del PDF de la API externa exige la clave de API, y esa clave no debe salir de tu servidor. Para entregar la factura a tu usuario final hay dos vías y ninguna incluye la clave:

  • sdk.invoices.downloadPdf(id, userId) devuelve los bytes en tu servidor.
  • sdk.invoices.regeneratePublicLink(id, { userId }) emite un enlace y devuelve la dirección pública, que sí puedes enviar por correo o abrir en el navegador.

getPdfUrl se retiró en 3.0.0 porque hacía justo lo contrario.

Guarda la dirección cuando la emitas. Del enlace emitido el backend guarda solo su huella, así que no se puede recuperar: sdk.invoices.getPublicLink(id, { userId }) es una lectura pura que informa de la vigencia, la caducidad y las acciones permitidas, y devuelve publicUrl: null SIEMPRE. Sin enlace vigente responde 404 con INVOICE_PUBLIC_LINK_NOT_AVAILABLE.

Emitir revoca el anterior. Si vuelves a emitir, el enlace que ya habías repartido deja de servir. Un cobro con charge que acabe en REQUIRES_ACTION entrega un enlace adicional, y ese NO revoca el que el usuario ya tuviera.

Todos los enlaces que emite la API externa permiten solo ver y pagar (VIEW, PAY), también los de una factura de suscripción. Firmar o retirar una autorización de cobro recurrente se recoge por su camino propio.

Chat para apps externas (sdk.chat)

Desde la versión 2.20.0, el SDK expone sdk.chat para que una aplicación externa participe en los canales de chat de UTILIA OS como una identidad propia (EXTERNAL_APP): la cuarta autoría visible junto a personas, usuarios del portal y agentes de IA. Superficie REST /external/v1/chat.

  • v1 (publicar): sendMessage(). Requiere scope chat:messages:send y una concesión (AppChannelGrant) activa en el canal.
  • v2 (leer): listChannels(), getChannel(), listMembers(), listMessages(), getMessage(). Requieren chat:channels:view / chat:messages:view. El backend aplica un filtro de visibilidad fail-closed: la app nunca recibe TEAM_ONLY/PRIVATE, respuestas privadas de IA ni mensajes borrados.

Doble puerta: el scope de la app autoriza la acción y la concesión por canal autoriza el recurso. El organizationId lo fija el backend desde la credencial; para un canal no concedido o de otro tenant responde 404.

// Publicar una lectura como la propia app en un canal concedido.
const message = await sdk.chat.sendMessage(channelId, {
  content: 'Termómetro del cliente: 72/100 (▲ +4 esta semana).',
});

// Leer el historial visible (cursor descendente).
const page = await sdk.chat.listMessages(channelId, { limit: 50 });

Verificar la firma de un webhook

sdk.chat.webhooks.verify() valida localmente (sin red) la firma HMAC de los webhooks entrantes: HMAC-SHA256 sobre ${timestamp}.${rawBody} con comparación de tiempo constante y ventana anti-replay (300 s por defecto). Devuelve el payload tipado o lanza UtiliaSDKError.

const payload = sdk.chat.webhooks.verify(rawBody, {
  signature: req.headers['x-utilia-signature'] as string,
  timestamp: req.headers['x-utilia-timestamp'] as string,
  secret: process.env.UTILIA_WEBHOOK_SECRET!,
});

Ejemplo completo: el "Termómetro" (anti-bucle)

El "Termómetro" publica lecturas como app y reacciona a los mensajes de personas. El anti-bucle es esencial: solo responde si el autor NO es una app (author.kind !== 'APP'), de modo que no reacciona a sí mismo ni a otras apps.

// Endpoint del webhook en el servidor del integrador.
app.post('/webhooks/utilia', async (req, res) => {
  let payload;
  try {
    payload = sdk.chat.webhooks.verify(req.rawBody, {
      signature: req.headers['x-utilia-signature'],
      timestamp: req.headers['x-utilia-timestamp'],
      secret: process.env.UTILIA_WEBHOOK_SECRET,
    });
  } catch {
    return res.status(401).end(); // firma inválida o fuera de ventana
  }

  if (payload.event === 'chat.message.created') {
    const message = await sdk.chat.getMessage(payload.data.messageId);
    // Anti-bucle: no reaccionar a mensajes de apps (incluida la nuestra).
    if (message.author.kind !== 'APP') {
      await sdk.chat.sendMessage(message.channelId, {
        content: 'Recibido. Recalculo el termómetro…',
        parentMessageId: message.id,
      });
    }
  }
  res.status(200).end();
});

Rectificativas y notas de crédito: reservadas al equipo

Desde la versión 3.0.0, el SDK no expone la emisión ni el parche de metadata legal de una rectificativa. Cualquier llamada a sdk.invoices.rectifications.updateLegalMetadata(...) lanza un error con la vía correcta.

El motivo: emitir una rectificativa exige elegir su código legal (R1 a R5 del RD 1619/2012) y de esa elección depende cómo la Agencia Tributaria clasifica el documento. Es una decisión del equipo que lleva la contabilidad, no de una aplicación que factura. La vía oficial es el panel de UTILIA OS o la herramienta MCP crm_invoices_rectifications.

Lo que sí puede hacer una aplicación externa es devolver el dinero:

const reembolso = await sdk.invoices.refunds.create('inv-uuid', {
  userId: 'user_01HXYZ',
  reason: 'requested_by_customer',
  // Por defecto NO se emite la rectificativa: la emite el equipo para
  // elegir su código legal. Pídela aquí solo si tu organización lo ha
  // decidido así.
  createRectifyingInvoice: false,
});

Hasta la 2.28.0 estos métodos apuntaban a /finance/*, rutas internas que exigen una sesión de usuario iniciada: devolvían 401 con la clave de API y también con OAuth, porque el token OAuth solo se resuelve en el carril MCP.

Errores tipados de facturación

Desde la versión 2.6.0, el SDK exporta tipos canónicos para el código INVOICE_TAX_COHERENCE_ISSUES. El backend lo emite con HTTP 400 cuando la factura tiene incongruencias fiscales (IGIC en peninsular, IVA en Canarias, taxType nulo, tipo no perteneciente al sistema, etc.) antes de persistir o emitir. Endpoints emisores: POST /finance/invoices, POST /finance/invoices/:id/issue y POST /external/v1/invoices.

import {
  UtiliaSDK,
  UtiliaSDKError,
  INVOICE_TAX_COHERENCE_ERROR_CODE,
  type InvoiceTaxCoherenceErrorPayload,
} from '@utilia-os/sdk-js';

try {
  await sdk.invoices.create({ /* ... */ });
} catch (error) {
  if (
    error instanceof UtiliaSDKError &&
    error.errorCode === INVOICE_TAX_COHERENCE_ERROR_CODE
  ) {
    // Cuando el backend devuelve este código, la corrección es local:
    // arregla los `taxType` de las líneas y vuelve a intentarlo. NO
    // reintentes automáticamente (no es un error transitorio).
    console.warn('La factura tiene incoherencias fiscales. Revisa el taxType de cada línea.');
  }
  throw error;
}

Si tu cliente HTTP captura el cuerpo del 400 y necesitas el detalle por línea, parséalo con el tipo importado o consulta el endpoint POST /finance/invoices/preview-tax-coherence (interno, OAuth) antes de intentar crear o emitir. Su respuesta tiene exactamente la forma de InvoiceTaxCoherenceErrorPayload:

function describeTaxCoherenceError(payload: InvoiceTaxCoherenceErrorPayload): string[] {
  return payload.issues.map((issue) => {
    switch (issue.code) {
      case 'NULL_TAX_TYPE':
        return `Línea ${issue.lineIndex + 1}: falta el tipo impositivo. ` +
               (issue.suggestedTaxType ? `Sugerencia: ${issue.suggestedTaxType}.` : '');
      case 'INCOHERENT_WITH_SYSTEM':
        return `Línea ${issue.lineIndex + 1}: ${issue.currentTaxType ?? 'sin tipo'} ` +
               `no es compatible con el sistema ${payload.taxSystem}. ` +
               (issue.suggestedTaxType ? `Cambia a ${issue.suggestedTaxType}.` : '');
      case 'RATE_NOT_IN_SYSTEM':
        return `Línea ${issue.lineIndex + 1}: el porcentaje ${issue.currentTaxRate}% ` +
               `no existe en el catálogo del sistema ${payload.taxSystem}.`;
      case 'LEGACY_HEADER_RATE_MISMATCH':
        return `Línea ${issue.lineIndex + 1}: tipo legado en la cabecera no coincide ` +
               `con el calculado por línea. Migración pendiente.`;
    }
  });
}

OAuth y Sign In

Desde la versión 1.6.0, el SDK incluye soporte nativo para OAuth 2.1 con PKCE. Esto permite implementar "Iniciar sesión con UTILIA" en tu aplicación:

const sdk = new UtiliaSDK({
  baseURL: 'https://os.utilia.ai/api',
  oauth: {
    clientId: 'client_xxxxxxxxxx',
    redirectUri: 'http://localhost:3000/callback',
    scopes: ['openid', 'profile', 'email'],
  },
});

// Redirigir al usuario para autorizar
const authUrl = await sdk.oauth.getAuthorizationUrl();
window.location.href = authUrl;

// Opcionalmente, solicitar un tema específico para la pantalla de consentimiento
const darkUrl = await sdk.oauth.getAuthorizationUrl({ theme: 'dark' });
window.location.href = darkUrl;

// En la página de callback
const tokens = await sdk.oauth.handleCallback(code);
const userInfo = await sdk.oauth.getUserInfo();

Documentación completa: https://os.utilia.ai/dashboard/docs/integrar-sdk/sdk-js-guia-oauth

Manejo de Errores

import { UtiliaSDK, UtiliaSDKError, ErrorCode } from '@utilia-os/sdk-js';

try {
  await sdk.tickets.create({ ... });
} catch (error) {
  if (error instanceof UtiliaSDKError) {
    switch (error.code) {
      case ErrorCode.UNAUTHORIZED:
        console.error('API Key inválida');
        break;
      case ErrorCode.RATE_LIMITED:
        console.error('Demasiadas peticiones, espera antes de reintentar');
        break;
      case ErrorCode.VALIDATION_ERROR:
        console.error('Datos inválidos:', error.message);
        break;
      case ErrorCode.NOT_FOUND:
        console.error('Recurso no encontrado');
        break;
      default:
        console.error('Error:', error.message);
    }

    // Verificar si se puede reintentar
    if (error.isRetryable()) {
      // Implementar lógica de reintento
    }
  }
}

Tipos

El SDK exporta todos los tipos TypeScript necesarios:

import type {
  // Configuración
  UtiliaSDKConfig,

  // Tickets
  CreateTicketInput,
  TicketFilters,
  AddMessageInput,
  CreatedTicket,
  TicketListItem,
  TicketDetail,
  TicketMessage,

  // Usuarios
  IdentifyUserInput,
  ExternalUser,

  // Errores del sistema
  ReportErrorInput,
  ReportedError,
  SystemError,
  ErrorFilters,
  ErrorStats,

  // Facturación y cobros (3.0.0)
  CreateExternalSubscriptionInput,
  ExternalSubscription,
  ExternalUpcomingCharge,
  CreateExternalPaymentAuthorizationInput,
  ExternalPaymentAuthorization,
  ExternalMandateConsentText,
  CreateExternalScheduledChargeInput,
  ExternalScheduledCharge,
  SendExternalInvoiceInput,
  ChargeExternalInvoiceInput,
  ExternalInvoiceChargeResult,
  MarkExternalInvoicePaidInput,
  RefundExternalInvoiceInput,
  ExternalInvoiceRefund,
  ExternalInvoicePublicLink,

  // Avisos de webhook tipados evento a evento (3.0.0)
  BillingWebhookEvent,
  SubscriptionWebhookEvent,
  ChargeWebhookEvent,

  // Comunes
  TicketStatus,
  TicketCategory,
  TicketPriority,
  PaginatedResponse,
} from '@utilia-os/sdk-js';

Requisitos

  • Node.js >= 18.0.0
  • TypeScript >= 4.7 (opcional, para tipos)

Licencia

MIT