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

# Ingresar clientes por lotes

> Envía hasta 500 contactos por solicitud a una etapa de un pipeline de Salesflow: resultado por fila, actualizaciones que no pisan datos y reintentos seguros

Usa el ingreso por lotes para sincronizar contactos desde un CRM, una exportación de hoja de cálculo, n8n o tu propio backend hacia un pipeline de Salesflow, muchos a la vez:

* **Batch ingest clients into a pipeline** — `POST /api/v2/public/salesflow/ingest/{input_node_uuid}/batch`
* **List pipelines** — `GET /api/v2/public/salesflow/pipelines`
* **List API input nodes of a pipeline** — `GET /api/v2/public/salesflow/pipelines/{pipeline_id}/input-nodes`
* **List stages in a pipeline** — `GET /api/v2/public/salesflow/pipelines/{pipeline_id}/stages`

Para un contacto a la vez — un formulario web, un webhook individual — usa **Ingest a client into a pipeline**. Ambos endpoints se dirigen a lo mismo: un **nodo de entrada API**.

<Note>
  **El nodo de entrada es dueño del destino.** Lo creas una vez en el editor de Salesflow (abre el pipeline → nodos de entrada → tipo **API**). Decide en qué etapa caen los contactos nuevos, cómo se asignan a tu equipo y el valor por defecto del lead. El endpoint por lotes añade lo que necesita una sincronización masiva: coincidencia de contactos, una política de duplicados, campos personalizados, etiquetas, un modo de prueba y un modo todo-o-nada.
</Note>

## Conceptos

| Cosa                      | Qué es                                                                                                                                                                                                                   |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Pipeline**              | Un tablero de Salesflow. Se identifica por su `id` numérico.                                                                                                                                                             |
| **Etapa**                 | Una columna de ese tablero. El nodo de entrada apunta a una; puedes cambiarla por solicitud con `stage_id`, siempre que pertenezca al mismo pipeline.                                                                    |
| **Nodo de entrada API**   | El punto de entrada al que envías contactos. Se identifica por su `input_node_uuid`. Debe estar activo y ser de tipo **API**.                                                                                            |
| **Cliente**               | Un contacto de tu cuenta de Dialtu. Se busca por `client_id`, luego por teléfono y luego por correo, en ese orden.                                                                                                       |
| **Campos personalizados** | El objeto libre `data` de un cliente: las mismas claves que ves en la pestaña *Datos* del cliente en el panel y que puedes usar en condiciones de Salesflow (`data.<clave>`) y como variables del prompt de los agentes. |

## De la clave de API al ingreso en tres llamadas

<Steps>
  <Step title="Encuentra el pipeline">
    ```bash theme={null}
    curl https://api.dialtu.com/api/v2/public/salesflow/pipelines?status=active \
      -H "Authorization: Bearer pk_xxxxxxxx"
    ```

    ```json theme={null}
    {
      "status": "success",
      "data": [
        { "id": 42, "name": "Inbound Leads", "description": null, "status": "active",
          "stage_count": 5, "client_count": 318, "created_at": "2026-05-02T14:11:09Z" }
      ],
      "count": 1, "limit": 20, "offset": 0
    }
    ```
  </Step>

  <Step title="Encuentra el nodo y copia su ruta de ingreso">
    ```bash theme={null}
    curl https://api.dialtu.com/api/v2/public/salesflow/pipelines/42/input-nodes \
      -H "Authorization: Bearer pk_xxxxxxxx"
    ```

    ```json theme={null}
    {
      "status": "success",
      "data": [
        {
          "input_node_uuid": "3f9c2a7e-5b1d-4e8a-9c0f-7d2b6a4e1c58",
          "name": "CRM sync",
          "is_active": true,
          "pipeline_id": 42,
          "target_stage_id": 210,
          "target_stage_name": "New lead",
          "default_lead_value": 150.0,
          "ingest_path": "/api/v2/public/salesflow/ingest/3f9c2a7e-5b1d-4e8a-9c0f-7d2b6a4e1c58/batch"
        }
      ],
      "count": 1
    }
    ```

    Solo se listan los nodos de tipo **API**, los únicos a los que se pueden dirigir los endpoints de ingreso. Si la lista está vacía, crea uno en el editor de Salesflow.
  </Step>

  <Step title="(Opcional) elige otra etapa">
    **List stages in a pipeline** te da cada `stage_id`. Envía uno como `stage_id` en el lote para que los contactos caigan en una etapa distinta de la del nodo.
  </Step>
