# Primeros 15 minutos con la API

Del alta de la aplicación a tu primer ticket: identificar al usuario, abrir una incidencia, pedir sugerencias de IA, enviar errores y tratar los fallos.

Esta guía te lleva de cero a una integración que funciona: das de alta la aplicación, le concedes permisos, identificas a un usuario, abres un ticket en su nombre, pides sugerencias de IA y aprendes a tratar los errores sin que tu interfaz se rompa.

Necesitas la clave de API de una aplicación externa. Los ejemplos usan tickets de soporte, así que el plan de tu organización tiene que incluir el aplicativo **Tickets de soporte** con la capacidad «Tickets propios», además de **Integraciones**.

Esta es la conversación completa entre tu servidor y UTILIA OS; los pasos de abajo la construyen una petición cada vez:

**Tu primera integración, de principio a fin**

Participantes: Tu servidor y UTILIA OS.

1. Tu servidor → UTILIA OS: `POST /external/v1/users/identify`. Da de alta a tu usuario con el externalId que tú eliges.
2. Tu servidor → UTILIA OS: `POST /external/v1/tickets`. Abre un ticket en su nombre.
3. UTILIA OS → Tu servidor (respuesta): 201 con el ticket creado
4. Tu servidor → UTILIA OS: `POST /external/v1/tickets/ai-suggest`. Pide sugerencias de IA para lo que escribe tu usuario.
5. UTILIA OS → Tu servidor (respuesta): jobId. El trabajo queda en cola.
6. Tu servidor → UTILIA OS: `GET /external/v1/tickets/ai-suggest/{jobId}/stream`. Espera el resultado en tiempo real.
7. UTILIA OS → Tu servidor (respuesta): completed, con título, descripción, categoría y prioridad
8. UTILIA OS (por su cuenta): Tu equipo contesta el ticket desde Tickets de soporte.
9. UTILIA OS → Tu servidor: Aviso TICKET_REPLIED. Llega a la dirección de avisos de tu aplicación.

_Todas las peticiones llevan la cabecera X-Api-Key; el aviso final llega firmado._

