# Referencia de las instalaciones

Las tres rutas de /external/v1/app-installations: canjear el código de una instalación, listar las instalaciones de tu aplicación y consultar una, con la suscripción de las de pago.

Tres rutas de la API externa para el servidor de la aplicación que publica una ficha en el catálogo: canjear el código de cada instalación y consultar qué empresas la tienen instalada. Todas se llaman con la clave de **tu** aplicación en `X-Api-Key`, no con la de una instalación, y solo ven las instalaciones de tu propia ficha.

## Requisitos

- **La clave de la aplicación que publica la ficha**, en `X-Api-Key`.
- **Dos permisos de la API** de esa aplicación, que nunca se piden a una empresa: «Recibir las credenciales de cada instalación» (`apps:installations:exchange`) para el canje y «Ver las instalaciones de esta aplicación» (`apps:installations:read`) para las consultas.

Ninguna de las tres devuelve personas de la empresa que instala: solo la empresa, el estado, los permisos y, si tu aplicación es de pago, su suscripción.

## Canjear el código

**`POST /api/external/v1/app-installations/exchange`**

Canjea el código de una instalación por su clave de API y su clave de firma, que se entregan una sola vez. Solo desde un servidor. Permiso: apps:installations:exchange.

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `code` | `string (10 a 200)` | Sí | El código que llega en la dirección de instalación (?code=). Vale 30 minutos y una sola vez. |

Responde `200` con `installation` y `credentials` y la cabecera `Cache-Control: no-store`. La forma completa y cómo guardarlas están en [Canjear el código](https://os.utilia.ai/docs/developers/apps-platform/installation-and-exchange#canjear-el-código).

| errorCode | HTTP | Cuándo |
| - | - | - |
| `EXCHANGE_FROM_BROWSER` | 403 | La petición trae `Origin` o `Sec-Fetch-Site`: llega desde un navegador. |
| `INSTALLATION_CODE_WRONG_APP` | 403 | El código es de una ficha de otra aplicación de tu organización. |
| `INSTALLATION_CODE_INVALID` | 404 | El código no existe para tu aplicación, o la instalación está desinstalada o suspendida. |
| `INSTALLATION_CODE_USED` | 409 | Ya se canjeó. |
| `INSTALLATION_CODE_EXPIRED` | 410 | Pasaron los 30 minutos. |

## Listar tus instalaciones

**`GET /api/external/v1/app-installations`**

Empresas que han instalado la ficha de tu aplicación, de la más reciente a la más antigua, con su estado y, si es de pago, su suscripción. Permiso: apps:installations:read.

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `status` | `query` | No | Solo las de un estado guardado: PENDING_CONNECTION (sin canjear, vigente o caducado), ACTIVE (también las que piden más permisos), SUSPENDED o UNINSTALLED. |
| `subscriptionStatus` | `query` | No | Solo las de una suscripción en este estado: TRIALING, ACTIVE, PAST_DUE, PAUSED o ENDED. En una ficha que no es de pago no devuelve ninguna. Se combina con status. |
| `limit` | `query (1 a 100)` | No | Instalaciones por página. Por defecto, 50. |
| `cursor` | `query` | No | El nextCursor de la página anterior. |

```json
{
  "items": [
    {
      "id": "cm3k9x2a40001qz8h7d1v2b3c",
      "organizationId": "cm1a2b3c40000ab12cd34ef56",
      "organizationName": "Ferretería López",
      "status": "ACTIVE",
      "installedVia": "CATALOG",
      "installedAt": "2026-11-03T09:14:27.000Z",
      "connectedAt": "2026-11-03T09:15:02.000Z",
      "uninstalledAt": null,
      "uninstallReason": null,
      "grantedScopes": ["crm:clients:read", "crm:contacts:read"],
      "pendingScopes": [],
      "subscription": null
    }
  ],
  "nextCursor": null
}
```

## Consultar una instalación

**`GET /api/external/v1/app-installations/:id`**

Una instalación de la ficha de tu aplicación, con la misma forma que cada elemento de la lista, también su suscripción si es de pago. Permiso: apps:installations:read.

Parámetro de ruta: `id`.

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `id` | `path` | Sí | El identificador de la instalación: el installation_id de la dirección de instalación y de la cabecera X-Utilia-Installation-Id. |

Una instalación que no es de tu ficha, o que no existe, responde `404 APP_INSTALLATION_NOT_FOUND`: no se distingue para no revelar instalaciones ajenas.

## Los campos de una instalación

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `id` | `string` | Sí | La instalación. |
| `organizationId` | `string` | Sí | La empresa que instaló. |
| `organizationName` | `string \| null` | Sí | Su nombre al instalar. |
| `status` | `enum` | Sí | ACTIVE, CONNECTING (código vigente sin canjear), NEEDS_CONNECTION (el código caducó), ACCESS_PENDING (no ha aceptado los permisos nuevos), SUSPENDED (por UTILIA) o UNINSTALLED. |
| `installedVia` | `enum` | Sí | CATALOG (desde Explorar), LINK (ficha «Solo con el enlace») o TEST (una instalación de prueba). |
| `installedAt` | `string` | Sí | Cuándo se instaló, en ISO 8601. |
| `connectedAt` | `string \| null` | Sí | Cuándo canjeó tu servidor el primer código. null si nunca. |
| `uninstalledAt` | `string \| null` | Sí | Cuándo se desinstaló. |
| `uninstallReason` | `string \| null` | Sí | El motivo que dio la empresa al desinstalar, si quiso contártelo. |
| `grantedScopes` | `string[]` | Sí | Permisos de la API concedidos: los únicos con los que puede llamar su clave. |
| `pendingScopes` | `string[]` | Sí | Permisos de una versión nueva que la empresa aún no ha aceptado. |
| `subscription` | `object \| null` | Sí | La suscripción de la empresa si tu ficha es «De pago en UTILIA» (la última, aunque haya terminado). null en las gratuitas y en las de «Gratis con cuenta en el servicio». Ver el apartado siguiente. |

El filtro `status` usa el estado guardado y la respuesta enseña el que ves en la pantalla: con `status=PENDING_CONNECTION` llegan instalaciones `CONNECTING` y `NEEDS_CONNECTION`, y con `status=ACTIVE`, también las `ACCESS_PENDING`. Qué significa cada estado y cómo se pasa de uno a otro está en [Estados de una instalación](https://os.utilia.ai/docs/developers/apps-platform/installation-and-exchange#estados-de-una-instalación).

La lista no avisa de que una empresa se quedó sin persona responsable o sin un aplicativo que tu aplicación necesita: la instalación sigue `ACTIVE` y son sus llamadas las que responden `403 APP_RESPONSIBLE_INACTIVE` o `403 MODULE_NOT_ENABLED`.

## La suscripción de una aplicación de pago

Si tu ficha es «De pago en UTILIA», cada instalación trae `subscription`: lo que la empresa contrató y cómo va su pago, para que decidas en tu aplicación si le das acceso. Sin impuestos, tarjeta, facturas, datos fiscales de la empresa ni personas. Una compra que no llega a cobrarse no aparece.

```json
{
  "subscription": {
    "id": "cm4a1b2c30000xy98zt76uv54",
    "status": "ACTIVE",
    "accessAllowed": true,
    "plan": { "key": "pro", "name": "Pro" },
    "interval": "MONTHLY",
    "priceCents": 1900,
    "currency": "EUR",
    "trialEndsAt": "2026-11-05T10:12:00.000Z",
    "currentPeriodStart": "2026-11-05T10:12:00.000Z",
    "currentPeriodEnd": "2026-12-05T10:12:00.000Z",
    "cancelAtPeriodEnd": false,
    "pendingChange": { "planKey": "basic", "interval": "MONTHLY", "effectiveAt": "2026-12-05T10:12:00.000Z" },
    "pauseReason": null,
    "endedAt": null,
    "endedReason": null,
    "simulated": false
  }
}
```

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `id` | `string` | Sí | La suscripción. Volver a contratar después de terminar crea otra. |
| `status` | `enum` | Sí | TRIALING (prueba gratis), ACTIVE (pagada y al corriente), PAST_DUE (falló un cobro y sigue funcionando mientras dura el plazo), PAUSED (en pausa: tu aplicación se detiene, sin acceso y sin cobros) o ENDED (terminada). |
| `accessAllowed` | `boolean` | Sí | La suscripción deja usar la aplicación: true en TRIALING, ACTIVE y PAST_DUE. No mira el estado de la instalación: una suspendida o desinstalada no se usa aunque esté pagada. |
| `plan` | `object` | Sí | key y name del plan de tu ficha que contrató. |
| `interval` | `enum` | Sí | MONTHLY o YEARLY. |
| `priceCents` | `number` | Sí | Precio del periodo, sin impuestos, en céntimos. |
| `currency` | `string` | Sí | Moneda, hoy siempre EUR. |
| `trialEndsAt` | `string \| null` | Sí | Fin de la prueba gratis. |
| `currentPeriodStart` | `string \| null` | Sí | Inicio del periodo pagado en curso. |
| `currentPeriodEnd` | `string \| null` | Sí | Fin del periodo pagado en curso. |
| `cancelAtPeriodEnd` | `boolean` | Sí | La empresa la ha cancelado: sigue hasta el final del periodo o de la prueba y no se renueva. |
| `pendingChange` | `object \| null` | Sí | Bajada de plan o cambio de periodicidad para la próxima renovación: planKey, interval y effectiveAt. |
| `pauseReason` | `enum \| null` | Sí | Solo en PAUSED: PAYMENT_FAILED, PLAN_REQUIREMENT_MISSING, LISTING_SUSPENDED, ORGANIZATION_SUSPENDED o DISPUTE_OPEN (la empresa ha abierto una disputa con su banco por un cargo de esta suscripción: no se renueva mientras dure la disputa, y esta pausa no tiene fecha de fin). |
| `endedAt` | `string \| null` | Sí | Solo en ENDED: cuándo terminó. |
| `endedReason` | `enum \| null` | Sí | Solo en ENDED: CANCELED, TRIAL_CANCELED, UNINSTALLED, UNPAID, PAUSE_EXPIRED, LISTING_DISCONTINUED, LISTING_REMOVED, DEVELOPER_CLOSED, ORGANIZATION_CANCELED o REFUNDED. |
| `simulated` | `boolean` | Sí | Compra simulada en una de tus empresas de prueba: no se cobra nada. |

Para saber si una empresa puede usar tu aplicación, mira las dos cosas: que la instalación no esté `SUSPENDED` ni `UNINSTALLED` y que `subscription` sea `null` o tenga `accessAllowed: true`. Los SDK lo hacen con `isAccessAllowed(instalacion)`. Los cambios te llegan en los [avisos de la suscripción](https://os.utilia.ai/docs/developers/apps-platform/webhooks#los-avisos-de-la-suscripción): no hace falta preguntar cada vez.

Comprar, cambiar de plan, cancelar o pagar no se hace por esta API: lo hace la empresa en UTILIA OS.

## Con los SDK

Los métodos llegan con la versión 4.7.0 de los dos SDK, y la suscripción, el filtro `subscriptionStatus` e `isAccessAllowed` con la 4.8.0, las dos todavía sin publicar. Mientras tanto, las rutas funcionan igual con las peticiones de arriba.

### JavaScript

```typescript
import { UtiliaSDK } from '@utilia-os/sdk-js';

const sdk = new UtiliaSDK({
  baseURL: 'https://os.utilia.ai/api',
  apiKey: process.env.UTILIA_APP_KEY!, // la clave de la aplicación que publica la ficha
});

const { installation, credentials } = await sdk.appInstallations.exchange(code);
const pagina = await sdk.appInstallations.list({ subscriptionStatus: 'PAST_DUE' });
const una = await sdk.appInstallations.get(installation.id);
const puedeUsarla = isAccessAllowed(una); // import { isAccessAllowed } from '@utilia-os/sdk-js';
```

### Python

```python
import os

from utilia_sdk import UtiliaSDK

async with UtiliaSDK(
    base_url="https://os.utilia.ai/api",
    api_key=os.environ["UTILIA_APP_KEY"],  # la clave de la aplicación que publica la ficha
) as sdk:
    canje = await sdk.app_installations.exchange(code)
    pagina = await sdk.app_installations.list(subscription_status="PAST_DUE")
    una = await sdk.app_installations.get(installation_id)
    puede_usarla = is_access_allowed(una)  # from utilia_sdk import is_access_allowed
```

Los errores llegan como `UtiliaSDKError`, con el `errorCode` de las tablas de arriba. Qué hacer con cada uno está en [Errores, identificador de petición y reintentos](https://os.utilia.ai/docs/developers/getting-started/errors-and-retries#instalaciones-del-catálogo).
