Metadata-Version: 2.5
Name: utilia-sdk
Version: 4.8.0
Summary: SDK Python para integrar aplicaciones externas con UTILIA OS
Project-URL: Homepage, https://os.utilia.ai
Project-URL: Repository, https://github.com/Utilia-ai/UTILIA-OS
Project-URL: Bug Tracker, https://github.com/Utilia-ai/UTILIA-OS/issues
Author-email: UTILIA OS <dev@utilia.ai>
License: MIT
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: eval-type-backport>=0.2.0; python_version < '3.10'
Requires-Dist: httpx<1,>=0.25
Requires-Dist: pydantic<3,>=2.13
Provides-Extra: dev
Requires-Dist: mypy<2,>=1.18; extra == 'dev'
Requires-Dist: pydantic<2.14,>=2.13; extra == 'dev'
Requires-Dist: pytest-asyncio<1,>=0.24; extra == 'dev'
Requires-Dist: pytest<9,>=8.0; extra == 'dev'
Requires-Dist: respx<1,>=0.22; extra == 'dev'
Requires-Dist: ruff<1,>=0.5; extra == 'dev'
Description-Content-Type: text/markdown

# UTILIA OS SDK para Python

SDK Python para integrar aplicaciones externas con el sistema de soporte de UTILIA OS.

## Instalación

```bash
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 3.0.0, el SDK exporta los scopes válidos como tipos
``Literal`` y constantes tipadas, espejo exacto del catálogo del backend:
61 desde la 4.7.0, con los dos de las instalaciones del catálogo de
aplicaciones (``apps:installations:read`` y ``apps:installations:exchange``).

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.

```python
from utilia_sdk import (
    EXTERNAL_API_SCOPES,
    EXTERNAL_API_SCOPE_DESCRIPTIONS,
    FINANCIAL_CRITICAL_SCOPES,
    INSUFFICIENT_SCOPE_MESSAGE,
    RGPD_SENSITIVE_SCOPES,
    ExternalApiScope,
    is_external_api_scope,
)

# 62 scopes válidos del catálogo
assert len(EXTERNAL_API_SCOPES) == 62

# 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``, ``:write``, ``:read:fiscal-data``                                               | ``read:fiscal-data`` (4.6.0) añade el NIF y la dirección de facturación; es RGPD CRÍTICO.      |
| 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).                                  |
| Instalaciones del catálogo | ``apps:installations:read``, ``:exchange``                                                    | 4.7.0. De la aplicación que publica una ficha, sobre sus instalaciones (``sdk.app_installations``). Ninguno está blindado. |

Los 16 scopes blindados 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)

```python
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

```python
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...",
        )
    )
```

### Renovación de la clave con periodo de transición (4.2)

Cuando alguien renueva la clave de tu aplicación dejando que la anterior siga
valiendo unos días, cada respuesta a una petición hecha con la anterior trae
`X-Utilia-Api-Key-Expires`. El parámetro `on_api_key_expires` (de `UtiliaSDK`,
`UtiliaSDKSync` y `UtiliaSDKConfig`) recibe `ApiKeyExpiryNotice(expires_at)`
una vez por fecha, y `sdk.api_key_expires_at` devuelve esa fecha o `None`. Sin
el parámetro, el SDK deja un único aviso por fecha en el registro `utilia_sdk`
con nivel WARNING. El aviso nunca cambia el resultado de la petición.

```python
from utilia_sdk import ApiKeyExpiryNotice, UtiliaSDK


def avisar(notice: ApiKeyExpiryNotice) -> None:
    print(f"La clave de UTILIA OS deja de valer el {notice.expires_at}")


sdk = UtiliaSDK(
    base_url="https://os.utilia.ai/api",
    api_key="tu-api-key",
    on_api_key_expires=avisar,
)
```

Las peticiones, las conexiones en tiempo real y las descargas de PDF llevan la
cabecera `X-Utilia-SDK: utilia-sdk-python/4.8.0` (`__version__`,
`SDK_IDENTITY` y `SDK_IDENTITY_HEADER`).

Con esa cabecera, los listados de `sdk.mcp.*` llegan igual que antes. Desde el
20 de octubre de 2026, los servidores MCP devuelven a los asistentes de IA una
vista resumida de cada fila y solo la clave `items`; a una petición del SDK le
siguen dando la fila completa y, además, la clave antigua de cada listado
(`clients`, `contacts`, `leads`, `tickets`, `campaigns`...). No tienes que
cambiar nada.

### Cabeceras fijas (4.7.0)

`headers` (de `UtiliaSDK`, `UtiliaSDKSync` y `UtiliaSDKConfig`) suma cabeceras a
todas las peticiones del SDK, también a las conexiones en tiempo real (por
ejemplo, `X-Utilia-User-Signature`, la firma del usuario del widget). No pueden
pisar la autenticación ni la identidad del SDK: `X-Api-Key`, `Authorization` y
`X-Utilia-SDK`, en mayúsculas o minúsculas, se rechazan con `ValueError` al
crear el SDK, igual que un valor que no sea texto.

### Reintentos (4.6.0)

- Se repiten solas, hasta `retry_attempts` veces (3 por defecto), las
  lecturas, `PUT`, `PATCH`, `DELETE` y los `POST` con clave de idempotencia
  (campo `idempotencyKey` o cabecera `Idempotency-Key`, que `post` admite en
  `headers`), ante 429, 5xx o corte de red.
- Un alta sin clave de idempotencia (tickets, mensajes, clientes, leads,
  conversión de lead, canales, agentes, archivos, IA) se intenta UNA vez: si el
  corte llega después de que el servidor la registre, repetirla la duplicaría.
  Decide tú si reintentar.
- En un 429 el SDK espera lo que pida el servidor (`Retry-After` o, en el 429
  del cupo por hora agotado que no lo traiga, `X-Utilia-Quota-Reset`) si no
  pasa de `max_retry_after` (60 segundos por defecto). Si pasa (el cupo por
  hora agotado), no espera ni reintenta: el error trae `retry_after` en
  segundos. Un 5xx sin `Retry-After` se repite con la espera creciente de
  siempre (0,5 s, 1 s...).

## Comprobar tu integración (4.6.0)

Desde la consola, con la clave de tu aplicación (requiere el servidor con el
sistema de pruebas):

```bash
export UTILIA_API_KEY=...                       # la orden no la muestra nunca
export UTILIA_BASE_URL=https://os.utilia.ai/api
utilia-doctor                                   # diagnóstico
utilia-doctor --run                             # y prueba activa de solo lectura
utilia-doctor --check "POST /external/v1/invoices" --body factura.json
utilia-doctor --json --strict                   # para tu asistente de IA o tu CI
```

Enseña el veredicto, los hallazgos ordenados con quién los arregla, qué puede
llamar tu clave, el siguiente paso y los enlaces a la documentación. Sale con 0
sin bloqueos; 1 si hay hallazgos que bloquean (con `--strict`, también los
avisos) o si el servidor responde que la aplicación está desactivada, sin
persona responsable activa o con la organización bloqueada; y 2 si no se pudo
comprobar (sin conexión, clave que no vale u orden mal escrita). `--json` saca
el diagnóstico tal cual, con `run` y `checkRequest` si se piden.

La clave solo viaja por `https://`. Por `http://` solo se admite este mismo
equipo (`localhost`, `127.0.0.1` o `::1`); para un laboratorio aislado,
`--allow-insecure-http`. Con una clave publicable (la de una aplicación con
orígenes de navegador), la lista de llamadas rechazadas (`calls()`), la prueba
activa y las de avisos se lanzan desde el panel: `utilia-doctor --run` lo explica y no lo cuenta como fallo.

