# Tickets de soporte

Endpoints para crear, consultar, cerrar y reabrir tickets de soporte, añadirles mensajes y recibir sus cambios en tiempo real, con los permisos support:tickets:*.

Con estos endpoints los usuarios de tu aplicación abren tickets de
soporte, envían mensajes, adjuntan archivos y reciben las respuestas de
tu equipo sin salir de tu aplicación. Todas las peticiones llevan la
cabecera `X-Api-Key` con una clave válida.

## Plan y permisos

Antes que el permiso de la aplicación se comprueba el plan de tu
organización: hace falta el aplicativo **Tickets de soporte** con la
capacidad de tickets propios (`support.own_tickets`). Si falta, la
respuesta es `403` con `MODULE_NOT_ENABLED` o `CAPABILITY_NOT_ENABLED`.

| Endpoint | Permiso de la API (scope) |
| - | - |
| `POST /external/v1/tickets` | `support:tickets:write` |
| `GET /external/v1/tickets` | `support:tickets:read` |
| `GET /external/v1/tickets/:id` | `support:tickets:read` |
| `GET /external/v1/tickets/unread-count` | `support:tickets:read` |
| `GET /external/v1/tickets/events` (SSE) | `support:tickets:read` |
| `POST /external/v1/tickets/stream-token` | `support:tickets:read` para `ticket-events`; `support:tickets:ai` para las conexiones de IA |
| `POST /external/v1/tickets/:id/messages` | `support:tickets:write` |
| `PATCH /external/v1/tickets/:id/close` | `support:tickets:write` |
| `PATCH /external/v1/tickets/:id/reopen` | `support:tickets:write` |
| `POST /external/v1/tickets/ai-suggest` | `support:tickets:ai` |
| `POST /external/v1/tickets/ai-transcribe`, `ai-transcribe-async` | `support:tickets:ai` |

