@utilia-os/sdk-js
v4.1.0
Published
SDK JavaScript/TypeScript para UTILIA OS External Integrations
Maintainers
Readme
@utilia-os/sdk-js
SDK JavaScript/TypeScript para integrar aplicaciones externas con UTILIA OS.
Instalación
npm install @utilia-os/sdk-jsConfiguració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 ticketListar 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 ticketsObtener Detalle de Ticket
const ticket = await sdk.tickets.get('ticket-uuid', 'user-123');
console.log(ticket.title);
console.log(ticket.messages); // Array de mensajesAgregar 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íaListar 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 frescaCampos 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
businessActivitydeOrganizationPublicSettings. La actividad económica ya no forma parte de la configuración general de la organización; se ha migrado al modeloFiscalConfig.activityDesc, disponible vía el endpointfinance/fiscal-config(se añadirá un método dedicado al SDK en próximas versiones). Si tu integración leíasettings.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
canReceiveEmailsen TODAS tus listas. Una baja en UTILIA OS llega como webhookCONTACT_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,estimatedValueni la identidad de los usuarios internos (createdBy*,assignedTo*). Los camposscore,temperatureynotessolo aparecen con los scopes correspondientes yincludeexplí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
taxIdSOLO aparece enget()ybyTaxId()y exige scopecrm:clients:read:fiscal-data. En listados y búsquedas viaja comonullaunque 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 CRMtaxId 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 scopechat:messages:sendy una concesión (AppChannelGrant) activa en el canal. - v2 (leer):
listChannels(),getChannel(),listMembers(),listMessages(),getMessage(). Requierenchat:channels:view/chat:messages:view. El backend aplica un filtro de visibilidad fail-closed: la app nunca recibeTEAM_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