Con una clave de API de la organización (`utk_...`) en `UTILIA_API_KEY`,
`utilia-doctor` la reconoce y enseña el diagnóstico de esa clave: veredicto,
hallazgos con quién los arregla, permisos por módulo frente al plan y cupo, con
los mismos códigos de salida. `--run` y `--check` todavía no están disponibles
con ese tipo de clave: lo dice y no lo cuenta como fallo. El SDK manda una clave
`utk_` en `Authorization: Bearer`, que es donde la acepta el servidor; la de una
aplicación va en `X-Api-Key`.

Desde el código, `sdk.diagnostics` (asíncrono y síncrono, sin ningún permiso de
la API): `me()`, `get()`, `get_markdown()` (las instrucciones para tu asistente
de IA, en Markdown), `calls(window="24h" | "7d")`,
`check_request(method, path, query=..., body=...)`, `run()`,
`send_webhook_sample(event=...)`, `check_webhook_receiver()` y `open_api()`,
sobre `/external/v1/me`, `/external/v1/diagnostics/*` y
`/external/v1/diagnostics/openapi.json`. Con una clave de la organización
(`utk_`), `organization_api_key()` pide
`/v2/organization-api-keys/current/diagnostics`, con el tipo de cuenta de la
clave en `organization.kind` (`company`, `developer` o `sandbox`).

```python
comprobacion = sdk.diagnostics.check_request("POST", "/external/v1/invoices", body=factura)
if not comprobacion.would_pass:
    for check in comprobacion.checks:
        print(check.key, check.status, check.message)
```

Para un copiloto, `sdk.mcp.integrations_overview` da en una llamada el estado
de todas las aplicaciones de la organización (`status()`, `health()` con la
peor parada en `worst_app.app_name`, `quickstart()`) y el diagnóstico de una de
ellas (`app_diagnosis(app_id)`).

`ALL_WEBHOOK_EVENTS` y `WEBHOOK_EVENTS_BY_DOMAIN` traen los nombres de todos
los avisos, comprobados contra el catálogo del servidor, y `sdk.ai_analytics`
(`get_overview`, `get_trends`) el uso de IA de tu propia aplicación (scope
`analytics:ai:read`).

## Publicar en el catálogo de aplicaciones (4.7.0)

Para el servidor de una aplicación que publica una ficha en el catálogo de
UTILIA OS. Se configura con la clave de la aplicación que publicó la ficha y
necesita los permisos de la API `apps:installations:exchange` («Recibir las
credenciales de cada instalación») y `apps:installations:read` («Ver las
instalaciones de esta aplicación»).

1. Una empresa instala tu aplicación: UTILIA OS manda a la persona a la
   dirección de instalación de tu ficha con `code`, `installation_id`, `state`
   (si lo había) y `return_url`.
2. Tu servidor canjea el código con `sdk.app_installations.exchange(code)` y
   recibe, UNA sola vez, la clave de API y la clave de firma de esa
   instalación. El código vale 30 minutos; desde un navegador responde 403
   `EXCHANGE_FROM_BROWSER`. El SDK no repite el canje: si se corta, vuelve a
   llamar tú con el mismo código (si ya se canjeó, `INSTALLATION_CODE_USED`).
3. Llega el aviso `APP_INSTALLED`, firmado con la clave de esa instalación.

```python
resultado = await sdk.app_installations.exchange(request.query_params["code"])
guardar_cifrado(resultado.installation.id, resultado.credentials)

# Avisos de todas las instalaciones en una sola dirección:
async def clave(id_: str) -> str | None:
    instalacion = await repositorio.buscar(id_)
    return instalacion.webhook_signing_secret if instalacion else None

sobre = await sdk.webhooks.verify_installation_async(crudo, request.headers, clave)
if sobre["event"] == "APP_UNINSTALLED":
    await repositorio.borrar(sobre["installation"]["id"])
```

- `sdk.app_installations.list(status=..., subscription_status=..., cursor=..., limit=...)`
  y `get(installation_id)`: las instalaciones de tus fichas, con su estado y
  los permisos concedidos. Sin personas de esas empresas.
- Aplicaciones de pago (4.8.0): cada instalación trae `subscription`
  (`ExternalAppSubscription`: plan, precio sin impuestos, estado `TRIALING`,
  `ACTIVE`, `PAST_DUE`, `PAUSED` o `ENDED`, prueba, periodo, cancelación y
  cambio programado; `None` en una ficha gratuita) e
  `is_access_allowed(instalacion)` dice si la empresa puede usarla. Los
  cambios llegan en los avisos `APP_SUBSCRIPTION_*`
  (`APP_SUBSCRIPTION_WEBHOOK_EVENTS`, carga `AppSubscriptionWebhookData`):
  `STARTED` justo después de `APP_INSTALLED` y `ENDED` antes de
  `APP_UNINSTALLED`. Una compra simulada llega con `simulated: true`.
- `sdk.webhooks.verify_installation` (resolutor normal) y
  `verify_installation_async` (también `async def`) leen
  `X-Utilia-Installation-Id` (`APP_INSTALLATION_ID_HEADER`), piden la clave de
  esa instalación a tu función, verifican la firma (también con dos claves) y
  comprueban que el sobre firmado es de esa instalación. Fallan con
  `WEBHOOK_INSTALLATION_MISSING`, `WEBHOOK_INSTALLATION_UNKNOWN`,
  `WEBHOOK_INSTALLATION_MISMATCH` o los errores de firma de siempre.
- Avisos del ciclo de vida (llegan solos, no se suscriben): `APP_INSTALLED`,
  `APP_UNINSTALLED`, `APP_ACCESS_CHANGED`, `APP_SUSPENDED`, `APP_REINSTATED`
  y, en las de pago, los de la suscripción. El sobre trae `installation`
  (`WebhookInstallation`).
- Errores: `APP_INSTALLATION_ERROR_CODES` y `WIDGET_IDENTITY_ERROR_CODES`, en
  `PUBLIC_API_ERROR_CATALOG`. `LISTING_NOT_CONNECTABLE`
  (`APP_CATALOG_PANEL_ERROR_CODES`) solo sale del panel.
- Por MCP (`sdk.mcp.integrations`): `catalog_list`, `catalog_get`,
  `catalog_request_install`, `installations_list`, `installations_get` (con
  `subscription` en las de pago; el importe, solo si puedes ver el plan de la
  empresa), `installations_uninstall`, `installations_change_responsible`,
  `installations_cancel_subscription`, las peticiones de instalación
  (`installations_requests_list` e `installations_requests_dismiss`) y la
  verificación de identidad del widget (`apps_widget_identity_get`, `_update`
  y `_regenerate_secret`). Instalar, comprar o pagar no se puede por MCP ni
  por el SDK: las fichas traen `url` para abrirlas. Las fichas que publica una
  cuenta de desarrollador se gestionan en el servidor de desarrolladores.

## 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 (captura automática, reportar, listar, estadísticas y gestión)
- `sdk.budgets` - Presupuestos CRM (CRUD, items, secciones, flujo, PDF, IA)
- `sdk.budget_templates` - Plantillas de presupuesto reutilizables
- `sdk.budget_comments` - Comentarios de presupuesto con visibilidad `INTERNAL` / `CLIENT` (con clave de API, solo `CLIENT`: `list` y `create`)
- `sdk.diagnostics` - Diagnóstico de la integración (4.6.0, ver «Comprobar tu integración»)
- `sdk.ai_analytics` - Uso de IA de tu propia aplicación (4.6.0)
- `sdk.app_installations` - Instalaciones de tu aplicación del catálogo: canje del código y consulta (4.7.0, ver «Publicar en el catálogo de aplicaciones»)
- `sdk.budget_signatures` - Firma electrónica, magic links y certificado legal (con clave de API, solo `list`, `get` y `verify`)
- `sdk.organization_settings` - Configuración pública de la organización
- `sdk.invoices` - Facturación externa (crear, listar, detalle, PDF, anular, estadísticas, enviar por correo, cobrar sin el usuario delante, marcar cobrada, enlace público)
- `sdk.invoices.scheduled_charges` - Cobros programados a fecha futura sobre una factura
- `sdk.invoices.refunds` - Devolución del dinero ya cobrado
- `sdk.payment_methods` - Métodos de pago guardados del usuario final (SetupIntent Stripe) y disposición de cobro de la organización
- `sdk.payment_authorizations` - Autorizaciones de cobro: el permiso expreso del usuario para que se le cobre sin estar delante (`sdk.mandates` es su alias en desuso)
- `sdk.payments` - Cobro CON el usuario delante y consulta de PaymentIntents
- `sdk.subscriptions` - Suscripciones: cuotas que se repiten y se cobran solas
- `sdk.webhooks` - Verificación local de la firma de los avisos entrantes y lectura tipada del evento
- `sdk.external_contacts` - Contactos del CRM para apps externas de envío de correos (sync delta, opt-out/opt-in, webhooks `CONTACT_*`)
- `sdk.external_leads` - Leads del CRM para apps externas de prospección y sincronización CRM bidireccional (listado, sync delta, `qualify` / `disqualify` / `convert`, webhooks `LEAD_*`)
- `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`, webhooks `CLIENT_*`)
- `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ás `webhooks.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)

Con la clave de tu aplicación (`api_key`), desde la 4.6.0 el servicio usa la API
externa `/external/v1/budgets/{budget_id}/comments`: `list` (scope
`crm:budgets:comments:read`) y `create` (scope `crm:budgets:comments:write`),
siempre con visibilidad `CLIENT`; `get`, `bulk_create` y `get_timeline` se
apoyan en ellos, y `update` y `remove` lanzan
`METHOD_NOT_AVAILABLE_WITH_API_KEY`. Los comentarios `INTERNAL`, las menciones
y la edición necesitan OAuth, como en el ejemplo:

```python
from utilia_sdk import (
    UtiliaSDK,
    CreateBudgetCommentInput,
    BudgetCommentVisibility,
    ListBudgetCommentsFilter,
)

oauth = {"client_id": "...", "redirect_uri": "https://tu-app.com/callback"}
async with UtiliaSDK(base_url="https://os.utilia.ai/api", oauth=oauth) 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)

> **Credencial (4.2).** Con clave de API, `list`, `get` y `verify` van a la API
> externa (`/external/v1/budgets/{budgetId}/signatures`, scopes
> `crm:budgets:signatures:read` y `crm:budgets:signatures:verify`) y
> funcionan. Los otros once métodos con petición y `download_certificate` van a
> rutas del panel que exigen la sesión de una persona: con clave de API lanzan,
> antes de hacer la petición, `METHOD_NOT_AVAILABLE_WITH_API_KEY`, en las dos
> variantes. Con OAuth la ruta responde 401 `OAUTH_BEARER_NOT_ALLOWED`, y
> `download_certificate` lanza `UtiliaSDKError` en lugar de
> `httpx.HTTPStatusError`. Con OAuth, las firmas se consultan por MCP
> (herramienta `crm_budget_files`; la antigua `crm_budget_signers` está
> retirada). `SignerAuditEvent` trae
> `ip_address_masked` y `device_label`; `ip_address` y `user_agent` quedan en
> desuso y ya no llegan.

Lo que funciona con clave de API:

```python
async with UtiliaSDK(base_url="https://os.utilia.ai/api", api_key="tu-api-key") as sdk:
    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")
```

El resto del flujo, que solo alcanza la sesión de una persona:

```python
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`:

```python
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 cobros

Desde la versión 3.0.0 el ciclo completo se recorre con la clave de API, sin
salir del SDK: dar de alta al usuario, guardar su tarjeta, recoger su
autorización de cobro, emitir la factura y enviarla por correo, montar la
suscripción que se cobra sola, reintentar lo que falle, escuchar los avisos y
devolver el dinero. Las facturas que emite tu aplicación son facturas de
UTILIA OS: misma fiscalidad y mismo motor de envío que las del equipo, y la
misma numeración salvo que la organización reserve a tu aplicación una serie
propia (4.4.0, ver «Series de facturación de tu aplicación»).

Requiere que la aplicación externa tenga `billingEnabled = true` en la
configuración de la organización.

### 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.payment_methods` | Guarda y gestiona las tarjetas del usuario, y dice si la organización puede cobrar | `X-Api-Key` |
| `sdk.payment_authorizations` | 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, devuelve y lista las series de tu aplicación (4.4.0) | `X-Api-Key` |
| `sdk.invoices.scheduled_charges` | Programa el cobro de una factura a fecha futura | `X-Api-Key` |
| `sdk.invoices.refunds` | Devuelve dinero ya cobrado | `X-Api-Key` |
| `sdk.invoices.rectifications` | Emite la rectificativa de una devolución o un contracargo (4.3.0) | `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.

### Emitir una factura (síncrono)

```python
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)

```python
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

```python
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.

```python
from utilia_sdk import UtiliaSDKSync, CreateInvoiceUser

with UtiliaSDKSync(base_url="https://os.utilia.ai/api", api_key="utl_...") as sdk:
    # ¿Puede cobrar esta organización? Pregúntalo ANTES de enseñar el
    # formulario de la tarjeta: si su pasarela no está lista, el botón de
    # pagar es una promesa que va a fallar.
    estado = sdk.payment_methods.get_readiness()
    if not estado.organization_can_charge:
        # 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.
        raise SystemExit(f"No se puede cobrar: {estado.reason}")

    # 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

```python
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 "")
```

### El recorrido completo

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

```python
usuario = await sdk.users.identify(
    IdentifyUserInput(
        external_id="user_01HXYZ",
        email="ana@estudiomarea.example",
        name="Estudio Marea SL",
        tax_id="B76543210",
        billing_address=CreateInvoiceBillingAddress(
            street="Avenida de Canarias 12, planta 3",
            city="Las Palmas de Gran Canaria",
            postal_code="35001",
            country="España",
        ),
    )
)
print(usuario.client_id)  # ficha del cliente en el CRM
```

`tax_id` y `billing_address` 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. La respuesta solo los devuelve si se mandaron en esa llamada o si
la aplicación tiene ``crm:users:read:fiscal-data``; ``users.list``, solo con ese
permiso.

**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.

```python
consentimiento = await sdk.payment_authorizations.get_consent_text(
    ExternalRequestablePaymentAuthorizationScope.RECURRING
)
# ... muestras `consentimiento.text` sin cambiar ni una coma ...

autorizacion = await sdk.payment_authorizations.create(
    CreateExternalPaymentAuthorizationInput(
        user_id="user_01HXYZ",
        setup_intent_id=intent.setup_intent_id,
        consent_text=consentimiento.text,
        consent_version=consentimiento.version,
        accepted_at=datetime.now(timezone.utc).isoformat(),
        accepted_ip=peticion.client.host,
        accepted_user_agent=peticion.headers.get("user-agent"),
        scope=ExternalRequestablePaymentAuthorizationScope.RECURRING,
    )
)
```

**3. La factura y su correo.** Con `send_email=True` la factura sale por correo
al emitirla, con su PDF adjunto, usando la plantilla y el remitente de la
organización.

```python
factura = await sdk.invoices.create(
    CreateInvoiceInput(..., send_email=True)
)
if factura.email_sent is False:
    # La factura ESTÁ emitida y numerada: solo falló el correo.
    # Reenvíala; no vuelvas a crearla.
    await sdk.invoices.send(
        factura.id, SendExternalInvoiceInput(user_id="user_01HXYZ")
    )
```

**4. La suscripción.** Con `charge_mode = 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á.