</Steps>

## Un lote de principio a fin

```bash theme={null}
curl -X POST https://api.dialtu.com/api/v2/public/salesflow/ingest/3f9c2a7e-5b1d-4e8a-9c0f-7d2b6a4e1c58/batch \
  -H "Authorization: Bearer pk_xxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "clients": [
      {
        "external_id": "crm-1001",
        "first_name": "Ana", "last_name": "García",
        "phone_number": "+525512345678",
        "email": "ana@example.com",
        "company": "ACME",
        "data": { "source": "landing-page", "plan": "gold" },
        "tags": [ { "name": "spring-campaign" } ]
      },
      { "external_id": "crm-1002", "client_id": 8874, "data": { "plan": "silver" } },
      { "external_id": "crm-1003", "first_name": "Sin identidad" }
    ],
    "on_duplicate": "update",
    "defaults": {
      "area_code": "52",
      "timezone": "America/Mexico_City",
      "tags": [ { "name": "imported" } ],
      "data": { "source": "crm-sync" }
    }
  }'
```

```json theme={null}
{
  "status": "success",
  "dry_run": false,
  "input_node_uuid": "3f9c2a7e-5b1d-4e8a-9c0f-7d2b6a4e1c58",
  "pipeline_id": 42,
  "stage_id": 210,
  "summary": {
    "total": 3, "created": 1, "updated": 1, "skipped": 0, "failed": 1,
    "pipeline_added": 2, "pipeline_already_active": 0, "pipeline_failed": 0
  },
  "results": [
    { "index": 0, "external_id": "crm-1001", "status": "created", "client_id": 9301, "matched_by": null,
      "errors": [], "pipeline": { "status": "added", "client_state_id": 55130 }, "existing_client": null },
    { "index": 1, "external_id": "crm-1002", "status": "updated", "client_id": 8874, "matched_by": "id",
      "errors": [], "pipeline": { "status": "already_active", "client_state_id": 54019 }, "existing_client": null },
    { "index": 2, "external_id": "crm-1003", "status": "failed", "client_id": null, "matched_by": null,
      "errors": [ { "field": "phone_number", "code": "required",
                    "message": "Each row needs a client_id, a phone_number or an email",
                    "existing_client_id": null, "conflicting_client_id": null, "duplicate_of_index": null } ],
      "pipeline": { "status": "not_attempted", "client_state_id": null }, "existing_client": null }
  ]
}
```

Las filas tienen éxito o fallan **de forma individual**. Un `200` significa que la solicitud era válida y se procesó: lee `summary` para los totales y `results[]` para lo que pasó con cada fila, en el orden de la solicitud. `external_id` se devuelve tal cual, así que correlaciona por él y no por la posición en el arreglo.

| `status` de la fila | Significado                                                                                                  |
| ------------------- | ------------------------------------------------------------------------------------------------------------ |
| `created`           | Se creó un cliente nuevo y se colocó en el pipeline.                                                         |
| `updated`           | La fila coincidió con un cliente existente (ver `matched_by`) y se fusionó con él.                           |
| `skipped`           | La fila coincidió con un cliente existente y lo dejó intacto (`on_duplicate: "skip"`), pero igual lo colocó. |
| `failed`            | No se escribió nada para esta fila; `errors[]` dice por qué.                                                 |

`pipeline.status` es `added` (tarjeta nueva), `reactivated` (el contacto había sido quitado del tablero y vuelve), `already_active` (ya estaba en el tablero: la tarjeta se queda donde está, sin duplicarse) o `not_attempted` (la fila falló, o fue una prueba).

## Cómo se buscan los contactos

Cada fila se compara con tus contactos existentes por, en orden:

1. **`client_id`**: exacto. Envíalo en cuanto lo conozcas; sobrevive a un cambio de teléfono o de correo.
2. **Teléfono**: tolerante al código de país (ver abajo).
3. **Correo**: sin distinguir mayúsculas.

Una fila que no coincide con nada se crea. Una fila cuyo teléfono coincide con un contacto y cuyo correo pertenece a *otro* se rechaza con `conflict`: escribirla pegaría datos a la persona equivocada.

