> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mindosoftware.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Crear o actualizar contacto

> Crea o actualiza un contacto de tu compañía identificándolo por teléfono

## Descripción

Este endpoint hace un **upsert**: identifica al contacto por su número de teléfono dentro de la compañía asociada a tu Company API Key y, si existe lo actualiza, si no lo crea.

Es la operación recomendada para sincronizar contactos desde un sistema externo, porque no necesitás conocer el `id` interno de Mindo ni hacer dos llamadas. Si ya tenés el `id`, usá [actualizar contacto](/api-reference/contactos/actualizar-contacto).

La operación es atómica: si el teléfono, un dato de perfil o cualquier campo personalizado es inválido, no se guarda ningún cambio del request.

<Note>
  El endpoint devuelve `201` cuando creó el contacto y `200` cuando actualizó uno existente. El cuerpo es el mismo en ambos casos: revisá el campo `created` para saber cuál fue.
</Note>

## Cómo se identifica el contacto

El número que enviás en `phone` se normaliza a E.164 antes de buscar, y la búsqueda admite las mismas variantes que el endpoint de consulta: E.164 con o sin `+`, formatos internacionales de Meta y, para Argentina, las variantes con `9` y con `15`.

Todas estas llamadas resuelven al mismo contacto:

```json theme={null}
{ "phone": "+5491112345678" }
{ "phone": "5491112345678" }
{ "phone": "+54 9 11 1234-5678" }
{ "phone": "541115123 45678" }
```

Un contacto creado por este endpoint se guarda con el número ya normalizado a E.164, para que las siguientes llamadas lo encuentren de forma unívoca.

<Warning>
  Si el número no normaliza a un E.164 válido, la llamada devuelve `400` y no se crea nada. Enviá el teléfono con código de país; no se asume ninguna región por vos salvo Argentina.
</Warning>

## Lista de contactos destino

Al **crear**, el contacto va a la lista por defecto de tu compañía. Si querés otra, enviá `contact_list_id`.

Al **actualizar**, el contacto permanece en su lista actual. `contact_list_id` sólo se acepta si coincide con esa lista; enviar una distinta devuelve `400` en vez de mover el contacto. Este endpoint no migra contactos entre listas.

<Note>
  Si tu compañía no tiene una lista marcada como predeterminada, `contact_list_id` pasa a ser obligatorio al crear.
</Note>

## Header de autenticación

<ParamField header="X-API-Key" type="string" required>
  Una Company API Key activa. No se admiten API keys legacy, JWT ni keys globales.
</ParamField>

<ParamField header="Content-Type" type="string" required>
  Debe ser `application/json`.
</ParamField>

## Campos del body

<ParamField body="phone" type="string" required>
  Número de teléfono del contacto. Es la identidad de la operación: determina si se crea o se actualiza. Debe normalizar a un E.164 válido.
</ParamField>

<ParamField body="contact_list_id" type="integer">
  Lista de contactos destino, sólo al crear. Si lo omitís, se usa la lista por defecto de tu compañía. Debe pertenecer a tu compañía.
</ParamField>

<ParamField body="full_name" type="string | null">
  Nombre completo del contacto.
</ParamField>

<ParamField body="email" type="string | null">
  Email del contacto. Puede ser una cadena vacía. Si se informa, debe ser válido.
</ParamField>

<ParamField body="source" type="string" default="api">
  Origen del contacto. Valores válidos: `manual`, `csv_import`, `organic`, `whatsapp_sync`, `instagram_sync`, `google_contacts`, `group`, `api` y `other`. Si lo omitís al crear, queda en `api`.
</ParamField>

<ParamField body="source_detail" type="string | null">
  Detalle del origen. Puede ser una cadena vacía.
</ParamField>

<ParamField body="profile_picture" type="string (URL) | null">
  URL de la foto de perfil. Puede ser una cadena vacía.
</ParamField>