1. **Crea la aplicación y guarda su clave**

   Cada integración necesita su propia aplicación externa. Créala en **Ajustes › Creadas por tu empresa** con **Nueva aplicación**, marca «Tickets y soporte» en «¿Para qué la usarás?» y pulsa **Crear la aplicación**. Al terminar verás la clave de API, del tipo `utl_xxxxxxxxxxxxxxxx`, que enviarás en cada petición:

   ```http
   X-Api-Key: utl_xxxxxxxxxxxxxxxx
   ```

   La clave es secreta: úsala desde tu servidor y no la subas nunca al repositorio. Si una web tuya tiene que llamar a la API desde el navegador, como el widget de soporte, usa otra aplicación con dominios autorizados y solo los permisos del navegador; lo explica [Claves dentro de una página web](https://os.utilia.ai/docs/developers/getting-started/rate-limits-and-cors#claves-dentro-de-una-página-web). El alta paso a paso está en [Panel de aplicaciones externas](https://os.utilia.ai/docs/developers/api/apps-panel).
2. **Concede los permisos**

   La aplicación nace sin permisos. En su ficha, **Configuración › Ajustes**, pestaña **Permisos**, aplica el conjunto «Tickets y soporte», que concede:

   - `crm:users:read` y `crm:users:write`, para listar y dar de alta usuarios.
   - `support:tickets:read` y `support:tickets:write`, para leer y abrir tickets.

   Si vas a seguir los pasos de IA y de errores, añade a mano «IA en tickets de soporte» (`support:tickets:ai`) y «Enviar errores de la aplicación» (`analytics:errors:write`).
3. **Identifica al usuario final**

   Antes de crear tickets, pedir IA o emitir facturas para un usuario, llama a `POST /users/identify`. Es el paso que vincula tu usuario con UTILIA OS mediante un `externalId` que tú eliges.

   Casi todos los endpoints que reciben `userId` en la dirección o como campo suelto del cuerpo dan por hecho que el usuario ya existe; si no, responden `404` con `errorCode: USER_NOT_FOUND`. Las excepciones son los que aceptan un objeto `user: { externalId, email, name }` en el cuerpo, como `POST /tickets` y `POST /invoices`: crean el usuario si no existe y lo actualizan si ya existe.

   **curl**

   ```bash
   curl -X POST 'https://os.utilia.ai/api/external/v1/users/identify' \
     -H 'Content-Type: application/json' \
     -H 'X-Api-Key: utl_xxx' \
     -d '{
       "externalId": "user_42",
       "email": "ada@example.com",
       "name": "Ada Lovelace"
     }'
   ```

   **fetch**

   ```javascript
   await fetch('https://os.utilia.ai/api/external/v1/users/identify', {
     method: 'POST',
     headers: {
       'Content-Type': 'application/json',
       'X-Api-Key': 'utl_xxx',
     },
     body: JSON.stringify({
       externalId: 'user_42',
       email: 'ada@example.com',
       name: 'Ada Lovelace',
     }),
   });
   ```

   **python**

   ```python
   import httpx

   async with httpx.AsyncClient() as client:
       await client.post(
           'https://os.utilia.ai/api/external/v1/users/identify',
           headers={'X-Api-Key': 'utl_xxx'},
           json={
               'externalId': 'user_42',
               'email': 'ada@example.com',
               'name': 'Ada Lovelace',
           },
           timeout=30,
       )
   ```

   Llama a `identify` cuando el usuario inicia sesión en tu aplicación y otra vez si cambian su correo o su nombre. Detalle en [Usuarios externos](https://os.utilia.ai/docs/developers/api/users).

   Con el SDK de JavaScript o de Python, identifica al usuario de forma explícita con `sdk.users.identify(...)` antes de operar sobre él: el SDK no lo hace por ti. Ver la configuración del [SDK de JavaScript](https://os.utilia.ai/docs/developers/sdk/configuration) o del [SDK de Python](https://os.utilia.ai/docs/developers/sdk/configuration).
4. **Abre un ticket**

   Tienes dos formas equivalentes de crear el ticket. Elige la que mejor encaje con tu flujo:

   **Opción A: deja que el propio endpoint cree el usuario.** No necesita `identify` previo: UTILIA OS busca al usuario por la pareja aplicación más `externalId` y lo crea si no existe.

   **fetch**

   ```javascript
   const res = await fetch('https://os.utilia.ai/api/external/v1/tickets', {
     method: 'POST',
     headers: {
       'Content-Type': 'application/json',
       'X-Api-Key': 'utl_xxx',
     },
     body: JSON.stringify({
       user: {
         externalId: 'user_42',
         email: 'ada@example.com',
         name: 'Ada Lovelace',
       },
       title: 'No puedo subir archivos al panel',
       description: 'Recibo un timeout al intentar adjuntar el PDF de la factura.',
       category: 'BUG',
       priority: 'ALTA',
       context: { url: 'https://mi-app.com/facturas/123' },
     }),
   });
   const ticket = await res.json();
   ```

   **python**

   ```python
   res = await client.post(
       'https://os.utilia.ai/api/external/v1/tickets',
       headers={'X-Api-Key': 'utl_xxx'},
       json={
           'user': {
               'externalId': 'user_42',
               'email': 'ada@example.com',
               'name': 'Ada Lovelace',
           },
           'title': 'No puedo subir archivos al panel',
           'description': 'Recibo un timeout al intentar adjuntar el PDF de la factura.',
           'category': 'BUG',
           'priority': 'ALTA',
           'context': {'url': 'https://mi-app.com/facturas/123'},
       },
       timeout=30,
   )
   ticket = res.json()
   ```

   **Opción B: si ya llamaste a `/users/identify`**, envía solo `user.externalId`. Los datos que omitas no se sobrescriben.

   Todos los campos del ticket están en [Endpoints básicos de tickets](https://os.utilia.ai/docs/developers/api/tickets).
5. **Pide sugerencias de IA**

   La IA analiza el texto, el audio o las imágenes que escribe tu usuario y propone `title`, `description`, `category` y `priority` para el ticket. A diferencia de `POST /tickets`, `POST /tickets/ai-suggest` **no crea el usuario**: el `userId` del cuerpo tiene que existir ya, o la respuesta es `404` con `USER_NOT_FOUND`.

   **fetch**

   ```javascript
   // En tu servidor, con la clave:
   // 1. Solicitar sugerencias (devuelve un jobId)
   const enqueue = await fetch(
     'https://os.utilia.ai/api/external/v1/tickets/ai-suggest',
     {
       method: 'POST',
       headers: {
         'Content-Type': 'application/json',
         'X-Api-Key': 'utl_xxx',
       },
       body: JSON.stringify({
         userId: 'user_42', // debe estar previamente identificado
         text: 'No puedo subir archivos. Sale un timeout cuando adjunto el PDF.',
         existingFields: { title: '', description: '' },
         source: 'SDK_EXTERNAL',
       }),
     },
   );
   const { jobId } = await enqueue.json();

   // 2. Recibir el resultado en tiempo real (o consultando el estado).
   //    EventSource no puede enviar cabeceras: tu servidor pide un pase de un
   //    solo uso con la clave en la cabecera...
   const pase = await fetch('https://os.utilia.ai/api/external/v1/tickets/stream-token', {
     method: 'POST',
     headers: { 'Content-Type': 'application/json', 'X-Api-Key': 'utl_xxx' },
     body: JSON.stringify({ route: 'ai-suggest', jobId }),
   }).then((r) => r.json());

   // ...y el navegador abre la conexión solo con el pase, nunca con la clave.
   const eventSource = new EventSource(
     `https://os.utilia.ai/api/external/v1/tickets/ai-suggest/${jobId}/stream?streamToken=${pase.streamToken}`,
   );
   eventSource.onmessage = (msg) => {
     const data = JSON.parse(msg.data);
     if (data.event === 'completed') {
       console.log('Sugerencias:', data.result.suggestions);
       eventSource.close();
     }
   };
   ```

   **python**

   ```python
   # 1. Solicitar sugerencias
   res = await client.post(
       'https://os.utilia.ai/api/external/v1/tickets/ai-suggest',
       headers={'X-Api-Key': 'utl_xxx'},
       json={
           'userId': 'user_42',
           'text': 'No puedo subir archivos. Sale un timeout cuando adjunto el PDF.',
           'existingFields': {'title': '', 'description': ''},
           'source': 'SDK_EXTERNAL',
       },
       timeout=30,
   )
   job_id = res.json()['jobId']

   # 2. Consultar el estado (alternativa al tiempo real)
   import asyncio
   while True:
       status = await client.get(
           f'https://os.utilia.ai/api/external/v1/tickets/ai-suggest/{job_id}',
           headers={'X-Api-Key': 'utl_xxx'},
           timeout=30,
       )
       body = status.json()
       if body['status'] == 'completed':
           suggestions = body['result']['suggestions']
           break
       if body['status'] == 'failed':
           raise RuntimeError(body['error'])
       await asyncio.sleep(1)
   ```

   Opciones de audio e imágenes en [Endpoints de sugerencias con IA](https://os.utilia.ai/docs/developers/api/ticket-ai-suggestions).
6. **Envía los errores de tu aplicación**

   Con el SDK, la captura son dos líneas: `sdk.errors.install()` al arrancar, que recoge sola lo que nadie trata, y `sdk.errors.capture(error)` donde ya atrapas un error. Nunca lanzan y envían en lotes ([Capturar los errores de tu aplicación](https://os.utilia.ai/docs/developers/sdk/error-reporting)).

   Sin el SDK, hay dos endpoints distintos, y confundirlos es de lo más habitual al integrar:

   | Endpoint | Para qué sirve | Quién lo llama | Campos |
   | - | - | - | - |
   | `POST /errors/report` y `POST /errors/batch` | Los **errores de tu aplicación**, con contexto, uno a uno o en lotes de hasta 25 | Tu servidor, o el SDK con `install` y `capture` | `message` y `module` (obligatorios), y además `severity`, `stack`, `file`, `line`, `function`, `endpoint`, `method`, `userId`, `requestId`, `context`, `release`, `environment`, `fingerprint` y `tags` |
   | `POST /tickets/client-errors` | Los **fallos del propio widget** y de `ai.reportError`, en lotes de hasta 10 | El widget y el SDK | `errors[]` con `message`, `stack`, `code`, `severity`, `endpoint`, `method`, `requestId`, `component`, `url`, `userAgent`, `release` y `environment` |

   Regla práctica: los errores de tu aplicación van a `/errors/report` o `/errors/batch`; `/tickets/client-errors` es la telemetría ligera del widget. Detalle en [Enviar los errores de tu aplicación](https://os.utilia.ai/docs/developers/api/errors) y [Telemetría ligera del widget y del SDK](https://os.utilia.ai/docs/developers/api/client-errors).
7. **Trata los errores en tu interfaz**

   Cuando algo falla, UTILIA OS responde siempre con la misma forma:

   ```json
   {
     "statusCode": 404,
     "message": "Usuario externo no encontrado. Usa /users/identify primero.",
     "error": "NOT_FOUND",
     "errorCode": "USER_NOT_FOUND",
     "timestamp": "2026-05-07T10:31:12.000Z",
     "requestId": "550e8400-e29b-41d4-a716-446655440000"
   }
   ```

   > **Atención: No pases el cuerpo del error a tu interfaz**
   >
   > `error.response.data` es un **objeto**, no un texto. Si lo pintas con `toast.error(error.response.data)` o lo metes como hijo en JSX, React lanza «Objects are not valid as a React child» y la pantalla se rompe. Muestra `message`, decide con `errorCode` y guarda `requestId` para soporte.

   La receta completa está en [Errores, identificador de petición y reintentos](https://os.utilia.ai/docs/developers/getting-started/errors-and-retries).
8. **Recibe avisos en tu servidor**

   Para enterarte de lo que pasa sin preguntar (una respuesta en un ticket con `TICKET_REPLIED`, su resolución con `TICKET_RESOLVED`, un usuario nuevo con `USER_CREATED`), configura la dirección de avisos en **Configuración › Avisos a tu servidor**, en la ficha, y comprueba la firma de cada entrega. Es opcional, pero ahorra muchas consultas.

   Catálogo de eventos y firma en [Avisos a tu servidor: visión general](https://os.utilia.ai/docs/developers/webhooks/overview).

## Errores típicos en la primera integración

| Síntoma | Causa más probable | Qué hacer |
| - | - | - |
| `403 INSUFFICIENT_SCOPE` | La aplicación no tiene el permiso de esa operación; `requiredScopes` dice cuál | Concédelo en **Configuración › Ajustes**, pestaña **Permisos**, de la ficha de la aplicación |
| `404 USER_NOT_FOUND` al llamar a `ai-suggest` u otros endpoints de tickets e IA con `userId` suelto | El usuario no se identificó antes | Llama a `POST /users/identify` y repite. Con el SDK, `sdk.users.identify(...)` |
| `404 EXTERNAL_USER_NOT_FOUND` en `invoices/stats`, `invoices/:id`, `invoices/:id/pay`, `payment-methods` u otros endpoints de facturación | Lo mismo: los endpoints de facturación usan este código | Trátalo igual que `USER_NOT_FOUND` |
| Faltan `module`, `file` o `line` en los errores registrados | Enviaste a `/tickets/client-errors` un error de tu aplicación: esos campos se descartan | Usa `sdk.errors.capture` o `/errors/report` |
| React: «Objects are not valid as a React child» tras un fallo de la API | Pintaste el objeto de error completo | Muestra `message` |
| `403 APP_DISABLED` en cualquier endpoint | Alguien desactivó la aplicación | Reactívala desde su ficha (ver [Panel de aplicaciones externas](https://os.utilia.ai/docs/developers/api/apps-panel)) |
| `429 RATE_LIMIT_EXCEEDED` | Agotaste un cupo por hora: el de la aplicación o el propio de una ruta, como contactos o clientes. El mensaje dice cuál | Respeta `Retry-After` y reparte las llamadas; las cifras están en [Límites de uso y CORS](https://os.utilia.ai/docs/developers/getting-started/rate-limits-and-cors#los-límites-de-un-vistazo) |
| `403 BILLING_NOT_ENABLED` al emitir la primera factura | La aplicación tiene permisos de facturas, pero su facturación está apagada | Un Super Administrador la enciende con **Activar la facturación**, en **Dinero › Facturación** de la ficha de la aplicación. El **Resumen** y la pestaña **Pruebas** lo marcan como paso pendiente, y `POST /external/v1/invoices/validate` lo avisa sin emitir nada |

## Siguiente paso

- Repasa [Dónde va cada parámetro](https://os.utilia.ai/docs/developers/getting-started/parameters): es la causa número uno de errores `400` y `404` en las primeras integraciones.
- Si vas a emitir facturas, sigue la [guía de facturación y cobro](https://os.utilia.ai/docs/developers/api/billing-guide) y, antes de la primera real, compruébalo todo sin emitir nada con [Probar la facturación sin emitir facturas](https://os.utilia.ai/docs/developers/api/invoice-testing).
- Antes de pasar a producción, vigila tu integración desde la pestaña de métricas de su ficha: lo explica [Métricas de la API](https://os.utilia.ai/docs/developers/api/apps-panel#métricas-de-la-api).