```python
suscripcion = await sdk.subscriptions.create(
    CreateExternalSubscriptionInput(
        user_id="user_01HXYZ",
        name="Plan Profesional mensual",
        start_date="2026-10-01",
        frequency=ExternalSubscriptionFrequency.MONTHLY_FIXED_DAY,
        day_of_month=1,
        charge_mode=ExternalSubscriptionChargeMode.AUTO_STRIPE,
        payment_authorization_id=autorizacion.id,
        send_email=True,
        lines=[
            CreateExternalSubscriptionLineInput(
                name="Plan Profesional", unit_price=49.95, tax_type="IGIC_7"
            )
        ],
    )
)
```

**5. Cobros y reintentos.** `charge` cobra sin el usuario delante;
`invoices.scheduled_charges.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 `public_url` 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.

`invoices.scheduled_charges.retry_now` 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 `error_code`: se
reconoce por el estado y el mensaje.

```python
resultado = await sdk.invoices.charge(
    factura.id,
    ChargeExternalInvoiceInput(
        user_id="user_01HXYZ", idempotency_key=f"cuota-{factura.id}"
    ),
)
if resultado.status is ExternalChargeResultStatus.REQUIRES_ACTION:
    enviar_al_usuario(resultado.public_url)
```

**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.

```python
@app.post("/webhooks/utilia")
async def recibir(request: Request) -> Response:
    crudo = (await request.body()).decode("utf-8")
    # Durante un cambio de clave de firma (4.2), pasa las dos:
    # [NUEVA, WebhookSecret(ANTERIOR, valid_until="2026-10-19T09:00:00Z")]
    evento = sdk.webhooks.parse(crudo, request.headers, SECRETO)
    if evento.event == "CHARGE_SUCCEEDED":
        marcar_como_pagado(evento.data.invoice_id)
    elif evento.event == "CHARGE_REQUIRES_ACTION":
        # `public_url` llega con valor si el cobro lo lanzó la
        # aplicación, y NULO si viene del motor de cobros programados:
        # ahí hay que emitir un enlace nuevo y entregárselo al usuario.
        direccion = evento.data.public_url
        if direccion is None:
            enlace = await sdk.invoices.regenerate_public_link(
                evento.data.invoice_id,
                RegenerateExternalInvoicePublicLinkInput(
                    user_id=evento.data.external_user_id
                ),
            )
            direccion = enlace.public_url
        avisar_al_usuario(direccion)
    elif evento.event == "CHARGE_RETRIES_EXHAUSTED":
        suspender_servicio(evento.data.external_user_id)
    return Response(status_code=200)
```

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

```python
await sdk.invoices.refunds.create(
    factura.id,
    RefundExternalInvoiceInput(
        user_id="user_01HXYZ",
        reason=ExternalRefundReason.REQUESTED_BY_CUSTOMER,
        idempotency_key=f"devolucion-{factura.id}",
    ),
)
```

### Probar sin emitir facturas de verdad

La numeración de las facturas es correlativa y no admite huecos, así que las
pruebas no se hacen con `create`. `validate` recibe el mismo cuerpo (modelo o
dict en camelCase) y dice si saldría bien, sin numerar, escribir, avisar ni
enviar nada; `validate_pdf` devuelve un PDF de muestra sin validez fiscal
(número «PRUEBA», sin QR). Basta cualquier scope `crm:invoices:*` y no hace
falta la facturación activada.

```python
ensayo = await sdk.invoices.validate(datos)
if not ensayo.valid:
    print([(b.code, b.message) for b in ensayo.blockers])
Path("prueba.pdf").write_bytes(await sdk.invoices.validate_pdf(datos))
await sdk.invoices.rectifications.validate(factura.id, datos_rectificativa)

# Tu receptor de avisos, en tus pruebas unitarias: firmado igual que UTILIA OS.
aviso = sdk.webhooks.sign_test_payload(sobre, "whsec_de_prueba")
cliente.post("/webhooks/utilia", content=aviso.raw_body, headers=aviso.headers)
```

### 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.download_pdf(id, user_id)` devuelve los bytes en tu servidor.
- `sdk.invoices.regenerate_public_link(id, datos)` **emite** un enlace y
  devuelve la dirección pública, que sí puedes enviar por correo o abrir en el
  navegador.

**Guarda la dirección cuando la emitas.** Del enlace emitido el backend guarda
solo su huella, así que no se puede recuperar:
`sdk.invoices.get_public_link(id, user_id=...)` es una lectura pura que informa
de la vigencia, la caducidad y las acciones permitidas, y devuelve
`public_url = None` 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.

## 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`](../../docs/integrations/external-contacts-api.md).

### Async

```python
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

```python
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

```python
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

```python
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

```python
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

```python
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 scope ``chat:messages:send``
  y una concesión (``AppChannelGrant``) activa en el canal.
- v2 (leer): ``list_channels()``, ``get_channel()``, ``list_members()``,
  ``list_messages()``, ``get_message()``. 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``.

```python
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``.

```python
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.

```python
# 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,
                ),
            )
```

## Facturas simplificadas y cobro con tu propia pasarela (4.3.0)

Si tu aplicación cobra con su propia pasarela (por ejemplo, su cuenta de
Stripe), la factura puede salir ya cobrada en el mismo acto con ``payment``.
Para ventas a consumidores de hasta 400 € impuestos incluidos,
``type=InvoiceType.SIMPLIFIED`` emite una factura simplificada que no necesita
destinatario (sin ``recipient`` se imprime «Consumidor final»).

Si la organización está en el régimen especial del pequeño empresario del
IGIC (REPEP), manda las líneas como ``EXEMPT`` sin causa en una factura IGIC:
UTILIA OS pone ``EXEMPT_IGIC_SMALL_BUSINESS`` y la mención «Exención
franquicia fiscal». Una línea con cuota IGIC en ese periodo se rechaza con
``IGIC_SMALL_BUSINESS_TAXED_LINE``. Las ventas con IVA no cambian.