<ParamField body="custom_fields" type="array">
  Campos personalizados que querés crear o actualizar.

  <Expandable title="Propiedades de cada custom field">
    <ParamField body="custom_field_id" type="integer">
      ID del campo personalizado. Debe pertenecer a la lista del contacto. Excluyente con `key`: enviá exactamente uno de los dos.
    </ParamField>

    <ParamField body="key" type="string">
      Clave del campo personalizado dentro de la lista. Excluyente con `custom_field_id`: enviá exactamente uno de los dos.
    </ParamField>

    <ParamField body="value" type="array" required>
      Valor del campo. Para valores únicos, enviá un solo elemento. Para `multiselector`, podés enviar varios. `[]` representa un campo sin valor.
    </ParamField>

    <ParamField body="expires_at" type="string (ISO 8601) | null">
      Vencimiento opcional. Enviá `null` para quitarlo. Si lo omitís, se conserva el vencimiento del valor activo.
    </ParamField>
  </Expandable>
</ParamField>

No podés modificar el `id`, la lista de un contacto existente, identificadores de WhatsApp o Instagram, fechas ni flags internos. Enviar cualquiera de estos campos, o cualquier campo fuera de la lista de arriba, devuelve `400`.

El teléfono tampoco es editable: es la clave con la que se identifica al contacto, no un dato que se pueda cambiar por esta vía.

## Identificar campos personalizados

Cada elemento de `custom_fields` debe traer `value` y **exactamente uno** de `custom_field_id` o `key`. Enviar los dos, o ninguno, devuelve `400`.

Recomendamos `key`: es estable, legible y no depende de IDs internos, lo que hace el payload portable entre entornos.

```json theme={null}
{
  "custom_fields": [
    { "key": "estado", "value": ["calificado"] },
    { "custom_field_id": 61, "value": ["30111222"] }
  ]
}
```

Podés obtener ambos identificadores del arreglo `customFields` que devuelve esta misma llamada: el `id` y la `key` de cada elemento.

Reglas de validación:

* El campo debe pertenecer a la lista del contacto.
* No repitas el mismo campo en un request, ni siquiera identificándolo de las dos maneras.
* Se valida el tipo y las opciones configuradas para cada campo. Los tipos disponibles son `string`, `text`, `textarea`, `int`, `float`, `email`, `url`, `date`, `datetime`, `time`, `selector` y `multiselector`.
* Los campos que pertenecen a un grupo (formularios repetibles, como turnos) no se pueden asignar por esta API: requieren una instancia de grupo.

## Semántica de actualización

La actualización es un **merge parcial**. Enviá solamente lo que querés cambiar:

* Los campos de perfil ausentes del payload conservan su valor.
* Los campos personalizados que no enviás conservan su valor activo.
* Un campo personalizado cuyo valor y vencimiento no cambian no genera una versión nueva.

Cada campo personalizado que sí cambia crea una versión nueva y conserva el historial. La respuesta enumera esos cambios en `versionedCustomFields`.

## Idempotencia y concurrencia

La operación es idempotente respecto del teléfono: repetir el mismo request no crea contactos duplicados ni versiones nuevas de campos personalizados que no cambiaron.

Dos llamadas concurrentes con el mismo número se serializan a nivel base de datos: la segunda espera a la primera y actualiza el contacto que ésta creó, en lugar de crear un duplicado.

## Convención de nombres de campos

En el request, los campos de primer nivel se aceptan tanto en `snake_case` (`full_name`) como en `camelCase` (`fullName`).

<Warning>
  La excepción es el contenido de `custom_fields`: ahí las claves se leen literalmente. Enviá `custom_field_id` y `expires_at` en `snake_case`. Un `customFieldId` se ignora y la llamada falla con `400`.
</Warning>

En la respuesta, las claves de primer nivel se devuelven en `camelCase` (`fullName`, `contactListId`, `customFields`, `versionedCustomFields`, `customFieldId`), mientras que dentro de cada elemento de `customFields` se conservan en `snake_case` (`field_type`, `expires_at`).

