# Avisos: contenido y catálogo de eventos

La forma común de los avisos que UTILIA OS envía a tu aplicación y los 115 eventos que puede recibir, con ejemplos del contenido por área.

Todos los avisos que UTILIA OS envía a tu servidor comparten una forma común. Aquí están esa forma, los 115 eventos que puede recibir tu aplicación, agrupados por área, y ejemplos del contenido de los más usados. Cómo configurarlos y comprobar su firma se explica en [Avisos a tu servidor: visión general](https://os.utilia.ai/docs/developers/webhooks/overview).

## Forma común de los avisos

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `event` | `string` | Sí | Nombre del evento. |
| `timestamp` | `string` | Sí | Fecha y hora en que se generó el aviso, en ISO 8601. Es la misma en todos los reintentos. Es informativa: la firma usa la cabecera X-Utilia-Timestamp, que cambia en cada intento. |
| `app.id` | `string` | Sí | Identificador de tu aplicación externa. |
| `app.slug` | `string` | Sí | Identificador legible de tu aplicación. |
| `installation` | `object` | No | Solo en los avisos de una aplicación instalada desde el catálogo: id de la instalación, organizationId y organizationName de la empresa que la instaló. Su id va también en la cabecera X-Utilia-Installation-Id. En las demás aplicaciones la clave no viaja. |
| `data` | `object` | Sí | Datos propios de cada evento. |
| `meta.schemaVersion` | `string` | Sí | Versión de la forma del contenido (2026-05; 2026-07 en los eventos de chat). Viaja también en la cabecera X-Utilia-Schema-Version. |
| `livemode` | `boolean` | No | Solo en las muestras de un evento, y siempre en false: las que se envían desde la pestaña Pruebas de la aplicación o con POST /external/v1/diagnostics/webhook-sample. Los avisos reales no lo llevan. |
| `meta.test` | `boolean` | No | Solo en las muestras de un evento, y siempre en true. Trata como prueba todo aviso con livemode: false o meta.test: true, después de comprobar su firma. Ver Avisos de prueba en «Probar tu integración». |

## Enrutado: dirigido o por difusión

Cada evento del catálogo se entrega de una de dos maneras, y la diferencia
importa porque decide qué recibe tu aplicación:

- **Dirigido**: solo llega a la aplicación dueña del recurso. Todos los avisos
  de suscripciones y de cobros son dirigidos, y también los de facturas,
  métodos de pago y autorizaciones cuando el recurso pertenece a un usuario de
  una aplicación externa. Sin aplicación dueña no hay destinatario.
- **Por difusión**: llega a todas las aplicaciones suscritas a ese evento. Es
  como viajan los avisos de los recursos propios de la organización, que no
  pertenecen a ninguna aplicación en concreto.

Un mismo nombre de evento puede viajar de las dos formas según el recurso: una
factura emitida por tu aplicación te llega dirigida, y una emitida por el equipo
desde el panel viaja por difusión.

## Catálogo de eventos

Los 115 eventos, por área. La lista que ves al configurar los avisos en la ficha de tu aplicación ya está recortada por el plan de tu organización: un evento de un aplicativo o una capacidad que no tienes no aparece. Los cinco de las instalaciones del catálogo no salen en esa lista: llegan solos. Qué exige cada área está en [Eventos disponibles](https://os.utilia.ai/docs/developers/webhooks/overview#eventos-disponibles).

### Tickets de soporte

- TICKET_CREATED
- TICKET_REPLIED
- TICKET_RESOLVED
- TICKET_CLOSED
- TICKET_CONVERTED
- TICKET_ASSIGNED
- TICKET_REOPENED

### Usuarios externos

- USER_CREATED

### Archivos

- FILE_UPLOADED

### Errores del sistema

- ERROR_REPORTED
- ERROR_RESOLVED
- ERROR_REOPENED
- ERROR_SILENCED
- ERROR_LINKED_TO_TICKET
- ERROR_LINKED_TO_ISSUE

`ERROR_REPORTED` llega una sola vez por grupo, con su primera ocurrencia. `ERROR_RESOLVED` llega cuando alguien resuelve el grupo, desde la pestaña Errores de la aplicación, por la API o al cerrar el ticket o la tarea vinculados. `ERROR_REOPENED` dice en `reason` si alguien lo reabrió (`manual`) o si volvió a ocurrir después de resuelto (`regression`). Los tres últimos llegan cuando alguien silencia el grupo, desde la pestaña o por la API, o lo vincula con un ticket de soporte o con una tarea de Gestión de proyectos. Qué es cada uno está en [Pestaña de errores de la aplicación](https://os.utilia.ai/docs/developers/api/errors-tab#avisos-que-recibe-tu-servidor).

### Salud de tu aplicación

- APP_HEALTH_CHANGED

UTILIA OS vigila sola la salud de cada aplicación con su tráfico real y sus cambios de configuración, sin hacer llamadas de prueba, y este aviso llega a la propia aplicación cada vez que cambia su estado: `ok` (funciona), `attention` (hay algo que revisar, o funciona solo en parte), `failing` (algo bloquea y hay llamadas o avisos que fallan) o `idle` (sin uso en 30 días). Exige, como el resto, los avisos activados, el evento marcado y el aplicativo Integraciones en el plan.

- **`status` nunca vale `paused`.** Una aplicación desactivada o eliminada no recibe avisos: rechaza todas sus llamadas con `APP_DISABLED`, que ya lo dice.
- **Al volver a activarla**, el primer aviso que llega trae `previousStatus: paused`.
- **La primera comprobación de una aplicación no se avisa**: es su estado de partida, no un cambio.

Son los mismos estados que devuelve el diagnóstico en `verdict.status`; qué los provoca está en [Salud de la aplicación](https://os.utilia.ai/docs/developers/getting-started/app-health) y la forma del aviso, en [APP_HEALTH_CHANGED](#app_health_changed).

### Instalaciones del catálogo

- APP_INSTALLED
- APP_UNINSTALLED
- APP_ACCESS_CHANGED
- APP_SUSPENDED
- APP_REINSTATED

Solo los recibe una aplicación del catálogo de aplicaciones, de cada empresa que la instala. No se marcan: llegan solos a la dirección de avisos de la ficha, firmados con la clave de esa instalación, con `installation` en el sobre y la cabecera `X-Utilia-Installation-Id`. `APP_INSTALLED` llega cuando tu servidor canjea el primer código de la instalación; `APP_UNINSTALLED`, cuando la empresa la desinstala, antes de cortar el acceso; `APP_ACCESS_CHANGED`, cuando cambian sus permisos; y `APP_SUSPENDED` y `APP_REINSTATED`, cuando UTILIA la suspende o la reanuda. Qué trae cada uno está en [Avisos de cada instalación](https://os.utilia.ai/docs/developers/apps-platform/webhooks#los-cinco-avisos-del-ciclo-de-vida).

### Suscripción de una aplicación de pago

- APP_SUBSCRIPTION_STARTED
- APP_SUBSCRIPTION_TRIAL_ENDING
- APP_SUBSCRIPTION_RENEWED
- APP_SUBSCRIPTION_CHANGED
- APP_SUBSCRIPTION_PRICE_CHANGE_SCHEDULED
- APP_SUBSCRIPTION_PAST_DUE
- APP_SUBSCRIPTION_PAUSED
- APP_SUBSCRIPTION_RESUMED
- APP_SUBSCRIPTION_CANCELED
- APP_SUBSCRIPTION_REACTIVATED
- APP_SUBSCRIPTION_REFUNDED
- APP_SUBSCRIPTION_ENDED

Solo los recibe una aplicación «De pago en UTILIA» del catálogo, por cada empresa que la contrata, igual que los de la instalación: sin marcarlos, firmados con la clave de esa instalación y con `X-Utilia-Installation-Id`. `APP_SUBSCRIPTION_STARTED` llega justo después de `APP_INSTALLED` y `APP_SUBSCRIPTION_ENDED`, antes de `APP_UNINSTALLED`. Todos traen en `data` el bloque `subscription` de la instalación; cuándo llega cada uno y qué más trae está en [Avisos de cada instalación](https://os.utilia.ai/docs/developers/apps-platform/webhooks#los-avisos-de-la-suscripción).

### Presupuestos

- BUDGET_CREATED
- BUDGET_UPDATED
- BUDGET_SENT
- BUDGET_APPROVED
- BUDGET_REJECTED
- BUDGET_CANCELLED
- BUDGET_EXPIRED
- BUDGET_DUPLICATED
- BUDGET_CONVERTED_TO_PROJECT
- BUDGET_CONVERTED_TO_INVOICE
- BUDGET_CONVERTED_TO_RECURRING_INVOICE
- BUDGET_COMMENT_ADDED
- BUDGET_COMMENT_UPDATED
- BUDGET_COMMENT_DELETED
- BUDGET_SIGNED
- BUDGET_SIGNING_LINK_GENERATED
- BUDGET_SIGNING_LINK_REVOKED
- BUDGET_VIEWED
- BUDGET_PARTIALLY_SIGNED
- BUDGET_SIGNING_LINK_REGENERATED
- BUDGET_SIGNING_LINK_EXPIRED
- BUDGET_VALIDITY_EXTENDED
- BUDGET_IMAGE_PLACEMENT_CREATED
- BUDGET_IMAGE_PLACEMENT_UPDATED
- BUDGET_IMAGE_PLACEMENT_DELETED
- BUDGET_IMAGE_PLACEMENT_REPOSITIONED
- BUDGET_IMAGE_PLACEMENT_CROPPED
- BUDGET_IMAGE_PLACEMENT_REORDERED

Como el resto, los de firma electrónica y los de imágenes solo llegan a las aplicaciones que los marquen en su configuración de avisos.

`BUDGET_VIEWED` no lleva la dirección IP completa de quien abre el presupuesto: solo `view.ipAddressMasked`. Su forma está en [el ejemplo de más abajo](#budget_viewed).

### Facturación

- INVOICE_CREATED
- INVOICE_SENT
- INVOICE_PAID
- INVOICE_PARTIALLY_PAID
- INVOICE_PAYMENT_FAILED
- INVOICE_OVERDUE
- INVOICE_CANCELLED
- INVOICE_REFUNDED
- INVOICE_REFUND_CANCELLED
- INVOICE_RECTIFIED
- INVOICE_RECTIFICATION_CREATED
- INVOICE_RECTIFICATION_METADATA_UPDATED

### Métodos de pago

- PAYMENT_METHOD_ADDED
- PAYMENT_METHOD_REMOVED
- PAYMENT_METHOD_EXPIRING

### Autorizaciones de cobro

- PAYMENT_AUTHORIZATION_GRANTED
- PAYMENT_AUTHORIZATION_REVOKED
- PAYMENT_AUTHORIZATION_CONSUMED

### Suscripciones

- SUBSCRIPTION_CREATED
- SUBSCRIPTION_UPDATED
- SUBSCRIPTION_PAUSED
- SUBSCRIPTION_RESUMED
- SUBSCRIPTION_CANCELLED
- SUBSCRIPTION_COMPLETED
- SUBSCRIPTION_INVOICE_GENERATED
- SUBSCRIPTION_PAYMENT_AUTHORIZATION_PENDING

### Cobros programados

- CHARGE_SCHEDULED
- CHARGE_STARTED
- CHARGE_SUCCEEDED
- CHARGE_FAILED
- CHARGE_REQUIRES_ACTION
- CHARGE_RETRY_SCHEDULED
- CHARGE_RETRIES_EXHAUSTED
- CHARGE_CANCELLED

### Contactos del CRM

- CONTACT_CREATED
- CONTACT_UPDATED
- CONTACT_DELETED
- CONTACT_OPTED_OUT
- CONTACT_OPTED_IN

### Leads del CRM

- LEAD_CREATED
- LEAD_UPDATED
- LEAD_QUALIFIED
- LEAD_DISQUALIFIED
- LEAD_CONVERTED
- LEAD_SCORE_UPDATED

### Clientes del CRM

- CLIENT_CREATED
- CLIENT_UPDATED
- CLIENT_DEACTIVATED
- CLIENT_REACTIVATED
- CLIENT_FISCAL_DATA_UPDATED
- CLIENT_LINKED_TO_GROUP
- CLIENT_UNLINKED_FROM_GROUP

**Grupos de empresas.** `CLIENT_LINKED_TO_GROUP` y `CLIENT_UNLINKED_FROM_GROUP` avisan de que una filial se vincula a su empresa matriz o se desvincula. Viajan solo con identificadores (`parentClientId`, `childClientId`, `organizationId` y, en la desvinculación, `reason`): si necesitas los datos de los clientes, pídelos a la API. Cambiar el vínculo no dispara `CLIENT_UPDATED`, y al disolver un grupo llega un `CLIENT_UNLINKED_FROM_GROUP` por filial con `reason=DISSOLVE`.

**Sin bucles.** Los eventos `CONTACT_*`, `LEAD_*` y `CLIENT_*` llegan por difusión a todas las aplicaciones suscritas, salvo a la que provocó el cambio: si tu aplicación registra la baja de un contacto, no recibe el aviso de su propia baja. `LEAD_CONVERTED` incluye `contactId` y, si se creó, `opportunityId`. No existen `LEAD_DELETED` ni `CLIENT_DELETED`: la API externa no borra leads ni clientes, los descarta (`/disqualify`) o los desactiva (`/deactivate`). El IBAN no viaja nunca en un aviso de cliente. Detalle en [Contactos](https://os.utilia.ai/docs/developers/api/contacts), [Leads](https://os.utilia.ai/docs/developers/api/leads) y [Clientes](https://os.utilia.ai/docs/developers/api/clients).

**Tarjeta guardada no es permiso para cobrar.** Los eventos `PAYMENT_AUTHORIZATION_*` cubren la firma y la retirada del consentimiento con el que la organización cobra suscripciones y cobros programados sin el usuario delante. Son distintos de `PAYMENT_METHOD_*`: guardar una tarjeta (`PAYMENT_METHOD_ADDED`) no autoriza cobros automáticos; para eso el usuario firma un texto de consentimiento con su versión.

**Facturas dirigidas o por difusión.** Los eventos `INVOICE_*` de una factura emitida por una aplicación externa (`source = EXTERNAL_APP`) solo llegan a esa aplicación. Los de una factura emitida desde el panel (`source = UTILIA_NATIVE`) llegan a todas las aplicaciones suscritas. Las rectificativas heredan el origen de la factura que corrigen: `INVOICE_RECTIFICATION_CREATED` e `INVOICE_RECTIFIED` de una factura de tu aplicación llegan solo a tu aplicación, tanto si la rectificativa la emite ella con `POST /external/v1/invoices/:id/rectifications` como si la emite tu equipo desde UTILIA OS. Solo se difunden cuando la original y la rectificativa son las dos del panel.

**Tipo de documento.** Desde el 13 de octubre de 2026, el objeto `invoice` de todos los avisos de factura lleva `type`: `STANDARD` (ordinaria), `SIMPLIFIED` (simplificada), `RECTIFYING` (rectificativa) o `CREDIT_NOTE` (abono). Las facturas anteriores a ese campo llegan como `STANDARD`.

**Serie.** Desde el 15 de octubre de 2026, el objeto `invoice` de todos los avisos de factura lleva también `seriesCode`: el código de la serie de tu aplicación en la que se numeró o `DEFAULT` para la serie de siempre de su tipo. Las rectificativas y los abonos llegan siempre con `DEFAULT`, porque van a su propia serie. Ver [Series disponibles](https://os.utilia.ai/docs/developers/api/invoices#series-disponibles).

**Número impreso.** Desde el 19 de octubre de 2026, todos los bloques de factura de los avisos `INVOICE_*` (`invoice` y, en los de rectificación, `originalInvoice` o `rectifyingInvoice`) llevan también `displayNumber`, el número tal como se imprime: `invoiceNumber` con un guion entre el año y el número de orden (`T-202600001` se imprime `T-2026-00001`). Úsalo para enseñarlo; guarda y concilia por `invoiceNumber`.

**Sistema impositivo y Estado de consumo.** Desde el 15 de octubre de 2026, el objeto `invoice` (y `originalInvoice` o `rectifyingInvoice` en los avisos de rectificación) lleva `taxSystem`, `consumerCountryCode` y `ossScheme`. `consumerCountryCode` es el Estado miembro del consumidor (nulo si no hay); en la difusión de las facturas que emite tu equipo llega siempre nulo, porque es un dato del consumidor. `ossScheme` solo tiene valor en las facturas de la ventanilla única del IVA (`EU_OSS`), que está en «Próximamente». Los avisos no llevan líneas ni la prueba de la ubicación del consumidor.

> **Atención: La divisa llega en minúsculas**
>
> Los campos `currency` de los avisos de factura y de pago llegan **en minúsculas** (`eur`, `usd`), y `eur` es el valor por defecto cuando no se indicó divisa. Compara siempre sin distinguir mayúsculas y minúsculas (`currency.toLowerCase() === 'eur'`) o tu condición fallará sin avisar.

### Plataforma de chat

- CHAT_MESSAGE_CREATED
- CHAT_MESSAGE_UPDATED
- CHAT_MESSAGE_DELETED
- CHAT_CHANNEL_CREATED
- CHAT_CHANNEL_UPDATED
- CHAT_CHANNEL_ARCHIVED
- CHAT_MEMBER_ADDED
- CHAT_MEMBER_REMOVED
- CHAT_APP_MENTIONED
- CHAT_AGENT_TASK_EXECUTED
- CHAT_AGENT_PENDING_APPROVAL
- CHAT_AGENT_APPROVED
- CHAT_AGENT_REJECTED
- CHAT_AGENT_BUDGET_EXCEEDED

Los eventos `CHAT_*` tienen su propia forma y viajan con `meta.schemaVersion = 2026-07` (el resto usa `2026-05`). Son dirigidos: solo llegan a las aplicaciones que participan en el canal y tienen el permiso correspondiente, y nunca transportan mensajes internos ni privados. Su contenido y ejemplos de receptor están en [Webhooks de chat](https://os.utilia.ai/docs/developers/chat-platform/webhooks).

## Ejemplos del contenido

### TICKET_CREATED

```json
{
  "event": "TICKET_CREATED",
  "timestamp": "2026-02-08T00:00:00.000Z",
  "app": { "id": "app_1", "slug": "mi-aplicacion" },
  "data": {
    "ticket": {
      "id": "t_1",
      "ticketKey": "mi-aplicacion-0001",
      "title": "No puedo iniciar sesión",
      "category": "BUG",
      "priority": "MEDIA",
      "status": "OPEN",
      "createdAt": "2026-02-08T00:00:00.000Z"
    },
    "user": {
      "externalId": "user_01HXYZ",
      "email": "cliente@ejemplo.com",
      "name": "Ana Cliente"
    }
  }
}
```

### ERROR_REPORTED

```json
{
  "event": "ERROR_REPORTED",
  "timestamp": "2026-02-27T12:00:00.000Z",
  "app": { "id": "app_1", "slug": "mi-aplicacion" },
  "data": {
    "hash": "a1b2c3d4e5f6...",
    "message": "Connection timeout to payment gateway",
    "module": "payment-processor",
    "severity": "critical",
    "deduplicated": false,
    "release": "2.4.1",
    "environment": "production",
    "count": 1
  }
}
```

El aviso se emite una única vez por `hash`, en la primera ocurrencia del error; las repeticiones posteriores suben el contador sin generar entrega. `count` son las ocurrencias ya contadas al emitirlo, y `release` y `environment` son los que envió tu aplicación, o `null`. `deduplicated` llega **siempre** con valor `false` y se conserva solo por compatibilidad del contrato: no construyas lógica sobre este campo ni esperes un aviso por cada repetición.

### ERROR_RESOLVED

```json
{
  "event": "ERROR_RESOLVED",
  "timestamp": "2026-02-27T14:00:00.000Z",
  "app": { "id": "app_1", "slug": "mi-aplicacion" },
  "data": {
    "hash": "a1b2c3d4e5f6...",
    "resolvedBy": "user-admin-id",
    "note": "Reiniciado el servicio de pagos",
    "reason": "manual"
  }
}
```

`resolvedBy` es quien lo resolvió: la persona del equipo o, si lo resolvió tu aplicación por la API, su persona responsable. Si lo resuelve el equipo de soporte de UTILIA, `resolvedBy` y `note` llegan vacíos (`null`): su nota es interna. `reason` es hoy siempre `manual`.

### ERROR_REOPENED

`reason` dice por qué se reabrió: `manual`, si alguien lo reabrió (`reopenedBy` lo identifica), o `regression`, si estaba resuelto y volvió a ocurrir (`reopenedBy` llega `null`). En una regresión, `release` trae la versión en la que reapareció, si tu aplicación la envía.

```json
{
  "event": "ERROR_REOPENED",
  "timestamp": "2026-02-27T16:00:00.000Z",
  "app": { "id": "app_1", "slug": "mi-aplicacion" },
  "data": {
    "hash": "a1b2c3d4e5f6...",
    "reopenedBy": null,
    "reason": "regression",
    "release": "2.5.0"
  }
}
```

### ERROR_SILENCED

`until` es la fecha hasta la que el grupo de errores queda silenciado, o `null` si se ha quitado el silencio. `silencedBy` es nulo cuando la acción no la origina una persona.

```json
{
  "event": "ERROR_SILENCED",
  "timestamp": "2026-08-04T09:15:00.000Z",
  "app": { "id": "app_1", "slug": "mi-aplicacion" },
  "data": {
    "hash": "a1b2c3d4e5f6...",
    "until": "2026-08-11T09:15:00.000Z",
    "silencedBy": "user-admin-id"
  }
}
```

### ERROR_LINKED_TO_TICKET

```json
{
  "event": "ERROR_LINKED_TO_TICKET",
  "timestamp": "2026-08-04T09:20:00.000Z",
  "app": { "id": "app_1", "slug": "mi-aplicacion" },
  "data": {
    "hash": "a1b2c3d4e5f6...",
    "ticket": { "id": "tkt_123", "ticketKey": "SUP-482" },
    "linkedBy": "user-admin-id"
  }
}
```

### ERROR_LINKED_TO_ISSUE

```json
{
  "event": "ERROR_LINKED_TO_ISSUE",
  "timestamp": "2026-08-04T09:25:00.000Z",
  "app": { "id": "app_1", "slug": "mi-aplicacion" },
  "data": {
    "hash": "a1b2c3d4e5f6...",
    "issue": { "id": "iss_456", "key": "PROJ-123" },
    "linkedBy": "user-admin-id"
  }
}
```

### APP_HEALTH_CHANGED

```json
{
  "event": "APP_HEALTH_CHANGED",
  "timestamp": "2026-10-21T10:20:00.000Z",
  "app": { "id": "ckapp1234567890", "slug": "mi-integracion" },
  "data": {
    "status": "failing",
    "previousStatus": "ok",
    "title": "Falla",
    "since": "2026-10-21T10:20:00.000Z",
    "findings": [
      {
        "code": "REJECTED_CALLS",
        "severity": "blocking",
        "fixedBy": "developer",
        "since": "2026-10-21T10:00:00.000Z",
        "count": 42,
        "route": "/external/v1/tickets",
        "errorCode": "VALIDATION_ERROR"
      }
    ],
    "diagnosticsPath": "/external/v1/diagnostics"
  }
}
```

`title` es el nombre corto del estado nuevo, tal como lo manda el servidor; para decidir, usa `status` (`ok`, `attention`, `failing` o `idle`), que no cambia de nombre. En el panel, `failing` se lee **No funciona**. `previousStatus` puede valer además `paused`, en el primer aviso después de reactivar la aplicación. `findings` trae los hallazgos de la evaluación con su código, su gravedad, quién lo arregla y, si los hay, desde cuándo, cuántas veces, la ruta y el `errorCode`; nunca texto libre ni datos personales. El texto y el arreglo de cada hallazgo te los da `GET /external/v1/diagnostics` con tu clave, y cada código tiene su apartado en [Hallazgos del diagnóstico](https://os.utilia.ai/docs/developers/getting-started/testing). La muestra que se envía desde las pruebas tiene esta misma forma.

### CLIENT_LINKED_TO_GROUP

```json
{
  "event": "CLIENT_LINKED_TO_GROUP",
  "timestamp": "2026-07-05T10:15:00.000Z",
  "app": { "id": "app_1", "slug": "mi-aplicacion" },
  "data": {
    "parentClientId": "cli-matriz-uuid",
    "childClientId": "cli-filial-uuid",
    "organizationId": "org-uuid",
    "occurredAt": "2026-07-05T10:15:00.000Z"
  }
}
```

### CLIENT_UNLINKED_FROM_GROUP

```json
{
  "event": "CLIENT_UNLINKED_FROM_GROUP",
  "timestamp": "2026-07-05T11:40:00.000Z",
  "app": { "id": "app_1", "slug": "mi-aplicacion" },
  "data": {
    "parentClientId": "cli-matriz-uuid",
    "childClientId": "cli-filial-uuid",
    "organizationId": "org-uuid",
    "reason": "DISSOLVE",
    "occurredAt": "2026-07-05T11:40:00.000Z"
  }
}
```

### BUDGET_VIEWED

Alguien ha abierto el presupuesto: desde el enlace del correo o dentro del Portal del Cliente. Se envía con la primera apertura de cada ventana de 30 minutos, no con cada recarga.

```json
{
  "event": "BUDGET_VIEWED",
  "timestamp": "2026-10-12T09:30:00.000Z",
  "app": { "id": "app_1", "slug": "mi-aplicacion" },
  "data": {
    "budget": { "id": "bud-uuid" },
    "view": {
      "viewTokenId": "tok-uuid",
      "viewedAt": "2026-10-12T09:30:00.000Z",
      "ipAddress": null,
      "ipAddressMasked": "***.***.113.45",
      "channel": "public-view-link"
    }
  }
}
```

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `view.channel` | `string` | Sí | public-view-link si se abrió desde el enlace del correo; portal si se abrió dentro del Portal del Cliente. |
| `view.viewTokenId` | `string \| null` | Sí | Enlace con el que se abrió. null en el portal, que no usa enlace. |
| `view.ipAddressMasked` | `string \| null` | Sí | IP de quien lo abrió, enmascarada: en IPv4 se ocultan los dos primeros bloques (_**.**_.113.45); en IPv6 se conservan los dos primeros grupos y el resto pasa a \*\*\*. null en el portal y cuando no hay dirección. |
| `view.ipAddress` | `null` | Sí | Siempre null. Se conserva para no romper a quien ya leía el campo. |

> **Atención: Cambio incompatible: la IP completa ya no viaja**
>
> Antes, `view.ipAddress` llevaba la dirección IP completa de quien abría el presupuesto. Ahora va siempre a `null` y la dirección enmascarada llega en `view.ipAddressMasked`. Las entregas guardadas anteriores también se han enmascarado, así que reintentar una antigua no envía la IP completa. Si tu integración la usaba, lee `view.ipAddressMasked`; la IP completa de quien firma solo está en el certificado de la firma, dentro de UTILIA OS.

### INVOICE_CREATED

```json
{
  "event": "INVOICE_CREATED",
  "timestamp": "2026-04-19T10:32:15.000Z",
  "app": { "id": "app_1", "slug": "mi-aplicacion" },
  "data": {
    "invoice": {
      "id": "inv-uuid",
      "invoiceNumber": "202600001",
      "displayNumber": "2026-00001",
      "type": "STANDARD",
      "seriesCode": "DEFAULT",
      "taxSystem": "IGIC",
      "consumerCountryCode": null,
      "ossScheme": null,
      "status": "ISSUED",
      "totalAmount": 53.45,
      "paidAmount": 0,
      "currency": "eur",
      "issueDate": "2026-04-19T00:00:00.000Z",
      "dueDate": null
    },
    "externalUser": { "externalId": "user_01HXYZ" },
    "metadata": { "orderId": "ORD-4471" }
  }
}
```

`metadata` solo viaja en `INVOICE_CREATED`: es el objeto `metadata` que tu aplicación envió al crear la factura por la API externa, devuelto tal cual salvo las claves reservadas de UTILIA OS (como `externalIdempotencyKey` o la residencia del consumidor), que se quitan antes de enviarlo. Llega `null` si la factura se creó sin `metadata`.

### INVOICE_PAID

```json
{
  "event": "INVOICE_PAID",
  "timestamp": "2026-04-20T11:22:33.000Z",
  "app": { "id": "app_1", "slug": "mi-aplicacion" },
  "data": {
    "invoice": {
      "id": "inv-uuid",
      "invoiceNumber": "202600001",
      "displayNumber": "2026-00001",
      "status": "PAID",
      "totalAmount": 53.45,
      "paidAmount": 53.45,
      "currency": "eur",
      "issueDate": "2026-04-19T00:00:00.000Z",
      "dueDate": null
    },
    "externalUser": { "externalId": "user_01HXYZ" },
    "payment": {
      "paymentIntentId": "pi-utilia-uuid",
      "amount": 53.45,
      "currency": "eur",
      "isFullPayment": true
    }
  }
}
```

### INVOICE_PARTIALLY_PAID

```json
{
  "event": "INVOICE_PARTIALLY_PAID",
  "timestamp": "2026-04-20T11:22:33.000Z",
  "app": { "id": "app_1", "slug": "mi-aplicacion" },
  "data": {
    "invoice": {
      "id": "inv-uuid",
      "invoiceNumber": "202600001",
      "displayNumber": "2026-00001",
      "status": "PARTIALLY_PAID",
      "totalAmount": 100.00,
      "paidAmount": 53.45,
      "currency": "eur",
      "issueDate": "2026-04-19T00:00:00.000Z",
      "dueDate": null
    },
    "externalUser": { "externalId": "user_01HXYZ" },
    "payment": {
      "paymentIntentId": "pi-utilia-uuid",
      "amount": 53.45,
      "currency": "eur",
      "isFullPayment": false
    }
  }
}
```

### INVOICE_PAYMENT_FAILED

```json
{
  "event": "INVOICE_PAYMENT_FAILED",
  "timestamp": "2026-04-20T11:10:05.000Z",
  "app": { "id": "app_1", "slug": "mi-aplicacion" },
  "data": {
    "invoice": {
      "id": "inv-uuid",
      "invoiceNumber": "202600001",
      "displayNumber": "2026-00001",
      "status": "ISSUED",
      "totalAmount": 53.45,
      "paidAmount": 0,
      "currency": "eur",
      "issueDate": "2026-04-19T00:00:00.000Z",
      "dueDate": null
    },
    "externalUser": { "externalId": "user_01HXYZ" },
    "payment": {
      "paymentIntentId": "pi-utilia-uuid",
      "amount": 53.45,
      "currency": "eur",
      "errorCode": "card_declined",
      "errorMessage": "Tu banco ha rechazado la tarjeta.",
      "initiator": "SYSTEM_RECURRING"
    }
  }
}
```

### INVOICE_OVERDUE

```json
{
  "event": "INVOICE_OVERDUE",
  "timestamp": "2026-05-20T06:00:00.000Z",
  "app": { "id": "app_1", "slug": "mi-aplicacion" },
  "data": {
    "invoice": {
      "id": "inv-uuid",
      "invoiceNumber": "202600001",
      "displayNumber": "2026-00001",
      "status": "OVERDUE",
      "totalAmount": 53.45,
      "paidAmount": 0,
      "currency": "eur",
      "issueDate": "2026-04-19T00:00:00.000Z",
      "dueDate": "2026-05-19T00:00:00.000Z"
    },
    "externalUser": { "externalId": "user_01HXYZ" }
  }
}
```

### INVOICE_CANCELLED

```json
{
  "event": "INVOICE_CANCELLED",
  "timestamp": "2026-04-20T15:00:00.000Z",
  "app": { "id": "app_1", "slug": "mi-aplicacion" },
  "data": {
    "invoice": {
      "id": "inv-uuid",
      "invoiceNumber": "202600001",
      "displayNumber": "2026-00001",
      "status": "CANCELLED",
      "totalAmount": 53.45,
      "paidAmount": 0,
      "currency": "eur",
      "issueDate": "2026-04-19T00:00:00.000Z",
      "dueDate": null
    },
    "externalUser": { "externalId": "user_01HXYZ" },
    "reason": "Factura duplicada"
  }
}
```

### INVOICE_REFUNDED

```json
{
  "event": "INVOICE_REFUNDED",
  "timestamp": "2026-04-22T09:15:00.000Z",
  "app": { "id": "app_1", "slug": "mi-aplicacion" },
  "data": {
    "invoice": {
      "id": "inv-uuid",
      "invoiceNumber": "202600001",
      "displayNumber": "2026-00001",
      "type": "STANDARD",
      "seriesCode": "DEFAULT",
      "status": "PARTIALLY_PAID",
      "totalAmount": 53.45,
      "paidAmount": 0,
      "currency": "eur",
      "issueDate": "2026-04-19T00:00:00.000Z",
      "dueDate": null
    },
    "externalUser": { "externalId": "user_01HXYZ" },
    "refund": {
      "refundId": "ref-uuid",
      "paymentIntentId": "pi-utilia-uuid",
      "amount": 5345,
      "currency": "eur",
      "reason": "requested_by_customer",
      "isFullRefund": true,
      "source": "STRIPE"
    }
  }
}
```

> **Atención: refund.amount viaja en céntimos, payment.amount no**
>
> `refund.amount` es el importe devuelto **en céntimos** (en el ejemplo, `5345` son 53,45 €). Los demás importes de los avisos de factura van en **unidades** de la divisa: `invoice.totalAmount`, `invoice.paidAmount` y también `payment.amount` de `INVOICE_PAID`, `INVOICE_PARTIALLY_PAID` e `INVOICE_PAYMENT_FAILED`. Si lo sumas o lo comparas con ellos sin convertir, obtendrás una cifra cien veces mayor. `refund.source` dice por dónde se devolvió: `STRIPE`, `MANUAL_BANK`, `MANUAL_CASH`, `MANUAL_OTHER` o `EXTERNAL_GATEWAY`.

`EXTERNAL_GATEWAY` («Pasarela de pago externa») es una devolución que tu
aplicación hizo en su propia pasarela y registró en UTILIA OS al emitir
la [rectificativa](https://os.utilia.ai/docs/developers/api/invoice-rectifications)
con el bloque `refund`, o que tu equipo registró a mano. UTILIA OS no
movió ese dinero.

### INVOICE_RECTIFIED

Llega cuando una rectificativa ya emitida surte efectos sobre la factura
original. `invoice` es la original, `rectifyingInvoice` la rectificativa
y `rectification` su código, su modalidad y su causa impresa. Antes llega
`INVOICE_RECTIFICATION_CREATED`, con la rectificativa en `invoice` y la
original en `originalInvoice`.

```json
{
  "event": "INVOICE_RECTIFIED",
  "timestamp": "2026-10-13T10:05:00.000Z",
  "app": { "id": "app_1", "slug": "mi-aplicacion" },
  "data": {
    "invoice": {
      "id": "cksimp1234567890",
      "invoiceNumber": "T-202600001",
      "displayNumber": "T-2026-00001",
      "type": "SIMPLIFIED",
      "seriesCode": "DEFAULT",
      "status": "RECTIFIED",
      "totalAmount": 39.99,
      "paidAmount": 0,
      "currency": "eur",
      "issueDate": "2026-10-13T00:00:00.000Z",
      "dueDate": null
    },
    "rectifyingInvoice": {
      "id": "ckrect1234567890",
      "invoiceNumber": "R-202600003",
      "displayNumber": "R-2026-00003",
      "type": "RECTIFYING",
      "seriesCode": "DEFAULT",
      "status": "ISSUED",
      "totalAmount": -39.99,
      "paidAmount": 0,
      "currency": "eur",
      "issueDate": "2026-10-13T00:00:00.000Z",
      "dueDate": null
    },
    "rectification": {
      "code": "R5",
      "type": "DIFFERENCE",
      "reason": "Devolución del precio."
    },
    "externalUser": { "externalId": "user_01HXYZ" }
  }
}
```

### PAYMENT_METHOD_ADDED

```json
{
  "event": "PAYMENT_METHOD_ADDED",
  "timestamp": "2026-04-10T12:00:00.000Z",
  "app": { "id": "app_1", "slug": "mi-aplicacion" },
  "data": {
    "externalUser": { "externalId": "user_01HXYZ" },
    "paymentMethod": {
      "id": "pm-uuid",
      "brand": "visa",
      "last4": "4242"
    }
  }
}
```

### PAYMENT_METHOD_REMOVED

```json
{
  "event": "PAYMENT_METHOD_REMOVED",
  "timestamp": "2026-04-15T09:30:00.000Z",
  "app": { "id": "app_1", "slug": "mi-aplicacion" },
  "data": {
    "externalUser": { "externalId": "user_01HXYZ" },
    "paymentMethod": {
      "id": "pm-uuid",
      "brand": "visa",
      "last4": "4242"
    }
  }
}
```

### PAYMENT_AUTHORIZATION_GRANTED

Se dispara cada vez que un cliente firma una autorización de cobro automático sobre una tarjeta concreta. Una reactivación tras revocar **no** reusa el mandato anterior: emite un evento nuevo con `mandateId` distinto.

#### Forma del campo `data`

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `mandateId` | `string` | Sí | Identificador interno UTILIA del mandato firmado. Único e idempotente. |
| `clientId` | `string` | Sí | Identificador del cliente al que pertenece la autorización. |
| `scope` | `enum` | Sí | Alcance del mandato: SINGLE_INVOICE, PER_INVOICE_OPT_IN, SCHEDULED_INVOICE, RECURRING o RECURRING_SUBSCRIPTION. |
| `paymentMethod.id` | `string` | Sí | Identificador de UTILIA OS del método de pago, no el de Stripe. Llega como cadena vacía en el caso límite de un mandato sin tarjeta vinculada, así que comprueba que no está vacío antes de usarlo como clave. |
| `paymentMethod.brand` | `string` | No | Marca de la tarjeta (visa, mastercard, amex, etc.). Puede ser null. |
| `paymentMethod.last4` | `string` | No | Últimos cuatro dígitos. Puede ser null. |
| `paymentMethod.expMonth` | `number` | No | Mes de caducidad (1-12). Puede ser null. |
| `paymentMethod.expYear` | `number` | No | Año de caducidad (cuatro dígitos). Puede ser null. |
| `consent.version` | `string` | Sí | Versión del texto legal aceptado (no cambia aunque la plantilla evolucione). |
| `consent.ipAddress` | `string` | No | IP enmascarada con los dos primeros octetos (formato a.b.x.x para IPv4 o a:b:x:x:x para IPv6). Puede ser null si el proxy no la pudo resolver. |
| `consent.userAgent` | `string` | No | User-Agent del navegador o cliente que firmó (truncado a 500 caracteres). Puede no llegar. |
| `consent.signedAt` | `datetime` | Sí | Fecha y hora ISO 8601 de aceptación. |
| `linkedSubscriptionIds` | `string[]` | Sí | Suscripciones (RecurringInvoice) vinculadas al mandato en el instante de la activación. Puede crecer después: consulta la API antes de cada cobro para tener la lista vigente. |
| `source` | `enum` | No | Origen del flujo: PORTAL_ADD_CARD, PORTAL_PAY_INVOICE, PORTAL_SUBSCRIPTION, PUBLIC_SETUP_INTENT, STAFF_DASHBOARD u OTHER. Puede ser null. |

#### Ejemplo

```json
{
  "event": "PAYMENT_AUTHORIZATION_GRANTED",
  "timestamp": "2026-05-09T18:42:13.000Z",
  "app": { "id": "app_1", "slug": "mi-aplicacion" },
  "data": {
    "mandateId": "pma_01HQ2X7C9MZK3FQAGBYQ8K2W4P",
    "clientId": "cli_01HXYZK6Z3AQAGBYQ8K2W4P",
    "scope": "RECURRING_SUBSCRIPTION",
    "paymentMethod": {
      "id": "pm_01HQ2X7C9MZK3FQAGBYQ8K2W4P",
      "brand": "visa",
      "last4": "4242",
      "expMonth": 4,
      "expYear": 2029
    },
    "consent": {
      "version": "v1.2",
      "ipAddress": "203.0.x.x",
      "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2) AppleWebKit/537.36",
      "signedAt": "2026-05-09T18:42:13.000Z"
    },
    "linkedSubscriptionIds": ["rec_01HXYZK6Z3AQAGBYQ8K2W4P"],
    "source": "PORTAL_ADD_CARD"
  }
}
```

La IP de `consent.ipAddress` llega **recortada** por protección de datos: en IPv4 se conservan los dos primeros bloques y los dos últimos se sustituyen por `x.x` (por ejemplo, `203.0.x.x`); en IPv6 se conservan los dos primeros segmentos y el resto se sustituye por `x:x:x`. La IP completa queda en el registro de auditoría de UTILIA OS y no se envía por aviso.

`paymentMethod.id` es el **identificador de UTILIA OS** del método de pago, estable entre avisos del mismo método. No es el identificador de Stripe, que no se envía nunca.

### PAYMENT_AUTHORIZATION_REVOKED

Se dispara cuando una autorización deja de valer. Las suscripciones afectadas pasan a cobro manual en UTILIA OS; si guardas una copia en tu sistema, refleja el cambio.

#### Forma del campo `data`

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `mandateId` | `string` | Sí | Identificador del mandato revocado. Coincide con el mandateId del GRANTED previo. |
| `clientId` | `string` | Sí | Identificador del cliente al que pertenecía la autorización. |
| `scope` | `enum` | Sí | Mismo enum que en GRANTED, pero NO es fiable en la revocación: si el mandato ya se ha marcado como revocado en base de datos, el emisor no puede recuperar su alcance original y envía siempre RECURRING_SUBSCRIPTION. No filtres por este campo en los eventos REVOKED. |
| `revokedBy` | `enum` | Sí | Quién inició la revocación: PORTAL_USER, STAFF, SYSTEM_PAYMENT_METHOD_EXPIRED o SYSTEM_OTHER. |
| `revokedAt` | `datetime` | Sí | Fecha y hora ISO 8601 en la que se efectuó la revocación. |
| `revocationReason` | `enum` | Sí | Motivo categorizado: USER_REVOKED, STAFF_REVOKED, PAYMENT_METHOD_EXPIRED, PAYMENT_METHOD_DETACHED u OTHER. |
| `affectedSubscriptionIds` | `string[]` | Sí | Suscripciones del cliente afectadas por la revocación. UTILIA OS las pasa a cobro manual; tu aplicación debería reflejar el cambio. La lista se calcula de forma aproximada (ver aviso más abajo): puede incluir alguna suscripción del mismo cliente que no dependía de este mandato. |

#### Ejemplo

```json
{
  "event": "PAYMENT_AUTHORIZATION_REVOKED",
  "timestamp": "2026-05-09T19:14:02.000Z",
  "app": { "id": "app_1", "slug": "mi-aplicacion" },
  "data": {
    "mandateId": "pma_01HQ2X7C9MZK3FQAGBYQ8K2W4P",
    "clientId": "cli_01HXYZK6Z3AQAGBYQ8K2W4P",
    "scope": "RECURRING_SUBSCRIPTION",
    "revokedBy": "PORTAL_USER",
    "revokedAt": "2026-05-09T19:14:02.000Z",
    "revocationReason": "USER_REVOKED",
    "affectedSubscriptionIds": ["rec_01HXYZK6Z3AQAGBYQ8K2W4P"]
  }
}
```

#### Dos campos de la revocación que no son exactos

- **`scope` no conserva el alcance original.** Al revocar, el mandato pasa a estado revocado en base de datos y el emisor pierde el alcance con el que se firmó, así que rellena siempre `RECURRING_SUBSCRIPTION`. Un mandato que era `SINGLE_INVOICE` llegará igualmente como `RECURRING_SUBSCRIPTION` en el evento REVOKED. Si necesitas el alcance real, guárdalo del `PAYMENT_AUTHORIZATION_GRANTED` previo, que sí lo trae bien, y búscalo por `mandateId`.
- **`affectedSubscriptionIds` es una aproximación.** Además de las suscripciones que todavía apuntan al mandato, la consulta recoge las del mismo cliente que se hayan quedado sin mandato en el último minuto, porque la cascada interna las desvincula antes de emitirse el webhook. En un cliente con varias suscripciones puede colarse alguna que no dependía de este mandato. Trátala como una lista de candidatas a revisar, no como una verdad cerrada.

Quién y por qué retiró la autorización:

- `revokedBy = PORTAL_USER` con `revocationReason = USER_REVOKED`: el cliente la retiró desde el portal del cliente.
- `revokedBy = STAFF` con `revocationReason = STAFF_REVOKED`: una persona del equipo la retiró desde el panel.
- `revokedBy = SYSTEM_PAYMENT_METHOD_EXPIRED` con `revocationReason = PAYMENT_METHOD_EXPIRED`: UTILIA OS detectó que la tarjeta del mandato había caducado y retiró la autorización.
- `revokedBy = SYSTEM_OTHER` con `revocationReason = PAYMENT_METHOD_DETACHED`: la tarjeta se desvinculó en Stripe (por el cliente o por el equipo) y arrastró al mandato.
- `revokedBy = SYSTEM_OTHER` con `revocationReason = OTHER`: cualquier otra causa interna.

Por protección de datos, los avisos de autorización **no** incluyen el texto íntegro del consentimiento (solo su versión), el identificador de Stripe del método de pago, la IP completa, el motivo libre que escribe el equipo al retirarla ni el identificador de la persona que firmó o retiró. Todo eso queda en el registro de auditoría de UTILIA OS.

### INVOICE_SENT

Se emite cuando la factura sale por correo hacia el usuario final, tanto desde
la API externa como cuando la envía el equipo desde el panel.

```json
{
  "event": "INVOICE_SENT",
  "timestamp": "2026-10-01T09:13:10.482Z",
  "app": { "id": "app_...", "slug": "mi-aplicacion" },
  "data": {
    "invoice": {
      "id": "ckinv1234567890",
      "invoiceNumber": "202600042",
      "displayNumber": "2026-00042",
      "status": "SENT",
      "totalAmount": 53.45,
      "paidAmount": 0,
      "currency": "eur",
      "issueDate": "2026-10-01T00:00:00.000Z",
      "dueDate": "2026-10-08T00:00:00.000Z"
    },
    "externalUser": { "externalId": "user_01HXYZ" },
    "delivery": {
      "recipients": ["administracion@estudiomarea.example"],
      "sentAt": "2026-10-01T09:13:10.000Z"
    }
  },
  "meta": { "schemaVersion": "2026-05" }
}
```

El bloque `payment` de `INVOICE_PAID` e `INVOICE_PARTIALLY_PAID` incluye
`source`. Vale `manual` cuando el cobro se registró fuera de la pasarela (una
transferencia, un pago en efectivo, la llamada a `mark-paid`), y en ese caso
`method` dice con qué forma de pago.

## Suscripciones

Los ocho avisos del dominio comparten estos campos en `data`, además de los que
añade cada uno:

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `subscriptionId` | `string` | Sí | Identificador de la suscripción en UTILIA OS. |
| `externalUserId` | `string \| null` | Sí | Identificador del usuario en tu aplicación. Nulo si el usuario ya no existe. |
| `name` | `string` | Sí | Nombre visible de la suscripción. |
| `status` | `enum` | Sí | ACTIVE, PAUSED, CANCELLED o COMPLETED. |
| `periodAmountCents` | `integer` | Sí | Importe por periodo, en céntimos. |
| `currency` | `string` | Sí | Divisa en minúsculas. |

Campos propios de cada aviso:

| Aviso | Campos adicionales |
| - | - |
| `SUBSCRIPTION_CREATED` | `frequency`, `chargeMode`, `startDate`, `nextInvoiceDate` |
| `SUBSCRIPTION_UPDATED` | `changes[]` con `field`, `from` y `to` |
| `SUBSCRIPTION_PAUSED` | `pausedAt`, `reason` |
| `SUBSCRIPTION_RESUMED` | `nextInvoiceDate` |
| `SUBSCRIPTION_CANCELLED` | `cancelledAt`, `reason` |
| `SUBSCRIPTION_COMPLETED` | `completedAt`, `invoiceCount` |
| `SUBSCRIPTION_INVOICE_GENERATED` | `invoiceId`, `invoiceNumber`, `amountCents`, `dueDate`, `emailSent` |
| `SUBSCRIPTION_PAYMENT_AUTHORIZATION_PENDING` | `nextInvoiceDate`, `reason` |

### SUBSCRIPTION_INVOICE_GENERATED

```json
{
  "event": "SUBSCRIPTION_INVOICE_GENERATED",
  "timestamp": "2026-11-01T06:00:12.104Z",
  "app": { "id": "app_...", "slug": "mi-aplicacion" },
  "data": {
    "subscriptionId": "cksub1234567890",
    "externalUserId": "user_01HXYZ",
    "name": "Plan profesional mensual",
    "status": "ACTIVE",
    "periodAmountCents": 5345,
    "currency": "eur",
    "invoiceId": "ckinv2234567890",
    "invoiceNumber": "202600097",
    "amountCents": 5345,
    "dueDate": "2026-11-08T00:00:00.000Z",
    "emailSent": true
  },
  "meta": { "schemaVersion": "2026-05" }
}
```

> **Atención: SUBSCRIPTION_PAYMENT_AUTHORIZATION_PENDING es el aviso que hay que atender**
>
> Significa que la suscripción está configurada para cobrarse sola pero no tiene
> autorización vigente. Si nadie hace nada, el periodo se facturará y no se
> cobrará. Pide al usuario que autorice el cobro antes de `nextInvoiceDate`.

## Cobros programados

Cada aviso de este dominio cuenta un paso en la vida de un cobro sin el usuario delante. Este es el orden en que pueden llegar:

**Los avisos de un cobro programado**

1. **Cobro programado** (`CHARGE_SCHEDULED`). Queda fecha para cobrar la factura.
   - **Cobro programado cancelado** (`CHARGE_CANCELLED`, estado final). Se llega desde Cobro programado cuando: Se cancela antes del cobro
2. **Cobro iniciado** (`CHARGE_STARTED`). Se llega desde Cobro programado cuando: Llega la fecha. Empieza un intento de cobro.
   - **Necesita que el usuario se identifique** (`CHARGE_REQUIRES_ACTION`). Se llega desde Cobro iniciado cuando: El banco pide verificar al titular. Lleva a tu usuario al enlace público de la factura.
   - **Cobro fallido** (`CHARGE_FAILED`). Se llega desde Cobro iniciado cuando: La pasarela rechaza el cobro
     - **Reintento de cobro programado** (`CHARGE_RETRY_SCHEDULED`). Se llega desde Cobro fallido cuando: Quedan intentos
     - **Reintentos de cobro agotados** (`CHARGE_RETRIES_EXHAUSTED`, estado final). Se llega desde Cobro fallido cuando: No quedan intentos
3. **Cobro realizado** (`CHARGE_SUCCEEDED`, estado final). Se llega desde Cobro iniciado cuando: La pasarela confirma el cobro. Renueva el servicio de tu usuario.

Otras transiciones:

- Reintento de cobro programado → Cobro iniciado: Llega la fecha del reintento.

_Los cobros que lanza tu aplicación con POST /invoices/:id/charge empiezan en Cobro iniciado: no hay programación detrás._

Los ocho avisos del dominio comparten estos campos en `data`:

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `scheduledChargeId` | `string \| null` | Sí | Cobro programado que originó el aviso. Viaja NULO cuando el cobro lo lanzó la aplicación con POST /invoices/:id/charge, porque en ese caso no hay programación detrás. |
| `invoiceId` | `string` | Sí | Factura que se cobra. |
| `invoiceNumber` | `string` | Sí | Número fiscal de la factura. |
| `externalUserId` | `string \| null` | Sí | Identificador del usuario en tu aplicación. |
| `subscriptionId` | `string \| null` | Sí | Suscripción de la que viene la factura, si viene de una. Nulo en los cobros que lanza la aplicación. |
| `amountCents` | `integer` | Sí | Importe del cobro, en céntimos. |
| `currency` | `string` | Sí | Divisa en minúsculas. |

Campos propios de cada aviso:

| Aviso | Campos adicionales |
| - | - |
| `CHARGE_SCHEDULED` | `scheduledAt`, `cancellableUntil` |
| `CHARGE_STARTED` | `startedAt`, `attemptNumber` |
| `CHARGE_SUCCEEDED` | `paymentIntentId`, `succeededAt`, `invoiceFullyPaid` |
| `CHARGE_FAILED` | `paymentIntentId`, `failedAt`, `attemptNumber`, `failureCode`, `failureMessage`, `willRetry` |
| `CHARGE_REQUIRES_ACTION` | `paymentIntentId`, `expiresAt`, `publicUrl`, `publicLinkAvailable` |
| `CHARGE_RETRY_SCHEDULED` | `nextRetryAt`, `attemptNumber`, `attemptsRemaining` |
| `CHARGE_RETRIES_EXHAUSTED` | `exhaustedAt`, `totalAttempts`, `lastFailureCode`, `subscriptionPaused` |
| `CHARGE_CANCELLED` | `cancelledAt`, `cancelledBy`, `reason` |

### CHARGE_REQUIRES_ACTION

Es el más importante de los dieciséis: **el cobro no se ha producido** y no
prosperará sin que el usuario se identifique ante su banco.

```json
{
  "event": "CHARGE_REQUIRES_ACTION",
  "timestamp": "2026-11-08T08:00:31.902Z",
  "app": { "id": "app_...", "slug": "mi-aplicacion" },
  "data": {
    "scheduledChargeId": "cksch1234567890",
    "invoiceId": "ckinv2234567890",
    "invoiceNumber": "202600097",
    "externalUserId": "user_01HXYZ",
    "subscriptionId": "cksub1234567890",
    "amountCents": 5345,
    "currency": "eur",
    "paymentIntentId": "pi_3QabcDEFghiJKLmn",
    "expiresAt": "2026-11-09T08:00:00.000Z",
    "publicUrl": null,
    "publicLinkAvailable": true
  },
  "meta": { "schemaVersion": "2026-05" }
}
```

La dirección del enlace no llega siempre:

- **Cobro que lanza tu aplicación** con `POST /invoices/:id/charge`: `publicUrl`
  **sí** trae la dirección, porque el emisor acaba de crear el enlace y conoce
  su valor. `scheduledChargeId` y `subscriptionId` llegan nulos.
- **Cobro programado**: `publicUrl` llega **nulo**. De un enlace emitido solo
  se guarda su huella, así que no se puede reconstruir, y generar otro sería
  abrir un acceso público que nadie ha pedido. Llega `publicLinkAvailable: true`
  y tu aplicación pide la dirección con
  `POST /external/v1/invoices/:id/public-link`.

En los cobros programados, `failureCode` de `CHARGE_FAILED` llega nulo y el
motivo viene como texto en `failureMessage`. En los cobros que lanza tu
aplicación, `failureCode` sí trae el código de la pasarela.

### PAYMENT_METHOD_EXPIRING

Avisa de que la tarjeta con la que se cobran las suscripciones o los cobros
programados de un usuario de tu aplicación está a punto de caducar. Llega antes
de que falle el cobro, para que pidas a tiempo una tarjeta nueva con
`POST /external/v1/payment-methods/setup-intent`.

**Avisos de una tarjeta que caduca**

1. **30 días antes**: Primer aviso. Al entrar en la ventana de 30 días. Espera 23 días hasta el siguiente.
2. **7 días antes**: Segundo aviso. Al entrar en la de 7 días.
3. **Fin del mes**: La tarjeta caduca. Una tarjeta 11/2026 vale hasta el 30 de noviembre.

_Se cuenta hasta el último día del mes de caducidad, en el día de la zona horaria de tu organización._

Cómo funciona:

- **Cuándo.** Una tarea diaria, a las 09:40 UTC, revisa las tarjetas. Cada una recibe **un aviso al entrar en la ventana de 30 días y otro al entrar en la de 7**, nunca más. Una tarjeta que ya entra con 7 días o menos (se guardó con poca vida o la tarea no pudo pasar) recibe solo el de 7. Si la red renueva la tarjeta y cambian su mes o su año de caducidad, los avisos vuelven a empezar para la fecha nueva.
- **Qué tarjetas.** Solo las activas que respaldan, para un usuario de tu aplicación, una suscripción activa con cobro automático o un cobro programado que aún se va a intentar. Una tarjeta guardada que no paga nada no avisa.
- **A quién.** Es un aviso dirigido: solo a la aplicación dueña de esas suscripciones y cobros, si está activa y suscrita al evento. Si te suscribes a mitad de una ventana, recibes su aviso en la siguiente pasada diaria. Un aviso por tarjeta y aplicación, con todos los cobros que dependen de ella.
- **Plan.** La organización tiene que estar activa y tener en su plan el cobro automático de facturas, que incluye Finanzas, y Pagos con tarjeta (Stripe).

El cuerpo no dice si es el aviso de 30 días o el de 7: calcúlalo con `expMonth`
y `expYear` si lo necesitas. `brand` y `last4` llegan vacíos si la pasarela no
los dio, y `externalUserId` es el identificador del usuario en tu aplicación.

```json
{
  "event": "PAYMENT_METHOD_EXPIRING",
  "timestamp": "2026-10-31T09:40:02.000Z",
  "app": { "id": "app_...", "slug": "mi-aplicacion" },
  "data": {
    "paymentMethodId": "ckpm1234567890",
    "externalUserId": "user_01HXYZ",
    "brand": "visa",
    "last4": "4242",
    "expMonth": 11,
    "expYear": 2026,
    "affectedSubscriptionIds": ["cksub1234567890"],
    "affectedScheduledChargeIds": []
  },
  "meta": { "schemaVersion": "2026-05" }
}
```

### PAYMENT_AUTHORIZATION_CONSUMED

Una autorización de un solo uso se ha agotado al ejecutarse el cobro que la
respaldaba. Deja de servir: si hace falta cobrar otra vez, el usuario tiene que
autorizarlo de nuevo. Las autorizaciones de alcance `RECURRING` no se consumen.

## Comprueba la firma de cada aviso

Todos los avisos llevan la firma HMAC en dos cabeceras: `X-Utilia-Signature`, con una sola firma, y `X-Utilia-Webhook-Signatures`, con la lista de firmas separadas por comas, la de la clave vigente primero. Fuera de una renovación de la clave las dos llevan la misma. Mientras dura el periodo de transición de una renovación, la lista lleva también la firma de la clave anterior. Acepta el aviso si **alguna** firma de la lista coincide con alguna de las claves que tenga tu servidor. El cálculo y el código de ejemplo están en [Firma HMAC de los avisos](https://os.utilia.ai/docs/developers/webhooks/hmac-signature).

## Buenas prácticas al procesarlos

- Procesa cada aviso una sola vez, usando `X-Utilia-Delivery-Id` para descartar repeticiones.
- Todos los avisos siguen la misma política de reintentos: hasta 5 intentos con esperas de 5, 10, 20 y 40 segundos. Detalle en [Entregas y reintentos de los avisos](https://os.utilia.ai/docs/developers/webhooks/delivery-and-retries).
- En `PAYMENT_AUTHORIZATION_*`, la pareja `mandateId` más `timestamp` identifica un cambio: un mismo mandato no recibe dos `GRANTED` ni dos `REVOKED` con el mismo `timestamp`. Si guardas autorizaciones, usa esa pareja para detectar duplicados antes de aplicar el cambio.

## Siguiente paso

Comprueba la firma de cada aviso antes de procesarlo, como se explica en [Firma HMAC de los avisos](https://os.utilia.ai/docs/developers/webhooks/hmac-signature).