```python
from utilia_sdk import (
    ConsumerResidenceRegion,
    CreateInvoiceConsumerResidence,
    CreateInvoiceInput,
    CreateInvoiceLine,
    CreateInvoicePayment,
    CreateInvoiceUser,
    ExternalManualPaymentMethod,
    InvoiceType,
    MarkExternalInvoicePaidInput,
    TaxSystem,
)

ticket = await sdk.invoices.create(
    CreateInvoiceInput(
        type=InvoiceType.SIMPLIFIED,
        user=CreateInvoiceUser(external_id="user_01HXYZ", email="ana@example.com"),
        tax_system=TaxSystem.IGIC,
        lines=[
            CreateInvoiceLine(
                name="Plan Básico mensual", quantity=1, unit_price=39.99, tax_type="EXEMPT"
            )
        ],
        payment=CreateInvoicePayment(
            method=ExternalManualPaymentMethod.CARD, reference="pi_3QabcDEF"
        ),
        consumer_residence=CreateInvoiceConsumerResidence(
            region=ConsumerResidenceRegion.CANARIAS,
            evidence="Tarjeta y dirección en Tenerife",
        ),
        idempotency_key="pedido-4471",
    )
)
if ticket.payment_registered is False:
    # La factura ESTÁ emitida; falló el registro del cobro. Complétalo.
    await sdk.invoices.mark_paid(
        ticket.id,
        MarkExternalInvoicePaidInput(
            user_id="user_01HXYZ", method=ExternalManualPaymentMethod.CARD
        ),
    )
```

Con ``prices_include_tax=True``, ``unit_price`` es el precio CON impuestos y el
total de la línea es exactamente lo cobrado (39,99 con ``IGIC_7`` da una base
de 37,37 y una cuota de 2,62).

## Rectificativas (4.3.0)

Cuando devuelves el dinero de una venta o un contracargo la deja sin efecto,
``sdk.invoices.rectifications.create`` emite la rectificativa. Tu aplicación
solo dice la causa (``REFUND`` o ``CHARGEBACK``); el código legal lo decide
UTILIA OS: ``R5`` si la original es una simplificada no cualificada y ``R1``
en los demás casos, siempre por diferencias y con importes negativos. Un
contracargo por fraude o impago en el que la operación no queda sin efecto, o
un contracargo ganado, NO se rectifica: decidirlo es responsabilidad de quien
integra.

Si la devolución ya la hiciste en tu pasarela, mándala en ``refund``: UTILIA
OS la registra como «pasarela de pago externa» sin mover dinero.

```python
from utilia_sdk import (
    CreateExternalInvoiceRectificationInput,
    ExternalInvoiceRectificationCause,
    ExternalRectificationOperationStatus,
    ExternalRectificationRefundInput,
)

resultado = await sdk.invoices.rectifications.create(
    ticket.id,
    CreateExternalInvoiceRectificationInput(
        user_id="user_01HXYZ",
        idempotency_key="devolucion-pedido-4471",  # obligatoria
        cause=ExternalInvoiceRectificationCause.REFUND,
        # amount=20,  # con impuestos; sin él, todo lo que quede
        refund=ExternalRectificationRefundInput(reference="re_3QabcDEF"),
    ),
)
print(resultado.rectification.rectification_code)  # InvoiceRectificationCode.R5
print(resultado.original_invoice.status)  # InvoiceStatus.RECTIFIED
if resultado.operation_status == ExternalRectificationOperationStatus.REVIEW_REQUIRED:
    ...  # Repite la MISMA llamada con la MISMA clave: reanuda sin duplicar nada.

lista = await sdk.invoices.rectifications.list(ticket.id, "user_01HXYZ")
print(lista.remaining_rectifiable_amount)
```

Scope ``crm:invoices:rectify`` (crítico financiero: lo concede un
administrador general con motivo razonado), más ``crm:invoices:write`` si
registras la devolución y ``crm:invoices:send`` si se envía por correo. La
rectificativa es una factura más: se lee, se descarga y se envía con
``sdk.invoices.get``, ``download_pdf`` y ``send`` usando su ``id``.

Cambiar a mano la metadata legal de una rectificativa sigue reservado al
equipo: ``sdk.invoices.rectifications.update_legal_metadata(...)`` lanza
``RECTIFICATIONS_NOT_EXPOSED``. ``sdk.invoices.refunds.create`` sigue siendo la
vía para devolver dinero cobrado con la pasarela de UTILIA OS (scope
``billing:refund``).

## Series de facturación de tu aplicación (4.4.0)

Sin hacer nada, tus facturas ordinarias se numeran en la serie general de la
organización, intercaladas con las del equipo, y las simplificadas en ``T-``.
Si quieres que las de tu aplicación vayan aparte (por ejemplo,
``APP-202600001``), pide a quien administra la organización que cree una serie
para tu aplicación en UTILIA OS y, si quiere, que la fije como serie por
defecto de la aplicación. Tu aplicación no crea ni cambia series: solo elige
entre las suyas.

```python
from utilia_sdk import InvoiceType

lista = await sdk.invoices.list_series()  # scope crm:invoices:read
for serie in lista.series:
    print(serie.code, serie.document_type, serie.example, serie.is_app_default)
# DEFAULT STANDARD 202600001 False
# APP STANDARD APP-202600001 True
# DEFAULT SIMPLIFIED T-202600001 True
# DEFAULT RECTIFYING R-202600001 False   (selectable=False)

factura = await sdk.invoices.create(entrada.model_copy(update={"series_code": "APP"}))
print(factura.invoice_number, factura.series_code)  # APP-202600001 APP
```

- Sin ``series_code``: la serie por defecto de tu aplicación para el tipo y,
  si no tiene, la de siempre. ``series_code="DEFAULT"`` fuerza la de siempre.
- La serie tiene que ser de tu aplicación (si no, 400
  ``INVOICE_SERIES_NOT_AVAILABLE``), del mismo tipo que la factura (400
  ``INVOICE_SERIES_TYPE_MISMATCH``) y estar activa (409
  ``INVOICE_SERIES_INACTIVE``). Se comprueba antes de numerar.