<RequestExample>
  ```bash cURL theme={null}
  curl -X POST 'https://api.mindosoftware.com/api/v1/external/contacts/upsert/' \
    -H 'X-API-Key: mindo_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \
    -H 'Content-Type: application/json' \
    -d '{
      "phone": "+5491112345678",
      "full_name": "Ana Pérez",
      "email": "ana.perez@example.com",
      "source": "api",
      "source_detail": "Sincronización ERP",
      "custom_fields": [
        {
          "key": "estado",
          "value": ["calificado"]
        },
        {
          "custom_field_id": 61,
          "value": ["2026-07-20"],
          "expires_at": "2026-12-31T23:59:59-03:00"
        }
      ]
    }'
  ```

  ```python Python theme={null}
  import requests

  response = requests.post(
      "https://api.mindosoftware.com/api/v1/external/contacts/upsert/",
      headers={
          "X-API-Key": "mindo_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
          "Content-Type": "application/json",
      },
      json={
          "phone": "+5491112345678",
          "full_name": "Ana Pérez",
          "email": "ana.perez@example.com",
          "source": "api",
          "source_detail": "Sincronización ERP",
          "custom_fields": [
              {"key": "estado", "value": ["calificado"]},
              {
                  "custom_field_id": 61,
                  "value": ["2026-07-20"],
                  "expires_at": "2026-12-31T23:59:59-03:00",
              },
          ],
      },
  )

  data = response.json()
  print("Creado" if data["created"] else "Actualizado", data["contact"]["id"])
  ```

  ```javascript JavaScript theme={null}
  const response = await fetch(
    "https://api.mindosoftware.com/api/v1/external/contacts/upsert/",
    {
      method: "POST",
      headers: {
        "X-API-Key": "mindo_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        phone: "+5491112345678",
        full_name: "Ana Pérez",
        email: "ana.perez@example.com",
        source: "api",
        source_detail: "Sincronización ERP",
        custom_fields: [
          { key: "estado", value: ["calificado"] },
          {
            custom_field_id: 61,
            value: ["2026-07-20"],
            expires_at: "2026-12-31T23:59:59-03:00",
          },
        ],
      }),
    }
  );

  const data = await response.json();
  console.log(data.created ? "Creado" : "Actualizado", data.contact.id);
  ```
</RequestExample>

## Respuesta (201 Created / 200 OK)

<ResponseField name="created" type="boolean">
  `true` si se creó el contacto en esta llamada, `false` si se actualizó uno existente. Acompaña al código de estado: `201` y `200` respectivamente.
</ResponseField>