Sin el permiso adecuado, los endpoints responden `403 INSUFFICIENT_SCOPE`
con el permiso que falta en
`requiredScopes`; lo concede un administrador de la organización. El catálogo y las reglas de concesión están en
[Permisos de la API](https://os.utilia.ai/docs/developers/api/scopes).

> **Atención: No hay periodo de gracia**
>
> Versiones anteriores de esta página anunciaban catorce días de
> tolerancia para las aplicaciones dadas de alta antes del 25 de mayo de
> 2026\. Esa tolerancia no se aplica: el permiso se exige en todas las
> llamadas desde el primer momento, también en aplicaciones antiguas.
> Concédelo en la ficha de la aplicación antes de integrar.

`support:tickets:ai` (sugerencias y transcripciones) es un permiso
sensible: consume cuota de IA y envía datos del usuario a un proveedor
externo. Solo lo concede un Super Administrador, con un motivo de al
menos 20 caracteres.

## Resumen de endpoints

| Método | Ruta | Descripción |
| - | - | - |
| `POST` | `/api/external/v1/tickets` | Crear un nuevo ticket |
| `GET` | `/api/external/v1/tickets` | Listar tickets de un usuario |
| `GET` | `/api/external/v1/tickets/:id` | Ver el detalle de un ticket |
| `GET` | `/api/external/v1/tickets/unread-count` | Recuento de mensajes sin leer |
| `POST` | `/api/external/v1/tickets/:id/messages` | Añadir un mensaje a un ticket |
| `PATCH` | `/api/external/v1/tickets/:id/close` | Cerrar un ticket |
| `PATCH` | `/api/external/v1/tickets/:id/reopen` | Reabrir un ticket |
| `GET` | `/api/external/v1/tickets/events` | Cambios en tiempo real (SSE) |
| `POST` | `/api/external/v1/tickets/stream-token` | Pase de conexión para el tiempo real, sin la clave en la dirección |

## Crear un ticket

**`POST /api/external/v1/tickets`**

Crea un ticket de soporte a nombre de un usuario externo. Permiso: support:tickets:write.

### Encabezados requeridos

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `X-Api-Key` | `string` | Sí | Clave de API externa (formato: utl_xxx) |
| `Content-Type` | `string` | Sí | application/json |
| `x-request-id` | `string` | No | Identificador único para seguir la petición |

### Cuerpo de la petición

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `user` | `ExternalUserDto` | Sí | Datos del usuario que crea el ticket |
| `title` | `string` | Sí | Título del ticket (entre 5 y 500 caracteres) |
| `description` | `string` | Sí | Descripción detallada del problema (mínimo 10 caracteres) |
| `category` | `string` | No | Categoría del ticket: BUG, CONSULTA o SUGERENCIA |
| `priority` | `string` | No | Prioridad del ticket: BAJA, MEDIA, ALTA o CRITICA |
| `context` | `ExternalContextDto` | No | Información del entorno donde se originó el ticket |
| `attachmentIds` | `string[]` | No | Identificadores de archivos adjuntos ya subidos (ver Archivos) |

#### Objeto `user` (ExternalUserDto)

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `externalId` | `string` | Sí | Identificador único del usuario en tu sistema (máximo 255 caracteres) |
| `email` | `string` | No | Correo electrónico del usuario (máximo 255 caracteres) |
| `name` | `string` | No | Nombre del usuario (máximo 255 caracteres) |
| `metadata` | `object` | No | Metadatos adicionales del usuario en formato clave-valor |

#### Objeto `context` (ExternalContextDto)

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `url` | `string` | No | URL de la página donde se originó el ticket |
| `appVersion` | `string` | No | Versión de la aplicación del usuario |
| `browserInfo` | `string` | No | Información del navegador (user agent) |
| `customData` | `object` | No | Datos personalizados adicionales en formato clave-valor |

### Respuesta correcta (201 Created)

| Nombre | Tipo | Descripción |
| - | - | - |
| `id` | `string` | Identificador del ticket (UUID) |
| `ticketKey` | `string` | Clave única legible del ticket (ejemplo: APP-0001) |
| `title` | `string` | Título del ticket |
| `status` | `string` | Estado inicial del ticket (siempre OPEN) |
| `createdAt` | `string` | Fecha de creación en formato ISO 8601 |

### Errores

| Código HTTP | Código de error | Descripción |
| - | - | - |
| 400 | `VALIDATION_ERROR` | El cuerpo de la petición no cumple las validaciones (título muy corto, descripción ausente, etc.) |
| 401 | -- | La clave de API es inválida o no fue proporcionada |

### Ejemplos

#### curl

```bash
curl -X POST 'https://os.utilia.ai/api/external/v1/tickets' \
  -H 'Content-Type: application/json' \
  -H 'X-Api-Key: utl_tu_clave_de_api' \
  -H 'x-request-id: req-crear-ticket-001' \
  -d '{
    "user": {
      "externalId": "usr-4821",
      "email": "laura@ejemplo.com",
      "name": "Laura García"
    },
    "title": "No puedo acceder a la sección de facturación",
    "description": "Al intentar entrar en Facturación desde el menú lateral, la página muestra un error 500 y no carga ningún contenido.",
    "category": "BUG",
    "priority": "ALTA",
    "context": {
      "url": "https://mi-app.com/dashboard/facturación",
      "appVersion": "2.4.1",
      "browserInfo": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0"
    }
  }'
```

#### fetch

```javascript
const response = await fetch('https://os.utilia.ai/api/external/v1/tickets', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-Api-Key': 'utl_tu_clave_de_api',
    'x-request-id': 'req-crear-ticket-001',
  },
  body: JSON.stringify({
    user: {
      externalId: 'usr-4821',
      email: 'laura@ejemplo.com',
      name: 'Laura García',
    },
    title: 'No puedo acceder a la sección de facturación',
    description:
      'Al intentar entrar en Facturación desde el menú lateral, la página muestra un error 500 y no carga ningún contenido.',
    category: 'BUG',
    priority: 'ALTA',
    context: {
      url: 'https://mi-app.com/dashboard/facturación',
      appVersion: '2.4.1',
      browserInfo: navigator.userAgent,
    },
  }),
});

const ticket = await response.json();
console.log(ticket.ticketKey); // APP-0042
```

#### axios

```javascript
import axios from 'axios';

const { data: ticket } = await axios.post(
  'https://os.utilia.ai/api/external/v1/tickets',
  {
    user: {
      externalId: 'usr-4821',
      email: 'laura@ejemplo.com',
      name: 'Laura García',
    },
    title: 'No puedo acceder a la sección de facturación',
    description:
      'Al intentar entrar en Facturación desde el menú lateral, la página muestra un error 500 y no carga ningún contenido.',
    category: 'BUG',
    priority: 'ALTA',
    context: {
      url: 'https://mi-app.com/dashboard/facturación',
      appVersion: '2.4.1',
      browserInfo: navigator.userAgent,
    },
  },
  {
    headers: {
      'Content-Type': 'application/json',
      'X-Api-Key': 'utl_tu_clave_de_api',
      'x-request-id': 'req-crear-ticket-001',
    },
  }
);

console.log(ticket.ticketKey); // APP-0042
```

#### Python

```python
import requests

response = requests.post(
    "https://os.utilia.ai/api/external/v1/tickets",
    headers={
        "Content-Type": "application/json",
        "X-Api-Key": "utl_tu_clave_de_api",
        "x-request-id": "req-crear-ticket-001",
    },
    json={
        "user": {
            "externalId": "usr-4821",
            "email": "laura@ejemplo.com",
            "name": "Laura García",
        },
        "title": "No puedo acceder a la sección de facturación",
        "description": "Al intentar entrar en Facturación desde el menú lateral, la página muestra un error 500 y no carga ningún contenido.",
        "category": "BUG",
        "priority": "ALTA",
        "context": {
            "url": "https://mi-app.com/dashboard/facturación",
            "appVersion": "2.4.1",
            "browserInfo": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0",
        },
    },
    timeout=30,
)

ticket = response.json()
print(ticket["ticketKey"])  # APP-0042
```

`user.email` y `user.name` no son obligatorios, pero conviene enviarlos: tu equipo de soporte identificará antes a la persona.

## Listar los tickets de un usuario

**`GET /api/external/v1/tickets`**

Lista los tickets de un usuario externo, paginados y con filtros opcionales. Permiso: support:tickets:read.

### Parámetros de consulta (query)

| Nombre | Tipo | Obligatorio | Por defecto | Descripción |
| - | - | - | - | - |
| `userId` | `string` | Sí | | Identificador externo del usuario. La API solo devuelve sus tickets. |
| `status` | `string` | No | | Filtra por estado: OPEN, IN_REVIEW, CONVERTED, RESOLVED o CLOSED |
| `page` | `number` | No | `1` | Número de página |
| `limit` | `number` | No | `20` | Elementos por página (máximo 100) |

### Respuesta correcta (200 OK)

La respuesta tiene dos campos: `data` (la lista de tickets) y `pagination` (la paginación).

#### Campos de cada ticket en `data`

| Nombre | Tipo | Descripción |
| - | - | - |
| `id` | `string` | ID del ticket (UUID) |
| `ticketKey` | `string` | Clave única legible (ejemplo: APP-0012) |
| `title` | `string` | Título del ticket |
| `category` | `string` | Categoría: BUG, CONSULTA o SUGERENCIA |
| `priority` | `string` | Prioridad: BAJA, MEDIA, ALTA o CRITICA |
| `status` | `string` | Estado actual: OPEN, IN_REVIEW, CONVERTED, RESOLVED o CLOSED |
| `unreadByExternal` | `number` | Cantidad de mensajes no leídos por el usuario externo |
| `createdAt` | `string` | Fecha de creación en formato ISO 8601 |
| `lastActivityAt` | `string` | Fecha de la última actividad en el ticket |

#### Campos de `pagination`

| Nombre | Tipo | Descripción |
| - | - | - |
| `page` | `number` | Página actual |
| `limit` | `number` | Elementos por página |
| `total` | `number` | Total de tickets que coinciden con los filtros |
| `totalPages` | `number` | Número total de páginas |

### Ejemplos

#### curl

```bash
curl -X GET 'https://os.utilia.ai/api/external/v1/tickets?userId=usr-4821&status=OPEN&page=1&limit=10' \
  -H 'X-Api-Key: utl_tu_clave_de_api' \
  -H 'x-request-id: req-listar-tickets-001'
```

#### fetch

```javascript
const params = new URLSearchParams({
  userId: 'usr-4821',
  status: 'OPEN',
  page: '1',
  limit: '10',
});

const response = await fetch(
  `https://os.utilia.ai/api/external/v1/tickets?${params}`,
  {
    headers: {
      'X-Api-Key': 'utl_tu_clave_de_api',
      'x-request-id': 'req-listar-tickets-001',
    },
  }
);

const { data: tickets, pagination } = await response.json();

console.log(`${pagination.total} tickets abiertos`);
for (const ticket of tickets) {
  const badge = ticket.unreadByExternal > 0 ? ' (nuevos mensajes)' : '';
  console.log(`[${ticket.ticketKey}] ${ticket.title}${badge}`);
}
```

#### axios

```javascript
import axios from 'axios';

const { data } = await axios.get(
  'https://os.utilia.ai/api/external/v1/tickets',
  {
    params: {
      userId: 'usr-4821',
      status: 'OPEN',
      page: 1,
      limit: 10,
    },
    headers: {
      'X-Api-Key': 'utl_tu_clave_de_api',
      'x-request-id': 'req-listar-tickets-001',
    },
  }
);

console.log(`${data.pagination.total} tickets abiertos`);
for (const ticket of data.data) {
  const badge = ticket.unreadByExternal > 0 ? ' (nuevos mensajes)' : '';
  console.log(`[${ticket.ticketKey}] ${ticket.title}${badge}`);
}
```

#### Python

```python
import requests

response = requests.get(
    "https://os.utilia.ai/api/external/v1/tickets",
    params={
        "userId": "usr-4821",
        "status": "OPEN",
        "page": 1,
        "limit": 10,
    },
    headers={
        "X-Api-Key": "utl_tu_clave_de_api",
        "x-request-id": "req-listar-tickets-001",
    },
    timeout=30,
)

result = response.json()
print(f"{result['pagination']['total']} tickets abiertos")

for ticket in result["data"]:
    badge = " (nuevos mensajes)" if ticket["unreadByExternal"] > 0 else ""
    print(f"[{ticket['ticketKey']}] {ticket['title']}{badge}")
```

## Ver un ticket

**`GET /api/external/v1/tickets/:id`**

Detalle completo de un ticket, con mensajes, adjuntos y contexto. Permiso: support:tickets:read.

Parámetro de ruta: `id`.

### Parámetros de ruta

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `id` | `string` | Sí | ID del ticket (UUID) |

### Parámetros de consulta (query)

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `userId` | `string` | Sí | Identificador externo del usuario. Se comprueba que el ticket sea suyo. |

### Respuesta correcta (200 OK)

| Nombre | Tipo | Descripción |
| - | - | - |
| `id` | `string` | ID del ticket (UUID) |
| `ticketKey` | `string` | Clave única legible (ejemplo: APP-0042) |
| `title` | `string` | Título del ticket |
| `description` | `string` | Descripción completa del problema |
| `category` | `string` | Categoría del ticket |
| `priority` | `string` | Prioridad del ticket |
| `status` | `string` | Estado actual del ticket |
| `createdAt` | `string` | Fecha de creación en formato ISO 8601 |
| `lastActivityAt` | `string` | Fecha de la última actividad |
| `messages` | `TicketMessage[]` | Lista de mensajes del ticket (ordenados cronológicamente) |
| `attachments` | `TicketAttachment[]` | Adjuntos del ticket original |
| `context` | `object \| null` | Contexto del entorno capturado al crear el ticket |
| `reporter` | `object \| null` | Datos del usuario que creó el ticket |

#### Estructura de cada mensaje (`TicketMessage`)

| Nombre | Tipo | Descripción |
| - | - | - |
| `id` | `string` | ID del mensaje (UUID) |
| `content` | `string` | Contenido del mensaje |
| `author` | `MessageAuthor` | Autor del mensaje |
| `attachments` | `TicketAttachment[]` | Archivos adjuntos del mensaje |
| `createdAt` | `string` | Fecha de creación del mensaje |

#### Estructura de `author` (MessageAuthor)

| Nombre | Tipo | Descripción |
| - | - | - |
| `type` | `string` | Tipo de autor: EXTERNAL (usuario) o INTERNAL (agente de soporte) |
| `id` | `string` | ID del autor |
| `name` | `string` | Nombre visible del autor |

#### Estructura de cada adjunto (`TicketAttachment`)

| Nombre | Tipo | Descripción |
| - | - | - |
| `id` | `string` | ID del archivo |
| `filename` | `string` | Nombre del archivo |
| `mimeType` | `string` | Tipo MIME del archivo (ejemplo: image/png) |
| `size` | `number` | Tamaño del archivo en bytes |

### Errores

| Código HTTP | Código de error | Descripción |
| - | - | - |
| 404 | `TICKET_NOT_FOUND` | El ticket no existe o es de otra aplicación |
| 403 | `TICKET_ACCESS_DENIED` | El ticket no es de ese usuario |

### Ejemplos

#### curl

```bash
curl -X GET 'https://os.utilia.ai/api/external/v1/tickets/a1b2c3d4-e5f6-7890-abcd-ef1234567890?userId=usr-4821' \
  -H 'X-Api-Key: utl_tu_clave_de_api' \
  -H 'x-request-id: req-detalle-ticket-001'
```

#### fetch

```javascript
const ticketId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';

const response = await fetch(
  `https://os.utilia.ai/api/external/v1/tickets/${ticketId}?userId=usr-4821`,
  {
    headers: {
      'X-Api-Key': 'utl_tu_clave_de_api',
      'x-request-id': 'req-detalle-ticket-001',
    },
  }
);

const ticket = await response.json();

console.log(`[${ticket.ticketKey}] ${ticket.title}`);
console.log(`Estado: ${ticket.status}`);
console.log(`Mensajes: ${ticket.messages.length}`);

for (const msg of ticket.messages) {
  const origen = msg.author.type === 'EXTERNAL' ? 'Usuario' : 'Soporte';
  console.log(`[${origen}] ${msg.author.name}: ${msg.content}`);
}
```

#### axios

```javascript
import axios from 'axios';

const ticketId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';

const { data: ticket } = await axios.get(
  `https://os.utilia.ai/api/external/v1/tickets/${ticketId}`,
  {
    params: { userId: 'usr-4821' },
    headers: {
      'X-Api-Key': 'utl_tu_clave_de_api',
      'x-request-id': 'req-detalle-ticket-001',
    },
  }
);

console.log(`[${ticket.ticketKey}] ${ticket.title}`);
console.log(`Estado: ${ticket.status}`);

for (const msg of ticket.messages) {
  const origen = msg.author.type === 'EXTERNAL' ? 'Usuario' : 'Soporte';
  console.log(`[${origen}] ${msg.author.name}: ${msg.content}`);
}
```

#### Python

```python
import requests

ticket_id = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"

response = requests.get(
    f"https://os.utilia.ai/api/external/v1/tickets/{ticket_id}",
    params={"userId": "usr-4821"},
    headers={
        "X-Api-Key": "utl_tu_clave_de_api",
        "x-request-id": "req-detalle-ticket-001",
    },
    timeout=30,
)

ticket = response.json()
print(f"[{ticket['ticketKey']}] {ticket['title']}")
print(f"Estado: {ticket['status']}")
print(f"Mensajes: {len(ticket['messages'])}")

for msg in ticket["messages"]:
    origen = "Usuario" if msg["author"]["type"] == "EXTERNAL" else "Soporte"
    print(f"[{origen}] {msg['author']['name']}: {msg['content']}")
```

## Mensajes sin leer

**`GET /api/external/v1/tickets/unread-count`**

Número total de mensajes sin leer del usuario en todos sus tickets. Permiso: support:tickets:read.

### Parámetros de consulta (query)

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `userId` | `string` | Sí | ID externo del usuario |

### Respuesta correcta (200 OK)

| Nombre | Tipo | Descripción |
| - | - | - |
| `unreadCount` | `number` | Cantidad total de mensajes sin leer del usuario en todos sus tickets |

### Ejemplos

#### curl

```bash
curl -X GET 'https://os.utilia.ai/api/external/v1/tickets/unread-count?userId=usr-4821' \
  -H 'X-Api-Key: utl_tu_clave_de_api' \
  -H 'x-request-id: req-unread-001'
```

#### fetch

```javascript
const response = await fetch(
  'https://os.utilia.ai/api/external/v1/tickets/unread-count?userId=usr-4821',
  {
    headers: {
      'X-Api-Key': 'utl_tu_clave_de_api',
      'x-request-id': 'req-unread-001',
    },
  }
);

const { unreadCount } = await response.json();

if (unreadCount > 0) {
  console.log(`Tienes ${unreadCount} mensajes sin leer`);
}
```

#### axios

```javascript
import axios from 'axios';

const { data } = await axios.get(
  'https://os.utilia.ai/api/external/v1/tickets/unread-count',
  {
    params: { userId: 'usr-4821' },
    headers: {
      'X-Api-Key': 'utl_tu_clave_de_api',
      'x-request-id': 'req-unread-001',
    },
  }
);

if (data.unreadCount > 0) {
  console.log(`Tienes ${data.unreadCount} mensajes sin leer`);
}
```

#### Python

```python
import requests

response = requests.get(
    "https://os.utilia.ai/api/external/v1/tickets/unread-count",
    params={"userId": "usr-4821"},
    headers={
        "X-Api-Key": "utl_tu_clave_de_api",
        "x-request-id": "req-unread-001",
    },
    timeout=30,
)

result = response.json()
if result["unreadCount"] > 0:
    print(f"Tienes {result['unreadCount']} mensajes sin leer")
```

Úsalo para mostrar en tu interfaz un indicador con el número de mensajes pendientes. Consúltalo al recibir un evento `ticket-updated` en lugar de preguntar cada poco.

## Añadir un mensaje

**`POST /api/external/v1/tickets/:id/messages`**

Añade un mensaje a un ticket existente. Permiso: support:tickets:write.

Parámetro de ruta: `id`.

### Parámetros de ruta

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `id` | `string` | Sí | ID del ticket (UUID) |

### Parámetros de consulta (query)

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `userId` | `string` | Sí | Identificador externo del usuario. Se comprueba que el ticket sea suyo. |

### Cuerpo de la petición

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `content` | `string` | Sí | Contenido del mensaje (entre 1 y 5.000 caracteres) |
| `attachmentIds` | `string[]` | No | Identificadores de archivos adjuntos ya subidos |

### Respuesta correcta (201 Created)

Devuelve el ticket actualizado, con el mensaje nuevo dentro de `messages`.

### Errores

| Código HTTP | Código de error | Descripción |
| - | - | - |
| 404 | `TICKET_NOT_FOUND` | El ticket no existe o es de otra aplicación |
| 403 | `TICKET_ACCESS_DENIED` | El ticket no es de ese usuario |
| 403 | `TICKET_CLOSED` | No se pueden añadir mensajes a un ticket cerrado |

### Ejemplos

#### curl

```bash
curl -X POST 'https://os.utilia.ai/api/external/v1/tickets/a1b2c3d4-e5f6-7890-abcd-ef1234567890/messages?userId=usr-4821' \
  -H 'Content-Type: application/json' \
  -H 'X-Api-Key: utl_tu_clave_de_api' \
  -H 'x-request-id: req-mensaje-001' \
  -d '{
    "content": "He probado a borrar la cache del navegador y el problema persiste. Adjunto una nueva captura de pantalla.",
    "attachmentIds": ["f8e7d6c5-b4a3-2190-fedc-ba9876543210"]
  }'
```

#### fetch

```javascript
const ticketId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';

const response = await fetch(
  `https://os.utilia.ai/api/external/v1/tickets/${ticketId}/messages?userId=usr-4821`,
  {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Api-Key': 'utl_tu_clave_de_api',
      'x-request-id': 'req-mensaje-001',
    },
    body: JSON.stringify({
      content:
        'He probado a borrar la cache del navegador y el problema persiste. Adjunto una nueva captura de pantalla.',
      attachmentIds: ['f8e7d6c5-b4a3-2190-fedc-ba9876543210'],
    }),
  }
);

const updatedTicket = await response.json();
console.log(`Ticket actualizado con ${updatedTicket.messages.length} mensajes`);
```

#### axios

```javascript
import axios from 'axios';

const ticketId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';

const { data: updatedTicket } = await axios.post(
  `https://os.utilia.ai/api/external/v1/tickets/${ticketId}/messages`,
  {
    content:
      'He probado a borrar la cache del navegador y el problema persiste. Adjunto una nueva captura de pantalla.',
    attachmentIds: ['f8e7d6c5-b4a3-2190-fedc-ba9876543210'],
  },
  {
    params: { userId: 'usr-4821' },
    headers: {
      'Content-Type': 'application/json',
      'X-Api-Key': 'utl_tu_clave_de_api',
      'x-request-id': 'req-mensaje-001',
    },
  }
);

console.log(`Ticket actualizado con ${updatedTicket.messages.length} mensajes`);
```

#### Python

```python
import requests

ticket_id = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"

response = requests.post(
    f"https://os.utilia.ai/api/external/v1/tickets/{ticket_id}/messages",
    params={"userId": "usr-4821"},
    headers={
        "Content-Type": "application/json",
        "X-Api-Key": "utl_tu_clave_de_api",
        "x-request-id": "req-mensaje-001",
    },
    json={
        "content": "He probado a borrar la cache del navegador y el problema persiste. Adjunto una nueva captura de pantalla.",
        "attachmentIds": ["f8e7d6c5-b4a3-2190-fedc-ba9876543210"],
    },
    timeout=30,
)

updated_ticket = response.json()
print(f"Ticket actualizado con {len(updated_ticket['messages'])} mensajes")
```

## Cerrar un ticket

**`PATCH /api/external/v1/tickets/:id/close`**

Cierra un ticket. Permiso: support:tickets:write.

Parámetro de ruta: `id`.

### Parámetros de ruta

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `id` | `string` | Sí | ID del ticket (UUID) |

### Parámetros de consulta (query)

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `userId` | `string` | Sí | Identificador externo del usuario. Se comprueba que el ticket sea suyo. |

### Respuesta correcta (200 OK)

Devuelve el ticket actualizado con `status` en `CLOSED`. Cerrar un ticket que ya estaba cerrado no da error: vuelve a quedar cerrado.

### Errores

| Código HTTP | Código de error | Descripción |
| - | - | - |
| 404 | `TICKET_NOT_FOUND` | El ticket no existe o es de otra aplicación |
| 403 | `TICKET_ACCESS_DENIED` | El ticket no es de ese usuario |

### Ejemplos

#### curl

```bash
curl -X PATCH 'https://os.utilia.ai/api/external/v1/tickets/a1b2c3d4-e5f6-7890-abcd-ef1234567890/close?userId=usr-4821' \
  -H 'X-Api-Key: utl_tu_clave_de_api' \
  -H 'x-request-id: req-cerrar-ticket-001'
```

#### fetch

```javascript
const ticketId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';

const response = await fetch(
  `https://os.utilia.ai/api/external/v1/tickets/${ticketId}/close?userId=usr-4821`,
  {
    method: 'PATCH',
    headers: {
      'X-Api-Key': 'utl_tu_clave_de_api',
      'x-request-id': 'req-cerrar-ticket-001',
    },
  }
);

const closedTicket = await response.json();
console.log(`Ticket ${closedTicket.ticketKey} cerrado`);
```

#### axios

```javascript
import axios from 'axios';

const ticketId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';

const { data: closedTicket } = await axios.patch(
  `https://os.utilia.ai/api/external/v1/tickets/${ticketId}/close`,
  null,
  {
    params: { userId: 'usr-4821' },
    headers: {
      'X-Api-Key': 'utl_tu_clave_de_api',
      'x-request-id': 'req-cerrar-ticket-001',
    },
  }
);

console.log(`Ticket ${closedTicket.ticketKey} cerrado`);
```

#### Python

```python
import requests

ticket_id = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"

response = requests.patch(
    f"https://os.utilia.ai/api/external/v1/tickets/{ticket_id}/close",
    params={"userId": "usr-4821"},
    headers={
        "X-Api-Key": "utl_tu_clave_de_api",
        "x-request-id": "req-cerrar-ticket-001",
    },
    timeout=30,
)

closed_ticket = response.json()
print(f"Ticket {closed_ticket['ticketKey']} cerrado")
```

## Reabrir un ticket

**`PATCH /api/external/v1/tickets/:id/reopen`**

Reabre un ticket cerrado o resuelto. Permiso: support:tickets:write.

Parámetro de ruta: `id`.

### Parámetros de ruta

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `id` | `string` | Sí | ID del ticket (UUID) |

### Parámetros de consulta (query)

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `userId` | `string` | Sí | Identificador externo del usuario. Se comprueba que el ticket sea suyo. |

### Respuesta correcta (200 OK)

Devuelve el ticket actualizado con `status` en `OPEN`.

### Errores

| Código HTTP | Código de error | Descripción |
| - | - | - |
| 404 | `TICKET_CANNOT_REOPEN` | El ticket no existe o no está en `CLOSED` ni en `RESOLVED` |
| 403 | `TICKET_ACCESS_DENIED` | El ticket no es de ese usuario |

### Ejemplos

#### curl

```bash
curl -X PATCH 'https://os.utilia.ai/api/external/v1/tickets/a1b2c3d4-e5f6-7890-abcd-ef1234567890/reopen?userId=usr-4821' \
  -H 'X-Api-Key: utl_tu_clave_de_api' \
  -H 'x-request-id: req-reabrir-ticket-001'
```

#### fetch

```javascript
const ticketId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';

const response = await fetch(
  `https://os.utilia.ai/api/external/v1/tickets/${ticketId}/reopen?userId=usr-4821`,
  {
    method: 'PATCH',
    headers: {
      'X-Api-Key': 'utl_tu_clave_de_api',
      'x-request-id': 'req-reabrir-ticket-001',
    },
  }
);

const reopenedTicket = await response.json();
console.log(`Ticket ${reopenedTicket.ticketKey} reabierto`);
```

#### axios

```javascript
import axios from 'axios';

const ticketId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';

const { data: reopenedTicket } = await axios.patch(
  `https://os.utilia.ai/api/external/v1/tickets/${ticketId}/reopen`,
  null,
  {
    params: { userId: 'usr-4821' },
    headers: {
      'X-Api-Key': 'utl_tu_clave_de_api',
      'x-request-id': 'req-reabrir-ticket-001',
    },
  }
);

console.log(`Ticket ${reopenedTicket.ticketKey} reabierto`);
```

#### Python

```python
import requests

ticket_id = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"

response = requests.patch(
    f"https://os.utilia.ai/api/external/v1/tickets/{ticket_id}/reopen",
    params={"userId": "usr-4821"},
    headers={
        "X-Api-Key": "utl_tu_clave_de_api",
        "x-request-id": "req-reabrir-ticket-001",
    },
    timeout=30,
)

reopened_ticket = response.json()
print(f"Ticket {reopened_ticket['ticketKey']} reabierto")
```

Solo se pueden reabrir tickets en `CLOSED` o `RESOLVED`. Con un ticket en `OPEN`, `IN_REVIEW` o `CONVERTED`, la respuesta es `404` con el código `TICKET_CANNOT_REOPEN`.

## Cambios en tiempo real (SSE)

**`GET /api/external/v1/tickets/events`**

Conexión SSE (Server-Sent Events) para recibir en tiempo real los cambios de los tickets. Permiso: support:tickets:read.

Este endpoint abre una conexión persistente con Server-Sent Events (SSE). El servidor envía un evento cada vez que cambia algo relevante en los tickets de tu aplicación. Además de la cabecera `X-Api-Key`, admite la clave en el parámetro `?apiKey=` para que funcione con `EventSource`, que no permite cabeceras propias.

### Parámetros de consulta (query)

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `userId` | `string` | No | Identificador externo del usuario. Si lo indicas, solo llegan eventos de sus tickets. |
| `streamToken` | `string` | No | Pase de POST /stream-token, en lugar de la clave. Si el pase se pidió con userId, la conexión tiene que llevar el mismo. |

### Pase de conexión, sin la clave en la dirección

Una clave en la dirección queda escrita en registros, servidores intermedios e historiales. Para no ponerla, pide antes un pase con la cabecera `X-Api-Key` y abre la conexión con `?streamToken=`:

**`POST /api/external/v1/tickets/stream-token`**

Devuelve un pase para abrir la conexión en tiempo real. Solo con la cabecera X-Api-Key. Permiso: support:tickets:read (ticket-events) o support:tickets:ai (ai-suggest y ai-transcribe).

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `route` | `'ticket-events' \| 'ai-suggest' \| 'ai-transcribe'` | Sí | Conexión para la que se pide: GET /tickets/events, /tickets/ai-suggest/:jobId/stream o /tickets/ai-transcribe/:jobId/stream. |
| `jobId` | `string` | No | Trabajo de IA al que se ata el pase. Obligatorio en ai-suggest y ai-transcribe. |
| `userId` | `string` | No | Identificador externo del usuario. Obligatorio en ticket-events para las aplicaciones con dominios autorizados (widget); la conexión tiene que usar exactamente el mismo. |

La respuesta (`201`) trae `streamToken`, `route`, `expiresAt`, `expiresIn` (300 segundos) y `keyVersion`, la generación de la clave con la que se emitió. Cada pase sirve para abrir una sola conexión, en los cinco minutos siguientes. La conexión abierta sigue valiendo mientras la clave con la que se pidió siga valiendo: si se renueva con periodo de transición, hasta que termine ese periodo o se retire la anterior. Al reconectar, pide uno nuevo.

| Código de error | HTTP | Cuándo |
| - | - | - |
| `STREAM_TOKEN_INVALID` | 401 | El pase no es válido, ya se usó o ha caducado. Pide uno nuevo. |
| `STREAM_TOKEN_USER_REQUIRED` | 400 | Aplicación con widget que pide el pase de `ticket-events` sin `userId`. |
| `STREAM_TOKEN_UNAVAILABLE` | 503 | No se ha podido emitir el pase en ese momento. Inténtalo en unos segundos. |

### Tipos de eventos

| Evento | Descripción | Datos |
| - | - | - |
| `connected` | Conexión establecida | `{ appId: string }` |
| `ticket-updated` | Un ticket ha cambiado (mensaje nuevo, cambio de estado…) | `{ ticketId, ticketKey, type, externalUserId }` |
| `heartbeat` | Señal para mantener viva la conexión | Cada 30 segundos |

### Ejemplos

#### curl

```bash
curl -N 'https://os.utilia.ai/api/external/v1/tickets/events?userId=usr-4821' \
  -H 'X-Api-Key: utl_tu_clave_de_api' \
  -H 'x-request-id: req-sse-001'
```

#### fetch

```javascript
// El servidor emite TODOS los eventos como `message` (sin línea `event:`),
// con el tipo lógico dentro del JSON, en el campo `event`. Por tanto:
// - NO uses `es.addEventListener('ticket-updated', ...)` ni similares.
// - Usa `es.onmessage` (o un polyfill compatible con headers) y lee el
//   campo `event` del JSON.
//
// Autenticación: lo habitual es la cabecera X-Api-Key. Con EventSource, que
// no admite cabeceras, abre la conexión con un pase (`?streamToken=`, ver
// «Pase de conexión»), que es lo que hace el SDK oficial de JavaScript desde
// la 4.2.0. La clave como `?apiKey=` se admite de momento, pero queda en
// registros, servidores intermedios e historial: no la uses.

import { EventSourcePolyfill } from 'event-source-polyfill';

const url = new URL('https://os.utilia.ai/api/external/v1/tickets/events');
url.searchParams.set('userId', 'usr-4821');

const es = new EventSourcePolyfill(url.toString(), {
  headers: { 'X-Api-Key': 'utl_tu_clave_de_api' },
});

es.onmessage = (msg) => {
  const data = JSON.parse(msg.data);
  switch (data.event) {
    case 'connected':
      console.log(`Conectado a la app: ${data.appId}`);
      break;
    case 'ticket-updated':
      console.log(`Ticket ${data.ticketKey} actualizado (tipo: ${data.type})`);
      break;
    case 'heartbeat':
      // Conexión viva, sin acción.
      break;
  }
};

es.onerror = (error) => {
  console.error('Error en la conexión SSE:', error);
};
```

#### axios

```javascript
// SSE no se consume con axios. Use EventSource o fetch con ReadableStream.
// Este ejemplo usa fetch con streaming para entornos Node.js:

const response = await fetch(
  'https://os.utilia.ai/api/external/v1/tickets/events?userId=usr-4821',
  {
    headers: {
      'X-Api-Key': 'utl_tu_clave_de_api',
      'x-request-id': 'req-sse-001',
    },
  }
);

const reader = response.body.getReader();
const decoder = new TextDecoder();

while (true) {
  const { value, done } = await reader.read();
  if (done) break;

  const text = decoder.decode(value);
  const lines = text.split('\n').filter(Boolean);

  for (const line of lines) {
    if (line.startsWith('data: ')) {
      const data = JSON.parse(line.slice(6));
      console.log('Evento recibido:', data);
    }
  }
}
```

#### Python

```python
# El servidor emite TODOS los eventos como `message`, no envía línea
# `event:` separada. NO uses `sseclient` con `event.event == "..."`:
# nunca se cumpliría. Lee el campo `event` del JSON.

import json
import requests

response = requests.get(
    "https://os.utilia.ai/api/external/v1/tickets/events",
    params={"userId": "usr-4821"},
    headers={
        "X-Api-Key": "utl_tu_clave_de_api",
        "Accept": "text/event-stream",
        "x-request-id": "req-sse-001",
    },
    stream=True,
)

buffer = ""
for chunk in response.iter_content(chunk_size=None, decode_unicode=True):
    if not chunk:
        continue
    buffer += chunk
    while "\n\n" in buffer:
        bloque, buffer = buffer.split("\n\n", 1)
        data_lines = [
            ln[5:].lstrip() for ln in bloque.split("\n") if ln.startswith("data:")
        ]
        if not data_lines:
            continue
        data = json.loads("\n".join(data_lines))
        if data["event"] == "connected":
            print(f"Conectado a la app: {data.get('appId')}")
        elif data["event"] == "ticket-updated":
            print(
                f"Ticket {data['ticketKey']} actualizado (tipo: {data['type']})"
            )
        elif data["event"] == "heartbeat":
            pass  # Conexión viva, sin acción
```

La conexión queda abierta sin límite de tiempo. Si se corta (por ejemplo, por un fallo de red), el cliente tiene que volver a conectarse; casi todas las implementaciones de `EventSource` lo hacen solas. En Python puedes usar la librería `sseclient-py` (`pip install sseclient-py`), y en Node.js, `eventsource` (`npm install eventsource`) si necesitas enviar cabeceras.

## Estados del ticket

**Estados de un ticket de soporte**

1. **Abierto** (`OPEN`). Al crearlo o al reabrirlo desde tu aplicación.
2. **En revisión** (`IN_REVIEW`). Se llega desde Abierto cuando: Tu equipo empieza a revisarlo. Lo cambia tu equipo desde UTILIA OS.
   - **Convertido** (`CONVERTED`). Se llega desde En revisión cuando: Tu equipo lo convierte en un elemento de trabajo. Solo puede pasar a Resuelto o a Cerrado.
3. **Resuelto** (`RESOLVED`). Se llega desde En revisión cuando: Tu equipo lo resuelve. Se puede reabrir.
4. **Cerrado** (`CLOSED`, estado final). Se llega desde Resuelto cuando: El usuario lo cierra con close o lo cierra tu equipo. Se puede reabrir.

Otras transiciones:

- Convertido → Resuelto: Tu equipo lo da por resuelto.
- Resuelto → Abierto: Tu aplicación lo reabre con reopen.
- Cerrado → Abierto: Tu aplicación lo reabre con reopen.

_Los rótulos son los del panel de Tickets de soporte; el valor de status es el que devuelve la API._

Desde la API externa solo se puede cerrar (`close`, desde cualquier estado) y reabrir (`reopen`, desde `CLOSED` o `RESOLVED`, que vuelve a `OPEN`). El resto de cambios los hace tu equipo de soporte en UTILIA OS.

## Errores frecuentes

Todos los endpoints de tickets pueden devolver estos errores:

| Código de error | HTTP | Descripción |
| - | - | - |
| `TICKET_NOT_FOUND` | 404 | El ticket no existe o es de otra aplicación |
| `TICKET_ACCESS_DENIED` | 403 | El ticket no es del usuario indicado |
| `TICKET_CLOSED` | 403 | La operación no se permite en un ticket cerrado (por ejemplo, añadir mensajes) |
| `TICKET_CANNOT_REOPEN` | 404 | El ticket no existe o no está cerrado ni resuelto, así que no se puede reabrir |
| `VALIDATION_ERROR` | 400 | El cuerpo contiene datos no válidos (campos que faltan, valores fuera de rango…) |
| `MODULE_NOT_ENABLED` / `CAPABILITY_NOT_ENABLED` | 403 | El plan de tu organización no incluye Tickets de soporte o los tickets propios |
| -- | 401 | Falta la clave de API en `X-Api-Key`, no es válida o ha caducado |

### Formato de las respuestas de error

Todas las respuestas de error tienen la misma forma:

```json
{
  "statusCode": 404,
  "error": "TICKET_NOT_FOUND",
  "message": "El ticket solicitado no existe"
}
```

Incluye siempre la cabecera `x-request-id` en tus peticiones: si tienes que escribir a soporte por un error inesperado, ese identificador permite localizar la petición exacta en los registros.

## Siguiente paso

Los usuarios pueden adjuntar archivos a sus tickets con los
[endpoints de archivos](https://os.utilia.ai/docs/developers/api/files), y tu
aplicación puede proponer respuestas con las
[sugerencias con IA](https://os.utilia.ai/docs/developers/api/ticket-ai-suggestions).
Si prefieres no construir la interfaz, el
[widget de soporte](https://os.utilia.ai/docs/developers/widget/overview) ya la trae hecha.