- ``issue_date`` no puede ser futura ni anterior a la de la última factura de
  la misma serie (400 ``INVOICE_ISSUE_DATE_OUT_OF_ORDER``).
- Las rectificativas van siempre a la serie de rectificativas (``R-``).
- Las suscripciones admiten ``type`` y ``series_code``; la serie queda fijada
  en la suscripción.

## Ventanilla única del IVA (Próximamente)

La ventanilla única del IVA está en «Próximamente»: todavía no está disponible
para las organizaciones, y cualquier dato suyo responde 403
``FEATURE_COMING_SOON`` (compruébalo con ``is_feature_coming_soon_error(exc)``;
no es un bloqueo de plan). El SDK 4.4.0 ya trae su contrato:
``TaxSystem.EU_OSS``, líneas ``EU_VAT`` con ``vat_rate_class`` y
``oss_supply_type``, ``CreateInvoiceConsumerResidence(evidence_items=...)``,
``sdk.invoices.get_eu_vat_rates()``, los campos ``tax_system``,
``consumer_country_code`` y ``oss_scheme`` de las respuestas y los códigos
``OSS_*``. Leer una factura ``EU_OSS`` no falla. Quién usa qué régimen, la
prueba de la ubicación y los errores están en la guía «Ventanilla única del
IVA (OSS)» de la documentación para desarrolladores.

## 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`.

```python
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`:

```python
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
```

Desde la 4.3.0, ``InvoiceErrorCode`` e ``is_invoice_error_code`` tipan los
códigos de las facturas simplificadas, el régimen del pequeño empresario y las
rectificativas, y desde la 4.4.0 los de las series (``INVOICE_SERIES_*``) y la
fecha de expedición (``INVOICE_ISSUE_DATE_OUT_OF_ORDER``). Los datos que el
servidor manda en la raíz del cuerpo llegan en ``error.details``:

```python
from utilia_sdk import InvoiceErrorCode, UtiliaSDKError

try:
    await sdk.invoices.rectifications.create(invoice_id, entrada)
except UtiliaSDKError as exc:
    if exc.error_code == InvoiceErrorCode.RECTIFICATION_AMOUNT_EXCEEDS_REMAINING:
        restante = exc.details["remainingRectifiableAmount"]
```

El rechazo por origen se reconoce por ``error_code`` (``ORIGIN_NOT_ALLOWED`` o
``CORS_ORIGIN_NOT_ALLOWED``), nunca por el texto del mensaje.

## Actualizaciones en tiempo real (SSE)

Recibe notificaciones cuando un agente responde, cambia el estado, resuelve o cierra un ticket. La clave viaja en la cabecera `X-Api-Key`, nunca en la dirección. Si la conexión se corta, vuelve a llamar a `stream_updates`:

### Asíncrono

```python
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

```python
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']}")
```

### Pase de conexión para un navegador (4.2)

Las conexiones del propio SDK no lo necesitan. Si quieres que un navegador
abra la conexión, tu servidor pide un pase de un solo uso y cinco minutos y le
entrega solo el pase, nunca la clave:

```python
pase = await sdk.tickets.create_stream_token("ticket-events", user_id="user-123")
# pase.url ya lleva ?streamToken=…; pase.expires_at, pase.key_version
```

`ticket-events` admite `user_id` (obligatorio en las aplicaciones con widget:
sin él, 400 `STREAM_TOKEN_USER_REQUIRED`); `ai-suggest` y `ai-transcribe`
exigen `job_id`. Las combinaciones que el servidor rechazaría fallan antes de
la petición.

## Capturar errores en dos líneas (4.6.0)

``python
from utilia_sdk import UtiliaSDKSync

sdk = UtiliaSDKSync(base_url="https://os.utilia.ai/api", api_key="tu-api-key")
sdk.errors.install(release="2.4.1", environment="production")

try:
    cobrar(pedido)
except Exception:
    sdk.errors.capture_exception(module="pagos", context={"pedido_id": pedido.id})
    raise
``

`install` engancha `sys.excepthook`, `threading.excepthook` y, si se
llama con un bucle de `asyncio` en marcha, su manejador de excepciones,
siempre encadenando el anterior (el traceback se sigue imprimiendo igual), y
vacía la cola al salir del programa (`atexit`, hasta 2 segundos).
`capture_exception()` sin argumento usa la excepción que se está tratando.
`capture_exception` y `capture_message` NUNCA lanzan: devuelven un
`concurrent.futures.Future` con lo que contestó el servidor
(`ErrorCaptureResult`) o `None`; no hace falta esperarlo.

- La cola sale en lotes de hasta 25 por `POST /external/v1/errors/batch`
  cada `flush_interval` segundos (2 por defecto) desde un hilo propio, también
  en `UtiliaSDK` asíncrono; dos errores idénticos en menos de un segundo se
  cuentan como uno; lo que no cabe en `max_queue` (100) se descarta.
- Un `429` o un `503` pausan la cola lo que diga `Retry-After`; solo se
  repiten los elementos con `storage_failed`. Un fallo del propio envío jamás
  se vuelve a reportar.
- `before_send(evento)` recibe un `ReportErrorInput` y devuelve el mismo o
  `None` para descartarlo; `sample_rate` (0 a 1) envía solo una fracción.
- `sdk.errors.flush(2.0)` (en el SDK asíncrono, `await`) envía ya lo
  pendiente.

## Reportar errores del sistema

``python
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",
            release="2.4.1",
            environment="production",
        ))
        print(result.accepted)     # False si el servidor lo descartó
        print(result.status)       # "created", "updated" o "regressed"
        print(result.hash)         # Hash de agrupación
``

`report` es la llamada directa: lanza `UtiliaSDKError` si falla (un
`503` con `ERROR_STORAGE_UNAVAILABLE` y `retry_after` se puede
reintentar). Para varios a la vez, `report_batch` (de 1 a 25, un solo
intento; repite solo los `storage_failed`). Con el alcance
`analytics:errors:manage`: `get`, `resolve`, `reopen`, `silence` (de 1
a 672 horas) y `unsilence`. Un error resuelto que vuelve a ocurrir se reabre
solo y avisa con `ERROR_REOPENED` (`reason: 'regression'`).

## OAuth y Sign In

Desde la versión 0.5.0, el SDK incluye soporte nativo para OAuth 2.1 con PKCE:

```python
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/developers/sdk/oauth

## Manejo de errores

```python
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}")
```

### Quién lo arregla y qué permiso falta (4.6.0)

La API pública añade a sus errores `fixedBy` (`developer`, `organization` o
`utilia`), `docsUrl` y, en el 403 `INSUFFICIENT_SCOPE`, `requiredScopes`; el SDK
los deja en `error.fixed_by`, `error.docs_url` y `error.required_scopes`.
`INSUFFICIENT_SCOPE` se exporta con `is_insufficient_scope_error(error)` e
`INSUFFICIENT_SCOPE_MESSAGE` queda en desuso: el servidor ya nombra el permiso.
El 409 llega como `ErrorCode.CONFLICT` (hasta la 4.5, `UNKNOWN`). Un 429 o 503
con espera trae `error.retry_after` en segundos. Se exportan también
`DIAGNOSTICS_RATE_LIMITED`, `DIAGNOSTICS_NOT_AVAILABLE_FOR_PUBLISHABLE_KEY`,
`BUDGET_ERROR_CODES` y, en `CredentialErrorCode`,
`API_KEY_WRONG_KIND`, `API_KEY_WRONG_HEADER` y `AUTH_TOKEN_INVALID`.

Todo error de `/external/v1` trae además `retryable`, que el SDK deja en
`error.retryable` y respeta: con `False` no repite la petición aunque sea un
429 o un 5xx, y con `True` no amplía nada (un 4xx o una escritura sin clave de
idempotencia siguen sin repetirse solos). `PUBLIC_API_ERROR_CATALOG` es el
catálogo público de errores, con cada `error_code` y su `fixed_by` y su
`retryable` por defecto (`PublicApiErrorInfo`); para reaccionar a un error
concreto, usa los del error. `RequestErrorCode` reúne los de la forma de la
petición: `INVALID_JSON` y `JSON_CONTENT_TYPE_REQUIRED` (400),
`METHOD_NOT_ALLOWED` (405, con los métodos admitidos en la cabecera `Allow` y
en `error.details["allowedMethods"]`) y `REQUEST_BODY_TOO_LARGE` (413, con el
tope en `error.details["details"]["limitBytes"]`).

Lo que el SDK comprueba antes de llamar lo dice en castellano y con el mismo
criterio que el servidor (`title: escribe al menos 5 caracteres (ahora tiene
3)`, `job_id: falta este campo obligatorio`); los servicios lanzan
`UtiliaSDKError` con `VALIDATION_ERROR` y `fixed_by == "developer"`.

Una clave usada desde un navegador no llama a rutas que exigen permisos
sensibles: responden 403 `SCOPE_NOT_ALLOWED_FROM_BROWSER`, y esas llamadas se
hacen desde tu servidor con la clave de una aplicación sin dominios
autorizados. Las claves con dominios autorizados son las que van en
páginas web, y el panel y el MCP rechazan con 400
`SCOPE_NOT_ALLOWED_WITH_ALLOWED_ORIGINS` darles permisos sensibles. El
diagnóstico lo avisa antes con el hallazgo `PUBLISHABLE_KEY_SENSITIVE_SCOPES`.

Si una escritura sale bien pero el servidor descartó campos del cuerpo (un
nombre mal escrito), la respuesta trae `X-Utilia-Ignored-Fields` y el SDK lo
avisa en el registro `utilia_sdk`, o con `on_ignored_fields` si lo pasas.

### Errores de las credenciales (4.2)

`CredentialErrorCode` (comparable con `error.error_code`) e
`is_credential_error_code` reúnen los códigos de las credenciales.
`METHOD_NOT_AVAILABLE_WITH_API_KEY` se exporta desde `utilia_sdk`.

- **Cambio de contrato de la API:** una aplicación cuya persona responsable no
  está activa recibe 403 `APP_RESPONSIBLE_INACTIVE`, en lugar del 401
  `API_KEY_INVALID` o el 422 `EXTERNAL_APP_CREATOR_MISSING` de antes.
  `EXTERNAL_APP_CREATOR_MISSING` queda solo para una aplicación sin
  organización vinculada.
- Códigos nuevos: 401 `API_KEY_IN_URL_NOT_ALLOWED`, 401 `STREAM_TOKEN_INVALID`,
  503 `STREAM_TOKEN_UNAVAILABLE` y 400 `STREAM_TOKEN_USER_REQUIRED`.
- Con OAuth, un 401 `OAUTH_BEARER_NOT_ALLOWED` no renueva el token ni repite la
  petición.

### Confirmar una operación MCP en el móvil

Renovar la clave de una aplicación o su clave de firma por MCP pide, a quien
tiene la verificación en dos pasos, confirmarlo en el móvil:

```python
from utilia_sdk import UtiliaSDKError, get_approval_challenge

try:
    await sdk.mcp.integrations.apps_regenerate_api_key(app_id, grace_days=7)
except UtiliaSDKError as exc:
    reto = get_approval_challenge(exc)
    if reto is None:
        raise
    print(f"Elige el número {reto.number} en el móvil.")
    # Cuando la persona lo apruebe:
    await sdk.mcp.integrations.apps_regenerate_api_key(
        app_id, grace_days=7, two_factor_approval_id=reto.approval_id
    )
```

La aplicación de iPhone que hay hoy en la App Store todavía no aprueba estas
confirmaciones; llegará en una próxima versión.

## Licencia

MIT