<ResponseField name="contact" type="object">
  Contacto resultante, con todos los campos personalizados configurados en su lista.

  <Expandable title="Propiedades de contact">
    <ResponseField name="id" type="integer">
      ID interno del contacto.
    </ResponseField>

    <ResponseField name="fullName" type="string | null">
      Nombre completo del contacto.
    </ResponseField>

    <ResponseField name="email" type="string | null">
      Email del contacto.
    </ResponseField>

    <ResponseField name="phone" type="string">
      Número de teléfono del contacto, normalizado a E.164.
    </ResponseField>

    <ResponseField name="source" type="string">
      Origen del contacto.
    </ResponseField>

    <ResponseField name="sourceDetail" type="string | null">
      Detalle del origen.
    </ResponseField>

    <ResponseField name="contactListId" type="integer">
      ID de la lista de contactos a la que pertenece.
    </ResponseField>

    <ResponseField name="profilePicture" type="string | null">
      URL de la foto de perfil.
    </ResponseField>

    <ResponseField name="customFields" type="array">
      Todos los campos personalizados configurados para la lista del contacto, incluso los que aún no tienen valor.

      <Expandable title="Propiedades de cada custom field">
        <ResponseField name="id" type="integer">
          ID del campo personalizado. Usalo como `custom_field_id` en las próximas llamadas.
        </ResponseField>

        <ResponseField name="key" type="string">
          Clave interna del campo. Usala como `key` en las próximas llamadas.
        </ResponseField>

        <ResponseField name="name" type="string">
          Nombre visible del campo.
        </ResponseField>

        <ResponseField name="field_type" type="string">
          Tipo del campo.
        </ResponseField>

        <ResponseField name="options" type="array | null">
          Opciones configuradas para campos de selección.
        </ResponseField>

        <ResponseField name="required" type="boolean">
          Indica si el campo es obligatorio.
        </ResponseField>

        <ResponseField name="value" type="array | null">
          Valor activo. Es `null` si aún no tiene valor o si venció.
        </ResponseField>

        <ResponseField name="version" type="integer | null">
          Versión del valor activo.
        </ResponseField>

        <ResponseField name="expires_at" type="string (ISO 8601) | null">
          Fecha y hora de vencimiento del valor activo.
        </ResponseField>
      </Expandable>
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="versionedCustomFields" type="array">
  Campos personalizados que generaron una versión nueva en esta llamada. Puede ser una lista vacía si ningún valor ni vencimiento cambió.

  <Expandable title="Propiedades de cada campo versionado">
    <ResponseField name="customFieldId" type="integer">
      ID del campo personalizado.
    </ResponseField>

    <ResponseField name="key" type="string">
      Clave del campo personalizado.
    </ResponseField>

    <ResponseField name="version" type="integer">
      Número de la nueva versión.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseExample>
  ```json 201 - Contacto creado theme={null}
  {
    "created": true,
    "contact": {
      "id": 123,
      "fullName": "Ana Pérez",
      "email": "ana.perez@example.com",
      "phone": "+5491112345678",
      "source": "api",
      "sourceDetail": "Sincronización ERP",
      "contactListId": 5,
      "profilePicture": null,
      "customFields": [
        {
          "id": 45,
          "key": "estado",
          "name": "Estado",
          "field_type": "selector",
          "options": ["nuevo", "calificado"],
          "required": false,
          "value": ["calificado"],
          "version": 1,
          "expires_at": null
        },
        {
          "id": 61,
          "key": "fecha_de_alta",
          "name": "Fecha de alta",
          "field_type": "date",
          "options": null,
          "required": false,
          "value": ["2026-07-20"],
          "version": 1,
          "expires_at": "2026-12-31T23:59:59-03:00"
        }
      ]
    },
    "versionedCustomFields": [
      { "customFieldId": 45, "key": "estado", "version": 1 },
      { "customFieldId": 61, "key": "fecha_de_alta", "version": 1 }
    ]
  }
  ```

  ```json 200 - Contacto actualizado theme={null}
  {
    "created": false,
    "contact": {
      "id": 123,
      "fullName": "Ana Pérez",
      "email": "ana.perez@example.com",
      "phone": "+5491112345678",
      "source": "api",
      "sourceDetail": "Sincronización ERP",
      "contactListId": 5,
      "profilePicture": null,
      "customFields": [
        {
          "id": 45,
          "key": "estado",
          "name": "Estado",
          "field_type": "selector",
          "options": ["nuevo", "calificado"],
          "required": false,
          "value": ["calificado"],
          "version": 3,
          "expires_at": null
        }
      ]
    },
    "versionedCustomFields": [
      { "customFieldId": 45, "key": "estado", "version": 3 }
    ]
  }
  ```

  ```json 200 - Lista sin campos personalizados theme={null}
  {
    "created": false,
    "contact": {
      "id": 123,
      "fullName": "Ana Pérez",
      "email": "ana.perez@example.com",
      "phone": "+5491112345678",
      "source": "api",
      "sourceDetail": "Sincronización ERP",
      "contactListId": 5,
      "profilePicture": null,
      "customFields": []
    },
    "versionedCustomFields": []
  }
  ```
</ResponseExample>

## Errores

