# Contactos del CRM

API externa /external/v1/contacts para herramientas de envío de correo: listado, sincronización por cambios, bajas, reactivaciones y avisos CONTACT_*.

Con `/external/v1/contacts` tu herramienta de envío de correo
(Instantly, Smartlead, Apollo, Mailerlite y similares) lee los contactos
del CRM de UTILIA OS y registra bajas y reactivaciones, de forma
controlada y auditada.

## Autenticación

Todas las peticiones llevan la clave de API de la aplicación:

```
X-Api-Key: <api_key_de_la_app>
```

La clave se obtiene al dar de alta la aplicación en
[Ajustes › Creadas por tu empresa](https://os.utilia.ai/dashboard/settings/custom-apps). UTILIA OS no
guarda la clave en claro (solo su huella) y puedes rotarla desde la misma ficha sin reescribir tu
aplicación. Además del permiso de la API, el plan de tu organización
tiene que incluir el aplicativo **CRM**; si no, la respuesta es
`403 MODULE_NOT_ENABLED`.

## Permisos de la API (scopes)

| Scope | Qué permite |
| - | - |
| `crm:contacts:read` | Lectura básica: listar, buscar, ver el detalle y sincronizar por cambios. |
| `crm:contacts:write` | Registrar bajas y reactivaciones. |
| `crm:contacts:read:opted-out` | Listar contactos con `canReceiveEmails=false` o con estado `INACTIVE` o `ARCHIVED`, y ver el detalle de cualquiera de ellos. |
| `crm:contacts:read:secondary-email` | Ver `secondaryEmail` cuando pides `?include=secondaryEmail`. |
| `crm:contacts:read:notes` | Ver `notes` cuando pides `?include=notes`. |
| `crm:contacts:read:birthday` | Ver `birthDay` y `birthMonth` cuando pides `?include=birthday`. |
| `crm:clients:read` | Ver `primaryClient` y la lista completa `clientIds`. `primaryClientId` viaja siempre. |

Sin el permiso necesario, la respuesta es `403` con
`errorCode: INSUFFICIENT_SCOPE`, salvo al leer el detalle de un contacto
dado de baja o inactivo, que responde `404` (ver [Consultar un contacto](#consultar-un-contacto)).
No hay periodo de gracia: el permiso
se exige desde la primera llamada en cualquier aplicación. Los permisos
se conceden en la ficha de la aplicación; cómo hacerlo, y el catálogo
de los 60, está en [Permisos de la API](https://os.utilia.ai/docs/developers/api/scopes).

## Endpoints

La clave de API trabaja a nivel de **organización**: lee cualquier
contacto de tu organización, sin el alcance de filas que tenga en el CRM
la persona responsable de la aplicación. Cada lectura y cada cambio
queda en el registro de auditoría a nombre de la aplicación.

Forma de las respuestas:

- **Listados**: `{ data, pagination: { page, limit, total, totalPages, hasNextPage, hasPreviousPage } }`.
- **Bajas y reactivaciones**: `{ contact, changed, recordedAt }`.
- **Sincronización**: `{ data, nextCursor, hasMore }`.

Un contacto tiene esta forma (`secondaryEmail`, `notes`, `birthDay` y
`birthMonth` solo aparecen si los pides con `include` y tienes su
permiso; `primaryClient` y `clientIds` completos, con `crm:clients:read`):

```json
{
  "id": "con-7",
  "organizationId": "org-1",
  "firstName": "Ana",
  "lastName": "Pérez",
  "fullName": "Ana Pérez",
  "email": "ana@acme.example",
  "phone": "+34 928 000 000",
  "position": "Directora de compras",
  "isPrimary": true,
  "canReceiveEmails": true,
  "canReceiveSMS": false,
  "status": "ACTIVE",
  "clientIds": ["cli-42"],
  "primaryClientId": "cli-42",
  "createdAt": "2026-10-01T09:00:00.000Z",
  "updatedAt": "2026-10-20T11:30:00.000Z",
  "lastContactedAt": null
}
```

### Listar y buscar

**`GET /api/external/v1/contacts`**

Listado paginado con los filtros de campaña por defecto: status=ACTIVE y canReceiveEmails=true. Permiso crm:contacts:read; pedir otros estados o canReceiveEmails=false exige crm:contacts:read:opted-out.

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `page` | `number` | No | Página, desde 1. |
| `limit` | `number` | No | Contactos por página, de 1 a 100. Por defecto, 25. |
| `clientId` | `string` | No | Solo los contactos vinculados a ese cliente. |
| `status` | `enum[]` | No | ACTIVE, INACTIVE o ARCHIVED, separados por comas. Por defecto, ACTIVE. |
| `canReceiveEmails` | `boolean` | No | Por defecto, true. |
| `updatedSince` | `string` | No | Solo los cambiados desde esa fecha (ISO 8601). |
| `sortBy` | `enum` | No | updatedAt (por defecto), createdAt, fullName o email. |
| `sortOrder` | `enum` | No | desc (por defecto) o asc. |
| `include` | `string` | No | secondaryEmail, notes o birthday, separados por comas. Cada uno exige su permiso. |

**`GET /api/external/v1/contacts/search`**

Búsqueda libre (q, hasta 200 caracteres) por nombre, apellidos y correo, hasta 100 resultados (25 por defecto). Permiso crm:contacts:read. Admite include.

**`GET /api/external/v1/contacts/by-email`**

Búsqueda exacta por correo (email, hasta 320 caracteres), con tiempo de respuesta constante. Permiso crm:contacts:read. 404 EMAIL_NOT_INDEXED si ningún contacto activo lo tiene.

### Consultar un contacto

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

Detalle de un contacto. Permiso crm:contacts:read; ?include=secondaryEmail, notes o birthday exige el permiso de cada campo (si no, 403 INSUFFICIENT_SCOPE). 404 CONTACT_NOT_FOUND si no existe, es de otra organización o está dado de baja o inactivo y la aplicación no tiene crm:contacts:read:opted-out.

Parámetro de ruta: `id`.

Un contacto dado de baja o inactivo que tu aplicación no puede leer responde
exactamente igual que uno que no existe, con el mismo cuerpo: un 403 que
nombrara `crm:contacts:read:opted-out` confirmaría que esa persona existe y se
ha dado de baja de los correos. Si esperabas encontrarlo, pide ese permiso a un
administrador.

### Sincronizar por cambios

**`GET /api/external/v1/contacts/sync`**

Lo que ha cambiado, de lo más antiguo a lo más reciente, con cursor compuesto (updatedAt, id). Permiso crm:contacts:read. Parámetros: updatedAfter (ISO 8601), cursor y limit (de 1 a 200, 100 por defecto).

Funciona igual que la de clientes: lo explica
[Sincronizar por cambios](https://os.utilia.ai/docs/developers/api/clients#sincronizar-por-cambios).

### Bajas y reactivaciones

Dar de baja y volver a dar de alta no piden lo mismo: reactivar exige
probar que la persona dio su consentimiento.

**`POST /api/external/v1/contacts/:id/unsubscribe`**

Registra la baja del envío de correos: canReceiveEmails pasa a false, se anota en la ficha del contacto y se emite CONTACT_OPTED_OUT. Permiso crm:contacts:write. 409 OPT_OUT_ALREADY_RECORDED si ya estaba dado de baja.

Parámetro de ruta: `id`.

**`POST /api/external/v1/contacts/:id/resubscribe`**

Registra la reactivación con prueba del consentimiento: canReceiveEmails vuelve a true y se emite CONTACT_OPTED_IN. Permiso crm:contacts:write. 422 RESUBSCRIBE_REQUIRES_PROOF sin proof; 409 CONTACT_ALREADY_OPTED_IN si ya podía recibir correos.

Parámetro de ruta: `id`.

**Baja y reactivación de un contacto**

#### Dar de baja (POST /contacts/:id/unsubscribe)

Cuerpo: `reason` (hasta 500 caracteres) y `source`, que admite `external_app`, `bounce`, `complaint` o `manual`.

#### Reactivar (POST /contacts/:id/resubscribe)

Cuerpo: `reason` (hasta 500 caracteres) y `proof`, obligatorio, con `type` (`web_form`, `double_opt_in`, `imported` o `verbal`), `capturedAt` y, si la tienes, `sourceUrl` (hasta 500 caracteres).

Pedir el estado que el contacto ya tiene no registra nada nuevo: responde
`409` con `OPT_OUT_ALREADY_RECORDED` o `CONTACT_ALREADY_OPTED_IN`, para
que tu herramienta pueda decir «ya estaba dado de baja». La baja y la
reactivación quedan anotadas a nombre de la persona responsable de la
aplicación: sin una responsable activa, la respuesta es
`403 APP_RESPONSIBLE_INACTIVE`.

Los campos personalizados del CRM no salen por esta API. Puedes
leerlos y escribirlos por MCP: lo explica el apartado «Campos
personalizados del CRM» de la [referencia MCP](https://os.utilia.ai/docs/developers/mcp/reference).

## Avisos a tu servidor de contacto

Cinco eventos a los que puedes suscribir la aplicación desde su ficha:

| Evento | Cuándo se envía |
| - | - |
| `CONTACT_CREATED` | Se ha creado un contacto. |
| `CONTACT_UPDATED` | Han cambiado campos relevantes. Incluye `changes[]`. |
| `CONTACT_DELETED` | Baja lógica del contacto (`status=INACTIVE`). |
| `CONTACT_OPTED_OUT` | `canReceiveEmails` pasa a `false`. |
| `CONTACT_OPTED_IN` | `canReceiveEmails` pasa a `true` con prueba del consentimiento. |

Si la baja la registró tu propia aplicación, UTILIA OS no le envía el
aviso resultante; el resto de aplicaciones suscritas sí lo reciben.

> **Atención: La firma se calcula sobre timestamp.cuerpo, no sobre el cuerpo solo**
>
> Cada aviso lleva `X-Utilia-Signature` y `X-Utilia-Timestamp`. La firma
> es un HMAC-SHA256 de la cadena `` `${timestamp}.${rawBody}` ``, con
> `timestamp` igual al valor de la cabecera (milisegundos Unix) y
> `rawBody` el cuerpo crudo tal como llega. Si calculas el HMAC solo sobre
> el cuerpo, no coincidirá nunca. El código de verificación completo está
> en [Firma HMAC](https://os.utilia.ai/docs/developers/webhooks/hmac-signature).

## Errores propios de estos endpoints

| Código | HTTP | Significado |
| - | - | - |
| `INSUFFICIENT_SCOPE` | 403 | A la aplicación le falta el permiso de la API necesario. |
| `CONTACT_NOT_FOUND` | 404 | El contacto no existe, es de otra organización o está dado de baja o inactivo y falta `crm:contacts:read:opted-out`. |
| `EMAIL_NOT_INDEXED` | 404 | No hay ningún contacto activo con ese correo. |
| `OPT_OUT_ALREADY_RECORDED` | 409 | El contacto ya estaba dado de baja. |
| `CONTACT_ALREADY_OPTED_IN` | 409 | El contacto ya podía recibir correos. |
| `RESUBSCRIBE_REQUIRES_PROOF` | 422 | Falta `proof` en `/resubscribe`. |

## Límites de uso

Se combinan dos límites: el general de la clave de la aplicación (1.000
peticiones por hora) y uno por endpoint:

| Endpoint | Límite por defecto |
| - | - |
| Lectura general | 600 peticiones por hora |
| `GET /external/v1/contacts/sync` | 60 peticiones por hora |
| `GET /external/v1/contacts/by-email` | 300 peticiones por hora |

Al superar un límite, la API responde `429` con la cabecera
`Retry-After`, que indica los segundos que faltan para que se renueve.

## Con los SDK oficiales

- **JavaScript** (`@utilia-os/sdk-js`): `sdk.externalContacts` con
  siete métodos: `list`, `get`, `search`, `byEmail`, `sync`,
  `unsubscribe` y `resubscribe`.
- **Python** (`utilia-sdk`): `sdk.external_contacts`, en versión
  asíncrona y síncrona.

Lo detalla la sección [SDK](https://os.utilia.ai/docs/developers/sdk).

## Datos personales

Cada baja y reactivación queda en el registro de auditoría con la
dirección IP y el navegador o cliente que la hizo; los campos sensibles
solo salen con su permiso propio; las reactivaciones guardan la prueba
del consentimiento; y cada organización solo ve sus propios contactos.

## Siguiente paso

Tras la sincronización inicial, suscribe la aplicación a los eventos
`CONTACT_*` desde su ficha para mantener la lista al día sin consultar
la API cada poco. Para identificar a los usuarios de tu aplicación,
consulta [Usuarios externos](https://os.utilia.ai/docs/developers/api/users); la
lista completa de eventos está en
[Contenido de los avisos](https://os.utilia.ai/docs/developers/webhooks/events).