### Números de teléfono

Los teléfonos se normalizan a **código de país + dígitos nacionales** y se guardan así. Envíalos en la forma que tengas:

* **`+E.164`** (`+525512345678`) es la forma autoritativa: no se consulta nada más.
* **Dígitos nacionales + código de país** (`5512345678` con `area_code: "52"`, o `defaults.area_code: "52"` para todo el lote): la forma recomendada para fuentes tipo CSV.
* **Dígitos internacionales sin `+`** (`525512345678`) se aceptan solo cuando forman un número *válido* en algún país. Un número local de 9 dígitos sin código de país nunca se adivina como de otro país: falla con `invalid_format`.

La búsqueda tolera las formas que ya existen en tu cuenta: un `+52 55 1234 5678` guardado se encuentra tanto si envías E.164, dígitos nacionales con código de país o los dígitos completos. Una regla a tener en cuenta: **la unicidad del teléfono es sobre los dígitos nacionales**, así que los mismos dígitos bajo dos códigos de país distintos no pueden coexistir; esa fila se reporta como `conflict` con `conflicting_client_id` apuntando al dueño.

## Política de duplicados

`on_duplicate` decide qué hace una coincidencia:

| Valor                  | Registro del cliente                                               | ¿Se coloca en el pipeline? | `status` de la fila                               |
| ---------------------- | ------------------------------------------------------------------ | -------------------------- | ------------------------------------------------- |
| `update` (por defecto) | **Se fusiona**: ver las reglas abajo                               | Sí                         | `updated`                                         |
| `skip`                 | Se deja intacto: ni campos, ni campos personalizados, ni etiquetas | Sí                         | `skipped`                                         |
| `fail`                 | Se deja intacto                                                    | No                         | `failed`, error `exists` con `existing_client_id` |

<Warning>
  **Este valor por defecto es el contrario al del endpoint individual.** El lote usa por defecto `on_duplicate: "update"`; **Ingest a client into a pipeline** usa por defecto `update: false` (colocar, pero no tocar). Indica la política explícitamente si mueves una integración de uno al otro.
</Warning>

### Reglas de fusión

Una actualización es una **fusión**, nunca un reemplazo:

* Solo se escriben los campos **no vacíos** de la fila. Un campo ausente, `null` o en blanco deja el valor guardado como está: no puedes vaciar un campo por este endpoint.
* **`phone_number` y `area_code` nunca cambian.** El teléfono es la identidad; cámbialo en el panel.
* **`timezone`** se escribe solo cuando la fila lo envía. Una zona inferida del teléfono o tomada de `defaults.timezone` aplica solo a contactos *nuevos*.
* **Los campos personalizados (`data`) se fusionan clave a clave**, al estilo JSON Merge Patch. Una clave en la fila la establece; una clave con valor `null` la **elimina**; las claves que no mencionas se conservan. `defaults.data` se aplica primero y luego ganan las claves propias de la fila. Las claves son cadenas de hasta 100 caracteres; el objeto fusionado debe quedar por debajo de 16 KB.
* **Las etiquetas se añaden.** Cada etiqueta se busca por nombre exacto y se crea si no existe (`color` y `description` aplican solo a etiquetas nuevas). Las etiquetas que alguien puso a mano nunca se quitan.

## Prueba con `dry_run`

Envía `"dry_run": true` para recibir la misma respuesta — el estado, las coincidencias y los errores de cada fila — sin escribir nada. Las filas que coinciden traen además `existing_client`, una foto del contacto guardado antes de cualquier fusión, para mostrar una vista previa "actual → nuevo". `pipeline.status` es `not_attempted` en todas las filas.

```bash theme={null}
curl -s -X POST .../batch -H "Authorization: Bearer pk_xxxxxxxx" -H "Content-Type: application/json" \
  -d @batch.json | jq '.results[] | select(.status == "failed") | {index, external_id, errors}'
```

## Todo o nada con `all_or_none`

Por defecto las filas buenas se guardan aunque otras fallen. Con `"all_or_none": true`, todas las filas se evalúan igualmente (para que veas todos los problemas de una vez) y luego, si al menos una falló, el lote completo se revierte:

```json theme={null}
HTTP/1.1 400 Bad Request

{
  "status": "error",
  "error_code": "INGEST_ROLLED_BACK",
  "message": "1 of 3 rows failed; nothing was written",
  "details": {
    "summary": { "total": 3, "created": 2, "updated": 0, "skipped": 0, "failed": 1,
                 "pipeline_added": 2, "pipeline_already_active": 0, "pipeline_failed": 0 },
    "results": [ ... ]
  }
}
```

`details.summary` y `details.results` tienen exactamente la forma de un cuerpo `200`, así que un mismo parser sirve para ambos. Los estados describen lo que *habría* pasado; los ids que la reversión descartó (`client_id` de una fila `created`, `client_state_id` de una tarjeta `added`) se omiten. Corrige las filas que fallan y reenvía el lote completo.

## Qué ocurre al colocar en el pipeline

Para cada fila colocada como `added` o `reactivated`:

* Se disparan las **automatizaciones de llegada** de la etapa — secuencias, mensajes, lo que la etapa haga cuando llega un contacto — salvo que envíes `"trigger_automations": false`. Ese interruptor es para cargas históricas silenciosas; una sincronización en vivo debería dejarlo activado para que los leads importados se trabajen como cualquier otro.
* Se aplican las **reglas de asignación** del nodo de entrada (estrategia y grupo), igual que para un lead que llegó por cualquier otra vía.
* El **`default_lead_value`** del nodo se escribe en el valor del cliente si aún no tiene uno.
* El `entry_source` de la tarjeta es `api` y su registro de entrada apunta al nodo.

Nada de lo anterior ocurre con las filas `already_active`: el contacto ya estaba en el tablero y su tarjeta, etapa y progreso se dejan tal cual. Volver a ingresar un contacto que está a mitad del pipeline es, por tanto, seguro: actualiza su registro (con `update`) y no lo mueve.

## Límites y buenas prácticas

* **500 filas por solicitud.** Divide conjuntos mayores en lotes consecutivos (`422` por encima del tope).
* **Síncrono.** Un lote de 500 filas suele completarse en unos segundos. No lances lotes en paralelo contra el mismo nodo.
* **Reintentar es seguro, y no necesitas clave de idempotencia.** Volver a enviar las mismas filas con `on_duplicate: "update"` *converge*: las filas que fueron `created` vuelven como `updated` / `already_active`, nada se duplica. Si una solicitud agotó el tiempo y no sabes si llegó, envíala otra vez.
* **Define `defaults.area_code`** siempre que tu fuente tenga números nacionales. Sin código de país, un número local se rechaza en lugar de adivinarse.
* **Sé explícito con `on_duplicate`.** `update` para una sincronización; `skip` para una importación puntual que no debe pisar datos editados a mano; `fail` cuando un duplicado significa que tu fuente tiene un problema.
* **Prefiere `client_id` en cuanto lo tengas.** Guarda el `client_id` de cada resultado junto a tu propio registro; las filas siguientes podrán actualizar a esa persona aunque su teléfono o correo hayan cambiado de tu lado.

## Conectarlo con n8n

<Steps>
  <Step title="Nodo HTTP Request">
    Método `POST`, URL `https://api.dialtu.com` + el `ingest_path` de **List API input nodes of a pipeline**.
  </Step>

  <Step title="Autenticación">
    *Generic Credential Type* → *Header Auth*, nombre `Authorization`, valor `Bearer pk_xxxxxxxx`.
  </Step>

  <Step title="Cuerpo">
    **Body Content Type** `JSON`. Construye `clients` con tus nodos anteriores (un nodo *Aggregate* o *Code* convierte los ítems en un arreglo; mantente por debajo de 500 por ejecución y usa *Split In Batches* si hay más). Pon el id de tu propio registro en `external_id`.
  </Step>

  <Step title="Después de la llamada">
    Recorre `results[]`: guarda `client_id` de tu lado para las filas `created` / `updated`; enruta las filas `failed` por `errors[0].code`. Un `400 INGEST_ROLLED_BACK` (solo con `all_or_none`) significa que nada se guardó: corrige y reenvía. Ver [Errores](/es/api-reference/errores#salesflow--ingreso-por-lotes-batch-ingest).
  </Step>
</Steps>

## No por este endpoint

Mover un contacto que ya está en el tablero no es tarea de un ingreso: usa **Move a client between stages**. Crear nodos de entrada API, y vaciar un campo guardado, se hace desde el panel.
