SDK Python para integrar aplicaciones externas con UTILIA OS
Project description
UTILIA OS SDK para Python
SDK Python para integrar aplicaciones externas con el sistema de soporte de UTILIA OS.
Instalación
pip install utilia-sdk
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 2.15.0, el SDK exporta los 29 scopes válidos como tipos
Literal y constantes tipadas.
from utilia_sdk import (
EXTERNAL_API_SCOPES,
EXTERNAL_API_SCOPE_DESCRIPTIONS,
INSUFFICIENT_SCOPE_MESSAGE,
RGPD_SENSITIVE_SCOPES,
ExternalApiScope,
is_external_api_scope,
)
# 29 scopes válidos del catálogo
assert len(EXTERNAL_API_SCOPES) == 29
# Metadatos canónicos (label, descripción, sensibilidad RGPD)
meta = EXTERNAL_API_SCOPE_DESCRIPTIONS["crm:invoices:cancel"]
print(meta.label, meta.requires_super_admin) # 'Cancelar facturas' True
# Validar un valor recibido por configuración o webhook
if not is_external_api_scope(unknown_scope):
raise ValueError("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 rápido
Asíncrono (recomendado)
from utilia_sdk import UtiliaSDK, CreateTicketInput, CreateTicketUser, IdentifyUserInput
async with UtiliaSDK(base_url="https://os.utilia.ai/api", api_key="tu-api-key") as sdk:
# Identificar usuario
user = await sdk.users.identify(
IdentifyUserInput(external_id="user-123", email="user@example.com")
)
# Crear ticket
ticket = await sdk.tickets.create(
CreateTicketInput(
user=CreateTicketUser(external_id="user-123"),
title="Problema con facturación",
description="No puedo ver mis facturas del mes pasado...",
)
)
print(ticket.ticket_key) # APP-0001
Síncrono
from utilia_sdk import UtiliaSDKSync, CreateTicketInput, CreateTicketUser
with UtiliaSDKSync(base_url="https://os.utilia.ai/api", api_key="tu-api-key") as sdk:
ticket = sdk.tickets.create(
CreateTicketInput(
user=CreateTicketUser(external_id="user-123"),
title="Problema con facturación",
description="No puedo ver mis facturas del mes pasado...",
)
)
Servicios disponibles
sdk.tickets- Tickets de soporte (crear, listar, mensajes, cerrar, reabrir)sdk.users- Usuarios externos (identificar, listar)sdk.files- Archivos adjuntos (subir, obtener URL, quota)sdk.ai- IA (sugerencias, transcripción)sdk.errors- Errores del sistema (reportar, listar, estadísticas)sdk.budgets- Presupuestos CRM (CRUD, items, secciones, flujo, PDF, IA)sdk.budget_templates- Plantillas de presupuesto reutilizablessdk.budget_comments- Comentarios de presupuesto con visibilidadINTERNAL/CLIENTsdk.budget_signatures- Firma electrónica, magic links y certificado legalsdk.organization_settings- Configuración pública de la organizaciónsdk.invoices- Facturación externa (crear, listar, detalle, PDF, anular, estadísticas)sdk.payment_methods- Métodos de pago guardados del usuario final (SetupIntent Stripe)sdk.payments- Cobro de facturas y consulta de PaymentIntentssdk.external_contacts- Contactos del CRM para apps externas de envío de correos (sync delta, opt-out/opt-in, webhooksCONTACT_*)sdk.external_leads- Leads del CRM para apps externas de prospección y sincronización CRM bidireccional (listado, sync delta,qualify/disqualify/convert, webhooksLEAD_*)sdk.external_clients- Clientes (empresas / cuentas) del CRM para apps externas de facturación y sincronización CRM bidireccional (listado, búsqueda por NIF/CIF timing-uniform, contactos vinculados,deactivate, webhooksCLIENT_*)sdk.chat- Plataforma de chat extensible (/external/v1/chat): publicar mensajes como app (send_message, v1) y leer canales, miembros e historial visible (list_channels/get_channel/list_members/list_messages/get_message, v2), máswebhooks.verify()para validar la firma HMAC de los webhooks entrantes
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.
Comentarios (async)
from utilia_sdk import (
UtiliaSDK,
CreateBudgetCommentInput,
BudgetCommentVisibility,
ListBudgetCommentsFilter,
)
async with UtiliaSDK(base_url="https://os.utilia.ai/api", api_key="tu-api-key") as sdk:
# Listar comentarios del cliente
page = await sdk.budget_comments.list(
budget_id,
ListBudgetCommentsFilter(visibility=BudgetCommentVisibility.CLIENT, page=1, limit=20),
)
# Crear un comentario interno con menciones
comment = await sdk.budget_comments.create(
budget_id,
CreateBudgetCommentInput(
body="Revisar el descuento del item 2 antes de enviar.",
visibility=BudgetCommentVisibility.INTERNAL,
mentioned_user_ids=["6d1a4c6b-3f8b-4a0e-a0d1-b29f1f1c21cb"],
),
)
Firmas y magic link (async)
from utilia_sdk import UtiliaSDK, SigningLinkRequest
async with UtiliaSDK(base_url="https://os.utilia.ai/api", api_key="tu-api-key") as sdk:
# Emitir magic link (idempotente por email)
link = await sdk.budget_signatures.generate_signing_link(
budget_id,
SigningLinkRequest(
signer_email="cliente@empresa.com",
signer_name="María García",
expires_in_hours=72,
send_email=True,
),
)
if not link.reused and link.signing_url:
print("Enviar al cliente:", link.signing_url)
# Listar, verificar y certificar
signatures = await sdk.budget_signatures.list(budget_id)
verification = await sdk.budget_signatures.verify(budget_id, signatures[0].id)
if not verification.valid:
print("El documento ha cambiado después de la firma")
pdf_bytes = await sdk.budget_signatures.download_certificate(
budget_id, signatures[0].id
)
Variantes síncronas
Todas las operaciones están disponibles también en UtiliaSDKSync:
from utilia_sdk import UtiliaSDKSync, SigningLinkRequest
with UtiliaSDKSync(base_url="https://os.utilia.ai/api", api_key="tu-api-key") as sdk:
link = sdk.budget_signatures.generate_signing_link(
budget_id,
SigningLinkRequest(signer_email="cliente@empresa.com"),
)
Facturación y pagos
Desde la versión 2.2.0, el SDK cubre el ciclo completo de facturación externa respaldado por Stripe Connect: emisión de facturas numeradas (AEAT/VeriFactu), guardado de tarjetas del usuario final y cobro on-session u off-session.
Requiere que la aplicación externa tenga billingEnabled = true en la
configuración de la organización.
Emitir una factura (síncrono)
from utilia_sdk import (
UtiliaSDKSync,
CreateInvoiceInput,
CreateInvoiceLine,
CreateInvoiceRecipient,
CreateInvoiceUser,
)
with UtiliaSDKSync(base_url="https://os.utilia.ai/api", api_key="utl_...") as sdk:
invoice = sdk.invoices.create(
CreateInvoiceInput(
user=CreateInvoiceUser(
external_id="user_123",
email="cliente@empresa.com",
tax_id="B76543210",
),
recipient=CreateInvoiceRecipient(
name="Empresa Receptora SL",
tax_id="B12345678",
address="Calle Mayor 1",
city="Madrid",
postal_code="28001",
country="España",
),
lines=[
CreateInvoiceLine(
name="Plan Premium mensual",
quantity=1,
unit_price=49.95,
tax_type="IVA_21",
),
],
idempotency_key="ord_01HXYZ-invoice",
)
)
print(invoice.invoice_number) # p. ej. 202600001
print(invoice.pdf_url)
Emitir una factura (asíncrono)
from utilia_sdk import (
UtiliaSDK,
CreateInvoiceInput,
CreateInvoiceLine,
CreateInvoiceRecipient,
CreateInvoiceUser,
)
async with UtiliaSDK(base_url="https://os.utilia.ai/api", api_key="utl_...") as sdk:
invoice = await sdk.invoices.create(
CreateInvoiceInput(
user=CreateInvoiceUser(external_id="user_123", email="x@y.com"),
recipient=CreateInvoiceRecipient(
name="Empresa SL",
address="Calle Mayor 1",
city="Madrid",
postal_code="28001",
country="España",
),
lines=[
CreateInvoiceLine(
name="Plan",
quantity=1,
unit_price=49.95,
tax_type="IVA_21",
),
],
)
)
Listado, detalle, PDF y anulación
from utilia_sdk import UtiliaSDKSync, InvoiceStatus, ListInvoicesFilters
with UtiliaSDKSync(base_url="https://os.utilia.ai/api", api_key="utl_...") as sdk:
# Listar facturas del usuario con filtros
page = sdk.invoices.list(
"user_123",
ListInvoicesFilters(status=InvoiceStatus.ISSUED, page=1, limit=20),
)
for invoice in page.invoices:
print(invoice.invoice_number, invoice.total_amount, invoice.currency)
# Detalle con líneas e impuestos
detail = sdk.invoices.get(page.invoices[0].id, "user_123")
print(detail.recipient_name, detail.lines)
# PDF como bytes
pdf_bytes = sdk.invoices.download_pdf(detail.id, "user_123")
with open("factura.pdf", "wb") as fh:
fh.write(pdf_bytes)
# Anular
cancelled = sdk.invoices.cancel(detail.id, "user_123", reason="Duplicada")
assert cancelled.status == InvoiceStatus.CANCELLED
# Estadísticas agregadas del usuario
stats = sdk.invoices.get_stats("user_123")
print(stats.total, stats.total_paid, stats.total_pending)
Guardar tarjeta (SetupIntent) y pagar
La app externa obtiene un clientSecret de UTILIA para montar Stripe
Elements. La respuesta incluye publishable_key (clave de la
plataforma de UTILIA, modelo Direct charges) y stripe_account_id (la
cuenta Connect de la organización); se pasan a load_stripe/loadStripe
con la opción stripeAccount. No configuras ninguna clave publishable
propia.
from utilia_sdk import UtiliaSDKSync, CreateInvoiceUser
with UtiliaSDKSync(base_url="https://os.utilia.ai/api", api_key="utl_...") as sdk:
# SetupIntent para que el cliente guarde una tarjeta nueva
setup = sdk.payment_methods.create_setup_intent(
CreateInvoiceUser(external_id="user_123", email="cliente@empresa.com")
)
print(setup.client_secret, setup.publishable_key, setup.stripe_account_id)
# Métodos guardados del usuario
methods = sdk.payment_methods.list("user_123")
default = next((m for m in methods if m.is_default), None)
# Marcar otro como predeterminado
if methods:
sdk.payment_methods.set_default(methods[-1].id, "user_123")
# Desvincular un método (se marca como DETACHED, no se borra)
sdk.payment_methods.remove(methods[0].id, "user_123")
Cobrar una factura
from utilia_sdk import UtiliaSDKSync
with UtiliaSDKSync(base_url="https://os.utilia.ai/api", api_key="utl_...") as sdk:
# Pagar con método guardado (on_session, sin 3DS -> already_succeeded)
result = sdk.payments.pay_invoice("inv_abc", "user_123", "pm_xyz")
if result.already_succeeded:
print("Cobro realizado")
else:
# La app externa completa el cobro con Stripe Elements
print("Confirmar con clientSecret:", result.client_secret)
# Pagar con tarjeta nueva (devuelve clientSecret para montar PaymentElement)
result = sdk.payments.pay_invoice(
"inv_abc", "user_123", save_card=True
)
# Histórico de intentos de cobro
for intent in sdk.payments.get_payment_intents("inv_abc", "user_123"):
print(intent.id, intent.status, intent.error_code or "")
Contactos para apps externas de envío de correos
Desde la versión 2.12.1 el SDK expone sdk.external_contacts 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.
Async
import os
from utilia_sdk import (
ExternalContactSyncOptions,
ResubscribeContactInput,
ResubscribeProof,
ResubscribeProofType,
UnsubscribeContactInput,
UnsubscribeContactSource,
UtiliaSDK,
)
from utilia_sdk.models.external_contact import ExternalContactInclude
async def sincronizar() -> None:
async with UtiliaSDK(
base_url="https://os.utilia.ai/api",
api_key=os.environ["UTILIA_API_KEY"],
) as sdk:
cursor: str | None = None
has_more = True
while has_more:
page = await sdk.external_contacts.sync(
ExternalContactSyncOptions(
cursor=cursor,
updated_after=(None if cursor else "1970-01-01T00:00:00.000Z"),
limit=200,
include=[ExternalContactInclude.SECONDARY_EMAIL],
)
)
for contact in page.data:
await mi_app.upsert_suscriptor(contact)
cursor = page.next_cursor
has_more = page.has_more
contact = await sdk.external_contacts.by_email("juan@empresa.com")
if contact is None:
return
opt_out = await sdk.external_contacts.unsubscribe(
contact.id,
UnsubscribeContactInput(
reason="Solicitud del propio contacto desde el footer",
source=UnsubscribeContactSource.EXTERNAL_APP,
),
)
if not opt_out.changed:
print("El contacto ya estaba dado de baja")
await sdk.external_contacts.resubscribe(
contact.id,
ResubscribeContactInput(
reason="El contacto se ha vuelto a registrar desde la web",
proof=ResubscribeProof(
type=ResubscribeProofType.DOUBLE_OPT_IN,
captured_at="2026-05-21T09:15:00.000Z",
source_url="https://aplicativo-externo.com/proofs/abc",
),
),
)
Síncrono
from utilia_sdk import (
UnsubscribeContactInput,
UnsubscribeContactSource,
UtiliaSDKSync,
)
def registrar_baja(api_key: str, contact_id: str) -> None:
with UtiliaSDKSync(
base_url="https://os.utilia.ai/api", api_key=api_key
) as sdk:
sdk.external_contacts.unsubscribe(
contact_id,
UnsubscribeContactInput(
reason="Rebote permanente notificado por el proveedor SMTP",
source=UnsubscribeContactSource.BOUNCE,
),
)
by_email devuelve None cuando el backend responde
404 EMAIL_NOT_INDEXED. unsubscribe y resubscribe devuelven
OptOutResult / OptInResult con contact, changed y
recorded_at para soportar idempotencia desde el aplicativo externo.
Si la ExternalApp no tiene crm:clients:read, client_ids llega
como [] y primary_client como None; primary_client_id se
mantiene siempre para correlación opaca.
Leads para apps externas
Desde la versión 2.13.0 el SDK expone sdk.external_leads para que
aplicativos de prospección, plataformas de sincronización CRM
bidireccional y herramientas de soporte gestionen los leads del CRM de
UTILIA OS. 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).
Async
import os
from utilia_sdk import (
ConvertLeadInput,
DisqualifyLeadInput,
DisqualifyLeadSource,
ExternalLeadSyncOptions,
QualifyLeadInput,
QualifyLeadSource,
UtiliaSDK,
)
from utilia_sdk.models.external_lead import ExternalLeadInclude
async def sincronizar_leads() -> None:
async with UtiliaSDK(
base_url="https://os.utilia.ai/api",
api_key=os.environ["UTILIA_API_KEY"],
) as sdk:
# Sincronización delta inicial (catálogo completo)
cursor: str | None = None
has_more = True
while has_more:
page = await sdk.external_leads.sync(
ExternalLeadSyncOptions(
cursor=cursor,
updated_after=(None if cursor else "1970-01-01T00:00:00.000Z"),
limit=100,
include=[ExternalLeadInclude.SCORING],
)
)
for lead in page.data:
await mi_app.upsert_lead(lead)
cursor = page.next_cursor
has_more = page.has_more
# Búsqueda exacta por email (devuelve None si no existe)
lead = await sdk.external_leads.by_email("juan@acme.com")
if lead is None:
return
# Cualificación tras detectar interés real
await sdk.external_leads.qualify(
lead.id,
QualifyLeadInput(
reason="El lead ha solicitado una demo concreta del producto",
source=QualifyLeadSource.EXTERNAL_APP,
),
)
# Descarte por rebote permanente
await sdk.external_leads.disqualify(
lead.id,
DisqualifyLeadInput(
reason="Rebote permanente notificado por el proveedor SMTP",
source=DisqualifyLeadSource.BOUNCE,
),
)
# Conversión (crea Contact y, opcionalmente, Opportunity)
result = await sdk.external_leads.convert(
lead.id,
ConvertLeadInput(
reason="El lead ha aceptado el presupuesto y solicita formalizar el contrato",
create_opportunity=True,
),
)
print(result.contact_id, result.opportunity_id, result.client_id)
Síncrono
from utilia_sdk import (
QualifyLeadInput,
QualifyLeadSource,
UtiliaSDKSync,
)
def cualificar(api_key: str, lead_id: str) -> None:
with UtiliaSDKSync(
base_url="https://os.utilia.ai/api", api_key=api_key
) as sdk:
result = sdk.external_leads.qualify(
lead_id,
QualifyLeadInput(
reason="Conversación cerrada con compromiso de presupuesto",
source=QualifyLeadSource.MANUAL,
),
)
if not result.changed:
print("El lead ya estaba cualificado")
by_email devuelve None cuando el backend responde
404 EMAIL_NOT_INDEXED_LEAD. Las mutaciones devuelven
LeadMutationResult / LeadConvertResult con lead, changed y
recorded_at (más contact_id / opportunity_id / client_id
en convert). ConvertLeadInput.reason exige al menos 20 caracteres
(queda en AuditLog inmutable). score y temperature solo llegan
con scope crm:leads:read:scoring + include=scoring; notes
solo con scope crm:leads:read:notes + include=notes.
Clientes para apps externas
Desde la versión 2.13.0 el SDK expone sdk.external_clients para que
aplicativos de facturación, plataformas de sincronización CRM
bidireccional y herramientas de soporte gestionen los clientes
(empresas / cuentas) del CRM de UTILIA OS. 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).
Restricción de seguridad importante: el tax_id (NIF/CIF) JAMÁS
aparece en listados ni en resultados de search. Solo se expone en
get y by_tax_id con scope crm:clients:read:fiscal-data.
Async
import os
from utilia_sdk import (
DeactivateClientInput,
DeactivateClientSource,
ExternalClientContactsFilters,
ExternalClientSyncOptions,
UtiliaSDK,
)
from utilia_sdk.models.external_client import ExternalClientInclude
async def sincronizar_clientes() -> None:
async with UtiliaSDK(
base_url="https://os.utilia.ai/api",
api_key=os.environ["UTILIA_API_KEY"],
) as sdk:
# Sincronización delta inicial
cursor: str | None = None
has_more = True
while has_more:
page = await sdk.external_clients.sync(
ExternalClientSyncOptions(
cursor=cursor,
updated_after=(None if cursor else "1970-01-01T00:00:00.000Z"),
limit=100,
)
)
for client in page.data:
await mi_app.upsert_client(client)
cursor = page.next_cursor
has_more = page.has_more
# Búsqueda exacta por NIF/CIF (devuelve None si no existe)
client = await sdk.external_clients.by_tax_id(
"B12345678", include=[ExternalClientInclude.FISCAL_DATA]
)
if client is None:
return
# Contactos vinculados al cliente
contactos = await sdk.external_clients.list_contacts(
client.id,
ExternalClientContactsFilters(can_receive_emails=True),
)
for contact in contactos.data:
print(contact.full_name, contact.email)
# Desactivación auditada (no existe DELETE vía API externa)
await sdk.external_clients.deactivate(
client.id,
DeactivateClientInput(
reason="Solicitud explícita del cliente desde la app externa",
source=DeactivateClientSource.EXTERNAL_APP,
),
)
Síncrono
from utilia_sdk import (
DeactivateClientInput,
DeactivateClientSource,
UtiliaSDKSync,
)
def desactivar(api_key: str, client_id: str) -> None:
with UtiliaSDKSync(
base_url="https://os.utilia.ai/api", api_key=api_key
) as sdk:
result = sdk.external_clients.deactivate(
client_id,
DeactivateClientInput(
reason="Política de baja por inactividad de la cuenta",
source=DeactivateClientSource.AUTOMATION,
),
)
if not result.changed:
print("El cliente ya estaba inactivo")
by_tax_id devuelve None cuando el backend responde
404 TAX_ID_NOT_INDEXED y aplica un rate limit más estricto
(100 req/h). deactivate devuelve ClientMutationResult con
client, changed y recorded_at. DeactivateClientInput.reason
exige entre 20 y 500 caracteres (queda en AuditLog inmutable y se
propaga en el webhook CLIENT_DEACTIVATED). La API NUNCA expone
IBAN, condiciones de pago, creditLimit, discount,
metadata ni asignaciones del equipo comercial.
Chat para apps externas (sdk.chat)
Desde la versión 2.20.0 el SDK expone sdk.chat (async y sync) para que
una aplicación externa participe en los canales de chat de UTILIA OS como
una identidad propia (EXTERNAL_APP). Superficie REST
/external/v1/chat.
- v1 (publicar):
send_message(). Requiere scopechat:messages:sendy una concesión (AppChannelGrant) activa en el canal. - v2 (leer):
list_channels(),get_channel(),list_members(),list_messages(),get_message(). 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.
from utilia_sdk import UtiliaSDK, SendChatMessageInput
async with UtiliaSDK(base_url="https://os.utilia.ai/api", api_key="...") as sdk:
# Publicar una lectura como la propia app en un canal concedido.
await sdk.chat.send_message(
channel_id,
SendChatMessageInput(content="Termómetro del cliente: 72/100."),
)
# Leer el historial visible (cursor descendente).
page = await sdk.chat.list_messages(channel_id, 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}.{raw_body} con
hmac.compare_digest y ventana anti-replay (300 s por defecto). Devuelve
el ChatWebhookPayload tipado o lanza UtiliaSDKError.
payload = sdk.chat.webhooks.verify(
raw_body,
signature=headers["X-Utilia-Signature"],
timestamp=headers["X-Utilia-Timestamp"],
secret=os.environ["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.
# Manejador del webhook en el servidor del integrador.
async def on_utilia_webhook(raw_body: str, headers: dict[str, str]) -> None:
try:
payload = sdk.chat.webhooks.verify(
raw_body,
signature=headers["X-Utilia-Signature"],
timestamp=headers["X-Utilia-Timestamp"],
secret=os.environ["UTILIA_WEBHOOK_SECRET"],
)
except UtiliaSDKError:
return # firma inválida o fuera de la ventana anti-replay
if payload.event == "chat.message.created":
message = await sdk.chat.get_message(payload.data["messageId"])
# Anti-bucle: no reaccionar a mensajes de apps (incluida la nuestra).
if message.author.kind != "APP":
await sdk.chat.send_message(
message.channel_id,
SendChatMessageInput(
content="Recibido. Recalculo el termómetro…",
parent_message_id=message.id,
),
)
Rectificativas y notas de crédito
Desde la versión 2.5.0, el SDK expone los tipos canónicos del flujo de
rectificativas y notas de crédito definido por el RD 1619/2012 art. 15.3.
La creación vive en endpoints INTERNOS
(POST /finance/invoices/:id/rectify y
POST /finance/invoices/:id/credit-note), por lo que requieren OAuth
con permiso INVOICES_RECTIFY. La metadata legal sobre borradores se
parchea con client.invoices.rectifications.update_legal_metadata(...).
Modalidad rectification_type y default
COMPLETE(sustitución total): reemplaza la factura original. Importe libre. Sólo puede existir UNA rectificativaCOMPLETEviva por factura original.DIFFERENCE(por diferencias): recoge solo el delta. El cap acumulado por diferencias no puede rebasar el total de la original.
Cambio en 2.5.0: el endpoint
credit-noteahora aplicaDIFFERENCEpor defecto cuando no se envíarectification_type. Si tu integración espera sustitución total, envíarectification_type="COMPLETE"(oRectificationType.COMPLETE) de forma explícita.
from utilia_sdk import (
UtiliaSDK,
RectificationCode,
RectificationType,
UpdateRectificationMetadataInput,
)
async with UtiliaSDK(
base_url="https://os.utilia.ai/api",
oauth={"client_id": "...", "redirect_uri": "..."},
) as sdk:
# Parchea la metadata legal sobre una rectificativa o nota de
# crédito en estado DRAFT antes de emitirla.
updated = await sdk.invoices.rectifications.update_legal_metadata(
invoice_id="inv-uuid",
body=UpdateRectificationMetadataInput(
rectification_code=RectificationCode.R1,
rectification_reason=(
"Error en el NIF del cliente, ahora B12345678."
),
rectification_type=RectificationType.COMPLETE,
),
)
Tipar los errores de negocio
from utilia_sdk import (
RECTIFICATION_BUSINESS_ERROR_CODES,
RectificationDifferenceExceedsRemainingError,
RectificationSubstitutionAlreadyExistsError,
)
print(RECTIFICATION_BUSINESS_ERROR_CODES)
# (
# 'RECTIFICATION_DIFFERENCE_EXCEEDS_REMAINING',
# 'RECTIFICATION_SUBSTITUTION_ALREADY_EXISTS',
# )
# Si capturas el cuerpo del 400 desde tu propio cliente HTTP:
def describe_rectification_error(raw: dict) -> str:
code = raw.get("code")
if code == "RECTIFICATION_DIFFERENCE_EXCEEDS_REMAINING":
err = RectificationDifferenceExceedsRemainingError.model_validate(raw)
return (
"La rectificativa por diferencias rebasa el saldo restante. "
f"Acumulado previo: {err.previous_total} €, intento: "
f"{err.new_amount} €, total original: {err.original_total} €."
)
if code == "RECTIFICATION_SUBSTITUTION_ALREADY_EXISTS":
err = RectificationSubstitutionAlreadyExistsError.model_validate(raw)
return (
f"Ya existe una rectificativa por sustitución activa "
f"({err.existing_invoice_number}, estado {err.existing_status}). "
"Anúlala antes de emitir otra."
)
return f"Error desconocido: {code}"
Crear rectificativas y notas de crédito
El SDK aún no expone un método dedicado para
rectifyycredit-note; mientras tanto, llama al endpoint con tu cliente HTTP autenticado vía OAuth. Recuerda enviarrectification_typeexplícito para no depender del default (que ahora esDIFFERENCEen notas de crédito).
body_credit_note = {
"creditNoteReason": "Devolución parcial de las horas no consumidas en marzo.",
"rectificationCode": "R4",
"rectificationType": "DIFFERENCE", # explícito; default desde 2.5.0
"lines": [
{
"name": "Horas no consumidas",
"quantity": 1,
"unitPrice": 200.0,
"taxType": "IGIC_7",
}
],
}
Errores tipados de facturación
Desde la versión 2.6.0, el SDK exporta modelos 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.
from utilia_sdk import (
UtiliaSDK,
UtiliaSDKError,
INVOICE_TAX_COHERENCE_ERROR_CODE,
InvoiceTaxCoherenceErrorPayload,
)
async with UtiliaSDK(
base_url="https://os.utilia.ai/api",
api_key="tu-api-key",
) as sdk:
try:
await sdk.invoices.create({...})
except UtiliaSDKError as exc:
if exc.error_code == 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.
print("La factura tiene incoherencias fiscales.")
raise
Si tu cliente HTTP captura el cuerpo crudo del 400 y necesitas el
detalle por línea, parséalo con el modelo 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:
def describe_tax_coherence_error(raw: dict) -> list[str]:
payload = InvoiceTaxCoherenceErrorPayload.model_validate(raw)
descriptions: list[str] = []
for issue in payload.issues:
idx = issue.line_index + 1
if issue.code == "NULL_TAX_TYPE":
sug = (
f" Sugerencia: {issue.suggested_tax_type}."
if issue.suggested_tax_type
else ""
)
descriptions.append(f"Línea {idx}: falta el tipo impositivo.{sug}")
elif issue.code == "INCOHERENT_WITH_SYSTEM":
cur = issue.current_tax_type or "sin tipo"
sug = (
f" Cambia a {issue.suggested_tax_type}."
if issue.suggested_tax_type
else ""
)
descriptions.append(
f"Línea {idx}: {cur} no es compatible con el sistema "
f"{payload.tax_system}.{sug}"
)
elif issue.code == "RATE_NOT_IN_SYSTEM":
descriptions.append(
f"Línea {idx}: el porcentaje {issue.current_tax_rate}% "
f"no existe en el catálogo del sistema {payload.tax_system}."
)
elif issue.code == "LEGACY_HEADER_RATE_MISMATCH":
descriptions.append(
f"Línea {idx}: tipo legado en la cabecera no coincide "
"con el calculado por línea. Migración pendiente."
)
return descriptions
Actualizaciones en tiempo real (SSE)
Recibe notificaciones cuando un agente responde, cambia el estado, resuelve o cierra un ticket:
Asíncrono
async with UtiliaSDK(base_url="https://os.utilia.ai/api", api_key="tu-api-key") as sdk:
async for event in sdk.tickets.stream_updates(user_id="user-123"):
print(f"Ticket {event['ticketKey']} actualizado: {event['type']}")
# event['type']: 'comment-added' | 'status-changed'
Síncrono
with UtiliaSDKSync(base_url="https://os.utilia.ai/api", api_key="tu-api-key") as sdk:
for event in sdk.tickets.stream_updates(user_id="user-123"):
print(f"Ticket {event['ticketKey']} actualizado: {event['type']}")
Reportar errores del sistema
import traceback
from utilia_sdk import UtiliaSDK, ReportErrorInput
async with UtiliaSDK(base_url="https://os.utilia.ai/api", api_key="tu-api-key") as sdk:
try:
await procesar_pago(orden)
except Exception as e:
result = await sdk.errors.report(ReportErrorInput(
message=str(e),
module="pagos",
severity="critical",
stack=traceback.format_exc(),
endpoint="/api/payments",
method="POST",
))
print(result.hash) # Hash de deduplicación
print(result.deduplicated) # True si ya existía
OAuth y Sign In
Desde la versión 0.5.0, el SDK incluye soporte nativo para OAuth 2.1 con PKCE:
from utilia_sdk import UtiliaSDK
sdk = UtiliaSDK(
base_url="https://os.utilia.ai/api",
oauth={
"client_id": "client_xxxxxxxxxx",
"redirect_uri": "http://localhost:8000/callback",
"scopes": ["openid", "profile", "email"],
},
)
# Generar URL de autorización
auth_url = await sdk.oauth.get_authorization_url()
# Opcionalmente, solicitar un tema específico para la pantalla de consentimiento
dark_url = await sdk.oauth.get_authorization_url(theme="dark")
# Manejar callback
tokens = await sdk.oauth.handle_callback(code)
user_info = sdk.oauth.get_user_info()
Documentación completa: https://os.utilia.ai/dashboard/docs/integraciones-sdk/sdk-python-guia-oauth
Manejo de errores
from utilia_sdk import UtiliaSDKError, ErrorCode
try:
ticket = await sdk.tickets.create(data)
except UtiliaSDKError as e:
if e.is_unauthorized:
print("API Key inválida")
elif e.is_rate_limited:
print("Demasiadas peticiones")
elif e.is_retryable:
print("Error temporal, reintentar")
else:
print(f"Error: {e.code} - {e.message}")
Licencia
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file utilia_sdk-2.20.0.tar.gz.
File metadata
- Download URL: utilia_sdk-2.20.0.tar.gz
- Upload date:
- Size: 248.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
77dd350b21eed082c6e70c901247cc85f8fc7ff571c1b3e223d119fb0e4f9571
|
|
| MD5 |
78f3168153fa636c659cdb1097cadce1
|
|
| BLAKE2b-256 |
f3fc1a25c2b0eeb08933ff4b3fe6a401b6b5f76cd75b01220436e429a3be74e9
|
Provenance
The following attestation bundles were made for utilia_sdk-2.20.0.tar.gz:
Publisher:
sdk-publish.yml on Utilia-ai/UTILIA-OS
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
utilia_sdk-2.20.0.tar.gz -
Subject digest:
77dd350b21eed082c6e70c901247cc85f8fc7ff571c1b3e223d119fb0e4f9571 - Sigstore transparency entry: 2168162932
- Sigstore integration time:
-
Permalink:
Utilia-ai/UTILIA-OS@e524395121b41029b02821b4363c98a89991c1fa -
Branch / Tag:
refs/tags/sdk-python@2.20.0 - Owner: https://github.com/Utilia-ai
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
sdk-publish.yml@e524395121b41029b02821b4363c98a89991c1fa -
Trigger Event:
push
-
Statement type:
File details
Details for the file utilia_sdk-2.20.0-py3-none-any.whl.
File metadata
- Download URL: utilia_sdk-2.20.0-py3-none-any.whl
- Upload date:
- Size: 234.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c71288dd96780204295f8d42c6537d57dbebcdad5cf8904c22b41cfb537b1416
|
|
| MD5 |
09cedd20f608fa3f49a8b4a7c0d1b4d4
|
|
| BLAKE2b-256 |
f3e1bcb5f037bd2cd8019cb40272b523aa126f35c267fc6249a1e0b9d9bf38df
|
Provenance
The following attestation bundles were made for utilia_sdk-2.20.0-py3-none-any.whl:
Publisher:
sdk-publish.yml on Utilia-ai/UTILIA-OS
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
utilia_sdk-2.20.0-py3-none-any.whl -
Subject digest:
c71288dd96780204295f8d42c6537d57dbebcdad5cf8904c22b41cfb537b1416 - Sigstore transparency entry: 2168162953
- Sigstore integration time:
-
Permalink:
Utilia-ai/UTILIA-OS@e524395121b41029b02821b4363c98a89991c1fa -
Branch / Tag:
refs/tags/sdk-python@2.20.0 - Owner: https://github.com/Utilia-ai
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
sdk-publish.yml@e524395121b41029b02821b4363c98a89991c1fa -
Trigger Event:
push
-
Statement type: