# Avisos de cada instalación

Cómo te llegan los avisos de cada empresa que instala tu aplicación: el sobre, la cabecera, la firma por instalación, los cinco avisos del ciclo de vida y los de la suscripción de una aplicación de pago.

Cada empresa que instala tu aplicación te manda sus avisos a la **dirección de avisos** de tu ficha, firmados con la clave de firma de **su** instalación. Así, con una sola dirección, sabes de qué empresa viene cada aviso y con qué clave comprobarlo antes de leer el cuerpo. Es la misma infraestructura que los [avisos a tu servidor](https://os.utilia.ai/docs/developers/webhooks/overview) de siempre: reintentos, historial de entregas e identificador de entrega.

## Qué avisos llegan

Llegan tres tipos de aviso, y solo si la ficha tiene dirección de avisos:

- **Los cinco del ciclo de vida de la instalación**, siempre, sin marcarlos: `APP_INSTALLED`, `APP_UNINSTALLED`, `APP_ACCESS_CHANGED`, `APP_SUSPENDED` y `APP_REINSTATED`.
- **Los doce de la suscripción**, si tu aplicación es de pago, también sin marcarlos: `APP_SUBSCRIPTION_*` ([más abajo](#los-avisos-de-la-suscripción)).
- **Los que declara tu ficha** en **Avisos que recibirá**, pero solo los que cubren los permisos que concedió esa empresa. Un aviso cuyo dato no podrías leer por la API con esos permisos no se envía.

| Avisos | Permiso de la instalación que los abre |
| - | - |
| Tickets de soporte (`TICKET_*`) | `support:tickets:read` |
| Usuarios externos (`USER_CREATED`) | `crm:users:read` |
| Archivos (`FILE_UPLOADED`) | `fm:files:read` |
| Contactos del CRM (`CONTACT_*`) | `crm:contacts:read` |
| Leads del CRM (`LEAD_*`) | `crm:leads:read` |
| Clientes del CRM (`CLIENT_*`) | `crm:clients:read` |
| Facturación (`INVOICE_*`) | `crm:invoices:read` |
| Comentarios de presupuesto (`BUDGET_COMMENT_ADDED`, `_UPDATED`, `_DELETED`) | `crm:budgets:comments:read` |
| Firma de presupuesto (`BUDGET_SIGNED`, `BUDGET_PARTIALLY_SIGNED`, `BUDGET_VIEWED` y los de su enlace de firma) | `crm:budgets:signatures:read` |
| Plataforma de chat (`CHAT_*`) | `chat:events:subscribe`; los de agentes, además `chat:agents:view` |

El resto de avisos de presupuestos, los de cobros, suscripciones, métodos de pago, autorizaciones de cobro, errores y salud de la aplicación no llegan nunca a una aplicación del catálogo. Además, como en cualquier aplicación, un aviso de un aplicativo que la empresa no tiene en su plan no sale. La lista que te toca en cada instalación es la de `installation.webhookEvents` del canje.

Los avisos de datos solo salen mientras la instalación funciona: no llegan si está sin conectar, suspendida o desinstalada.

## El sobre

Es el de siempre, con un campo más, `installation`, que solo llevan los avisos de una aplicación instalada desde el catálogo:

```json
{
  "event": "CLIENT_CREATED",
  "timestamp": "2026-11-03T10:02:11.000Z",
  "app": { "id": "cm3k9x2a50002qz8hm4n5p6q7", "slug": "acme-recordatorios-k7f2" },
  "installation": {
    "id": "cm3k9x2a40001qz8h7d1v2b3c",
    "organizationId": "cm1a2b3c40000ab12cd34ef56",
    "organizationName": "Ferretería López"
  },
  "data": { "...": "el contenido del evento, igual que en cualquier aplicación" },
  "meta": { "schemaVersion": "2026-05" }
}
```

`app` es la aplicación que UTILIA OS crea dentro de la empresa para esa instalación, no la tuya: para saber de quién es el aviso, usa `installation`. El contenido de `data` de cada evento es el del [catálogo de eventos](https://os.utilia.ai/docs/developers/webhooks/events).

Además de las cabeceras de siempre (`X-Utilia-Signature`, `X-Utilia-Timestamp`, `X-Utilia-Event`, `X-Utilia-Delivery-Id`...), cada entrega lleva:

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `X-Utilia-Installation-Id` | `string` | Sí | El id de la instalación, el mismo que installation.id. Úsalo para elegir la clave de firma antes de leer el cuerpo. Va también en los reintentos. |

## Comprobar la firma

La firma se calcula igual que en cualquier aviso, `HMAC-SHA256(claveDeFirma, timestamp + "." + cuerpo)`, pero con la clave de firma de **esa instalación**: la que recibiste en el canje. Con la clave de otra instalación o la de tu propia aplicación no coincide nunca.

**Comprobar un aviso de una instalación**

1. **Lee la instalación** (X-Utilia-Installation-Id): Antes de interpretar el cuerpo.
2. **Busca su clave de firma** (Tu base de datos): La que guardaste al canjear su código. Si no la tienes: responde 401 y revisa tus canjes.
3. **Comprueba la firma** (X-Utilia-Signature): Sobre el cuerpo crudo y la marca de tiempo, en tiempo constante. Si no coincide o es antigua: responde 401.
4. **Procesa el evento** (installation.organizationId): Con los datos de esa empresa y, si llamas a la API, con su clave.

_Responder 401 cierra la entrega sin reintentos._

```javascript
import crypto from 'node:crypto';
import express from 'express';

const app = express();

app.post('/utilia/avisos', express.raw({ type: 'application/json' }), async (req, res) => {
  const instalacionId = req.header('X-Utilia-Installation-Id');
  const firma = req.header('X-Utilia-Signature') ?? '';
  const marca = req.header('X-Utilia-Timestamp') ?? '';

  const instalacion = instalacionId ? await buscarInstalacion(instalacionId) : null; // tu base de datos
  if (!instalacion) return res.status(401).send('Instalación desconocida');

  if (!marca || Math.abs(Date.now() - Number(marca)) > 5 * 60 * 1000) {
    return res.status(401).send('Aviso fuera de la ventana de cinco minutos');
  }

  const esperada =
    'sha256=' +
    crypto
      .createHmac('sha256', instalacion.webhookSigningSecret)
      .update(`${marca}.${req.body.toString('utf8')}`)
      .digest('hex');
  const a = Buffer.from(firma);
  const b = Buffer.from(esperada);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(401).send('Firma no válida');

  const aviso = JSON.parse(req.body.toString('utf8'));
  if (aviso.installation?.id !== instalacionId) return res.status(401).send('Instalación cambiada');
  await procesar(instalacion, aviso); // descarta duplicados por X-Utilia-Delivery-Id
  return res.status(200).send('ok');
});
```

La cabecera `X-Utilia-Installation-Id` no va firmada: sirve para elegir la clave, pero, después de comprobar la firma, confirma que `installation.id` del cuerpo es la misma instalación. Si no coincide, descarta el aviso.

Con los SDK 4.7.0 (sin publicar todavía) lo hace `sdk.webhooks.verifyInstallation`: tú le das una función que devuelve la clave de firma de una instalación y él elige la clave, comprueba la firma y la ventana de tiempo y confirma que el sobre es de esa instalación.

### JavaScript

```typescript
const aviso = await sdk.webhooks.verifyInstallation(cuerpoCrudo, req.headers, async (id) =>
  (await buscarInstalacion(id))?.webhookSigningSecret ?? null,
);
if (aviso.event === 'APP_UNINSTALLED') await borrarInstalacion(aviso.installation.id);
```

### Python

```python
aviso = await sdk.webhooks.verify_installation_async(
    cuerpo_crudo,
    request.headers,
    lambda instalacion_id: buscar_clave_de_firma(instalacion_id),
)
```

Si falta la cabecera, si no tienes la clave de esa instalación o si el sobre es de otra, lanza `UtiliaSDKError` con `WEBHOOK_INSTALLATION_MISSING`, `WEBHOOK_INSTALLATION_UNKNOWN` o `WEBHOOK_INSTALLATION_MISMATCH`: en los tres casos, descarta el aviso. Todo lo demás de la verificación (la ventana de cinco minutos, los reintentos y el identificador de entrega) está en [Firma HMAC de los avisos](https://os.utilia.ai/docs/developers/webhooks/hmac-signature).

## Los cinco avisos del ciclo de vida

Llegan solos a la dirección de avisos de tu ficha, firmados con la clave de la instalación y con `installation` en el sobre. No se marcan ni se pueden quitar.

| Aviso | Cuándo llega | Qué trae `data` |
| - | - | - |
| `APP_INSTALLED` | Tu servidor acaba de canjear el primer código de la instalación. Al volver a conectar no se repite. | `organizationName`, `scopes` y `webhookEvents` |
| `APP_ACCESS_CHANGED` | La empresa acepta los permisos nuevos de una versión, o se aprueba una versión que pide menos y se quitan solos. | `scopes` (los vigentes), `addedScopes`, `removedScopes`, `webhookEvents` y `revisionId`. Cuando se quitan solos, `addedScopes` va vacío |
| `APP_SUSPENDED` | UTILIA suspende tu aplicación. Cuando llega, la suspensión ya se ha aplicado: la clave de la instalación responde `403 APP_DISABLED`. | `reason`, el motivo público |
| `APP_REINSTATED` | UTILIA levanta la suspensión y la instalación vuelve a funcionar con los permisos que tenía. | Nada |
| `APP_UNINSTALLED` | La empresa la desinstala. Sale antes de cortar el acceso, así que te llega aunque la clave ya no responda. | `reason`, el motivo que dio la empresa o `null` |

`APP_UNINSTALLED` y `APP_SUSPENDED` llegan aunque la empresa haya perdido el aplicativo Integraciones: anuncian un corte y no llevan datos de la empresa. Los demás, solo si lo tiene.

```json
{
  "event": "APP_UNINSTALLED",
  "timestamp": "2026-12-01T16:40:05.000Z",
  "app": { "id": "cm3k9x2a50002qz8hm4n5p6q7", "slug": "acme-recordatorios-k7f2" },
  "installation": {
    "id": "cm3k9x2a40001qz8h7d1v2b3c",
    "organizationId": "cm1a2b3c40000ab12cd34ef56",
    "organizationName": "Ferretería López"
  },
  "data": { "reason": "Ya no enviamos recordatorios por correo." },
  "meta": { "schemaVersion": "2026-05" }
}
```

Para saber los permisos vigentes después de un `APP_ACCESS_CHANGED`, lo más seguro es pedir la instalación con [`GET /external/v1/app-installations/:id`](https://os.utilia.ai/docs/developers/apps-platform/installations-api#consultar-una-instalación) y leer `grantedScopes`. Qué hacer con los datos de la empresa al recibir `APP_UNINSTALLED` está en [Desinstalación, suspensión y datos](https://os.utilia.ai/docs/developers/apps-platform/uninstall-and-data).

## Los avisos de la suscripción

Si tu aplicación es «De pago en UTILIA», cada cambio de la suscripción de una empresa te llega como un aviso más de su instalación: firmado con su clave, con `X-Utilia-Installation-Id` y con `installation` en el sobre. No se marcan ni se pueden quitar. Te sirven para dar o quitar acceso dentro de tu aplicación sin preguntar cada vez.

| Aviso | Cuándo llega |
| - | - |
| `APP_SUBSCRIPTION_STARTED` | La empresa contrata tu aplicación: empieza la prueba gratis o se cobra el primer periodo. Llega justo después de `APP_INSTALLED`, en cuanto tu servidor canjea el código. |
| `APP_SUBSCRIPTION_TRIAL_ENDING` | Faltan tres días para el fin de la prueba gratis, por si quieres avisar dentro de tu aplicación. |
| `APP_SUBSCRIPTION_RENEWED` | Se cobra un periodo nuevo: una renovación o el primer cobro al terminar la prueba. |
| `APP_SUBSCRIPTION_CHANGED` | La empresa cambia de plan o de periodicidad, en el acto o para la próxima renovación (`subscription.pendingChange`). |
| `APP_SUBSCRIPTION_PRICE_CHANGE_SCHEDULED` | La subida de precio que aprobaste se le aplicará a esta empresa en su renovación de `effectiveAt`, con al menos 30 días de aviso. |
| `APP_SUBSCRIPTION_PAST_DUE` | No se ha podido cobrar un periodo. La aplicación sigue funcionando mientras dura el plazo para pagar. |
| `APP_SUBSCRIPTION_PAUSED` | La suscripción queda en pausa: tu aplicación se detiene en esa empresa y no se le cobra. El motivo, en `reason`. |
| `APP_SUBSCRIPTION_RESUMED` | Vuelve a estar al corriente tras un pago pendiente o una pausa. |
| `APP_SUBSCRIPTION_CANCELED` | La empresa la cancela: funciona hasta `endsAt` y no se renueva. |
| `APP_SUBSCRIPTION_REACTIVATED` | La empresa deshace la cancelación antes de que surta efecto. |
| `APP_SUBSCRIPTION_REFUNDED` | UTILIA devuelve a la empresa todo o parte de un cobro. Lo que se descuenta de tu parte lo ves en Ingresos. |
| `APP_SUBSCRIPTION_ENDED` | La suscripción termina. El motivo, en `reason`. Sale antes de `APP_UNINSTALLED`. |

Todos traen en `data` el mismo bloque `subscription` que devuelve [`GET /external/v1/app-installations/:id`](https://os.utilia.ai/docs/developers/apps-platform/installations-api#la-suscripción-de-una-aplicación-de-pago), y cada uno solo lo que le toca:

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `occurredAt` | `string` | Sí | Cuándo pasó, en ISO 8601. |
| `subscription` | `object` | Sí | La suscripción después del cambio: plan, precio sin impuestos, estado, prueba, periodo, cancelación y cambio programado. |
| `simulated` | `boolean` | Sí | true en una compra simulada de una de tus empresas de prueba: recorre los mismos avisos sin cobrar nada. |
| `previous` | `object` | No | Estado, plan y periodicidad de antes (status, planKey, interval), solo cuando cambia alguno. |
| `reason` | `string` | No | En APP_SUBSCRIPTION_PAUSED, el motivo de la pausa (PAYMENT_FAILED, PLAN_REQUIREMENT_MISSING, LISTING_SUSPENDED, ORGANIZATION_SUSPENDED o DISPUTE_OPEN, si la empresa ha abierto una disputa con su banco por un cargo de esta suscripción; esa pausa no tiene fecha de fin y dura lo que dure la disputa). En APP_SUBSCRIPTION_ENDED, el del fin (CANCELED, TRIAL_CANCELED, UNINSTALLED, UNPAID, PAUSE_EXPIRED, LISTING_DISCONTINUED, LISTING_REMOVED, DEVELOPER_CLOSED, ORGANIZATION_CANCELED o REFUNDED, por un reembolso o una disputa perdida). |
| `endsAt` | `string` | No | En APP_SUBSCRIPTION_CANCELED: hasta cuándo funciona. |
| `effectiveAt` | `string` | No | En APP_SUBSCRIPTION_PRICE_CHANGE_SCHEDULED: cuándo se aplica el precio nuevo. |
| `refund` | `object` | No | En APP_SUBSCRIPTION_REFUNDED: amountCents, lo devuelto a la empresa con impuestos y sin desglose, y full, si ese cobro queda devuelto entero. |

```json
{
  "event": "APP_SUBSCRIPTION_PAUSED",
  "timestamp": "2026-12-12T08:00:03.000Z",
  "app": { "id": "cm3k9x2a50002qz8hm4n5p6q7", "slug": "acme-recordatorios-k7f2" },
  "installation": {
    "id": "cm3k9x2a40001qz8h7d1v2b3c",
    "organizationId": "cm1a2b3c40000ab12cd34ef56",
    "organizationName": "Ferretería López"
  },
  "data": {
    "occurredAt": "2026-12-12T08:00:00.000Z",
    "subscription": {
      "id": "cm4a1b2c30000xy98zt76uv54",
      "status": "PAUSED",
      "accessAllowed": false,
      "plan": { "key": "pro", "name": "Pro" },
      "interval": "MONTHLY",
      "priceCents": 1900,
      "currency": "EUR",
      "trialEndsAt": null,
      "currentPeriodStart": "2026-11-05T10:12:00.000Z",
      "currentPeriodEnd": "2026-12-05T10:12:00.000Z",
      "cancelAtPeriodEnd": false,
      "pendingChange": null,
      "pauseReason": "PAYMENT_FAILED",
      "endedAt": null,
      "endedReason": null,
      "simulated": false
    },
    "previous": { "status": "PAST_DUE", "planKey": "pro", "interval": "MONTHLY" },
    "reason": "PAYMENT_FAILED",
    "simulated": false
  },
  "meta": { "schemaVersion": "2026-05" }
}
```

Nunca llevan quién compró o canceló, el motivo que dio la empresa al darse de baja, códigos del banco, impuestos, facturas, la tarjeta ni datos fiscales de la empresa. Una compra que no llega a cobrarse no te avisa.

`APP_SUBSCRIPTION_ENDED` y `APP_SUBSCRIPTION_PAUSED` llegan aunque la empresa haya perdido el aplicativo Integraciones, como los otros avisos de corte. Mientras UTILIA tenga suspendida tu aplicación en una empresa, los de su suscripción siguen llegando: son los únicos.

Con los SDK 4.8.0 (sin publicar todavía), la carga se tipa con `AppSubscriptionWebhookData` y `isAccessAllowed(instalacion)` (`is_access_allowed` en Python) dice si la empresa puede usar tu aplicación.

## Si algo falla

- **No llega ningún aviso.** La ficha no tiene dirección de avisos, o la instalación aún no se ha conectado. Comprueba que el canje respondió bien.
- **Llegan los del ciclo de vida, pero no los de datos.** El evento no está en **Avisos que recibirá** de la ficha publicada, la empresa no concedió el permiso que lo cubre o su plan no incluye ese aplicativo. Mira `webhookEvents` de la instalación.
- **No llega `APP_SUBSCRIPTION_STARTED` al comprar.** Llega cuando tu servidor canjea el código de la instalación, no antes: hasta entonces no tienes su clave de firma. Si el canje falló, pide la instalación y lee `subscription`.
- **La firma no coincide.** Estás usando la clave de firma de otra instalación o la de tu propia aplicación. Elige la clave por `X-Utilia-Installation-Id` y calcula la firma sobre el cuerpo crudo.