| Estado | Situación                                                                                                                                                                                                                                                                                                                                             |
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | `phone` ausente o no normalizable a E.164; campo protegido o no permitido; campo personalizado inválido, inexistente, duplicado, agrupado o mal identificado; `expires_at` mal formado; `contact_list_id` inexistente, de otra compañía o distinto al del contacto existente; la compañía no tiene lista por defecto y no se envió `contact_list_id`. |
| `401`  | Falta una Company API Key válida, activa y no vencida.                                                                                                                                                                                                                                                                                                |
| `409`  | Ya hay más de un contacto de tu compañía con el número enviado.                                                                                                                                                                                                                                                                                       |

<Warning>
  Ante un `409`, resolvé la duplicidad de contactos antes de continuar. La API no actualiza ni selecciona un contacto de forma arbitraria.
</Warning>

### Ejemplos de error

<CodeGroup>
  ```json 400 - Teléfono inválido theme={null}
  {
    "phone": [
      "Se requiere un teléfono válido normalizable a E.164 (ej. +5491112345678)."
    ]
  }
  ```

  ```json 400 - Campo protegido theme={null}
  {
    "error": "No se pueden editar campos protegidos.",
    "fields": ["is_testing"]
  }
  ```

  ```json 400 - Campo no permitido theme={null}
  {
    "error": "El payload contiene campos no permitidos.",
    "fields": ["nickname"]
  }
  ```

  ```json 400 - Valor inválido theme={null}
  {
    "customFields": {
      "0": ["El valor del campo 'Estado' no es una opción válida."]
    }
  }
  ```

  ```json 400 - Identificación ambigua theme={null}
  {
    "customFields": {
      "0": ["Enviá exactamente uno de 'custom_field_id' o 'key'."]
    }
  }
  ```

  ```json 400 - Lista distinta theme={null}
  {
    "contactListId": [
      "El contacto ya existe en otra lista; esta API no mueve contactos entre listas."
    ]
  }
  ```

  ```json 409 - Contacto duplicado theme={null}
  {
    "error": "Hay más de un contacto con ese teléfono en esta organización."
  }
  ```
</CodeGroup>

Las claves de error de campos personalizados son la posición del elemento dentro del arreglo `custom_fields` que enviaste, empezando en `"0"`.

## Recomendaciones de integración

<Steps>
  <Step title="Usá el upsert como operación por defecto">
    Para sincronizar contactos desde tu sistema, este endpoint reemplaza al par consulta + actualización. Es idempotente por teléfono y no depende de IDs internos de Mindo.
  </Step>

  <Step title="Identificá los campos personalizados por key">
    La `key` es estable y legible, y hace que el mismo payload funcione en cualquier entorno. Si preferís los IDs numéricos, guardalos por compañía y lista en vez de deducirlos del nombre del campo.
  </Step>

  <Step title="Enviá sólo lo que cambió">
    El merge parcial evita versiones innecesarias en el historial de campos personalizados y hace las llamadas más livianas.
  </Step>

  <Step title="Registrá el resultado, no sólo el código">
    Guardá `created` y `versionedCustomFields` para auditar qué hizo realmente cada sincronización.
  </Step>

  <Step title="Tratá el 409 como una alerta operativa">
    Un `409` significa que tu compañía tiene contactos duplicados en Mindo. No es un error transitorio: reintentarlo no lo resuelve.
  </Step>
</Steps>

<Warning>
  Guardá tu API Key como secreto. No la incluyas en código de cliente, repositorios ni logs, y filtrá el header `X-API-Key` de cualquier traza que registres.
</Warning>

## Siguientes pasos

<CardGroup cols={2}>
  <Card title="Buscar contacto" icon="magnifying-glass" href="/api-reference/contactos/buscar-contacto">
    Consultá un contacto por teléfono y revisá si respondió y si la ventana de sesión está activa.
  </Card>

  <Card title="Actualizar contacto" icon="pen-to-square" href="/api-reference/contactos/actualizar-contacto">
    Actualizá un contacto cuando ya conocés su ID interno.
  </Card>
</CardGroup>
