> ## 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.

# Batch ingest clients

> Push up to 500 contacts per request into a Salesflow pipeline stage — per-row results, merge-safe updates, retries that converge

Use the batch ingest to sync contacts from a CRM, a spreadsheet export, n8n or your own backend into a Salesflow pipeline, many at a time:

* **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`

For one contact at a time — a website form, a single webhook — use **Ingest a client into a pipeline** instead. Both endpoints are addressed to the same thing: an **API input node**.

<Note>
  **The input node owns the destination.** You create it once in the Salesflow editor (open the pipeline → input nodes → type **API**). It decides which stage new contacts land on, how they are assigned to your team, and the default lead value. The batch endpoint adds what a bulk sync needs on top: matching, a duplicate policy, custom fields, tags, a dry run and all-or-nothing mode.
</Note>

## Concepts

| Thing              | What it is                                                                                                                                                                                    |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Pipeline**       | A Salesflow board. Identified by its numeric `id`.                                                                                                                                            |
| **Stage**          | A column on that board. The input node points at one; you can override it per request with `stage_id`, as long as it belongs to the same pipeline.                                            |
| **API input node** | The entry point you send contacts to. Identified by its `input_node_uuid`. Must be active and of type **API**.                                                                                |
| **Client**         | A contact in your Dialtu account. Matched by `client_id`, then phone number, then email — in that order.                                                                                      |
| **Custom fields**  | The free-form `data` object on a client — the same keys you see on the client's *Data* tab in the dashboard and can use in Salesflow conditions (`data.<key>`) and as agent prompt variables. |

## From API key to ingest in three calls

<Steps>
  <Step title="Find the 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="Find the node — and copy its ingest path">
    ```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
    }
    ```

    Only **API-type** nodes are listed — the ones the ingest endpoints can be addressed to. If the list is empty, create one in the Salesflow editor.
  </Step>

  <Step title="(Optional) pick another stage">
    **List stages in a pipeline** gives you every `stage_id`. Send one as `stage_id` in the batch to land contacts somewhere other than the node's target stage.
  </Step>
</Steps>

## A batch, end to end

```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": "No identity" }
    ],
    "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 }
  ]
}
```

Rows succeed or fail **individually**. A `200` means the request was valid and processed — read `summary` for the totals and `results[]` for what happened to each row, in request order. `external_id` is echoed back untouched, so correlate on it rather than on array position.

| Row `status` | Meaning                                                                                                 |
| ------------ | ------------------------------------------------------------------------------------------------------- |
| `created`    | A new client was created and placed.                                                                    |
| `updated`    | The row matched an existing client (see `matched_by`) and was merged into it.                           |
| `skipped`    | The row matched an existing client and left it untouched (`on_duplicate: "skip"`), but still placed it. |
| `failed`     | Nothing was written for this row; `errors[]` says why.                                                  |

`pipeline.status` is `added` (new card), `reactivated` (the contact had been removed from the board and is back), `already_active` (already on the board — the card stays where it is, no second card) or `not_attempted` (the row failed, or this was a dry run).

## How contacts are matched

Each row is matched against your existing contacts by, in order:

1. **`client_id`** — exact. Send it once you know it; it survives a changed phone or email.
2. **Phone number** — country-code tolerant (see below).
3. **Email** — case-insensitive.

A row that matches nothing is created. A row whose phone matches one contact and whose email belongs to *another* is refused with `conflict` — writing it would attach data to the wrong person.

### Phone numbers

Phones are normalised to **country code + national digits** and stored that way. Send them in whichever shape you have:

* **`+E.164`** (`+525512345678`) is authoritative — nothing else is consulted.
* **National digits + a country code** (`5512345678` with `area_code: "52"`, or `defaults.area_code: "52"` for the whole batch) — the recommended shape for CSV-style sources.
* **Bare international digits** (`525512345678`) are accepted only when they form a *valid* number somewhere. A 9-digit local number without a country code is never guessed as some other country's number — it fails with `invalid_format` instead.

Matching tolerates the shapes already in your account: a stored `+52 55 1234 5678` is found whether you send E.164, national digits with a country code, or the full digits. One rule to know: **phone uniqueness is on the national digits**, so the same digits under two different country codes cannot coexist — such a row is reported as `conflict` with `conflicting_client_id` pointing at the owner.

## Duplicate policy

`on_duplicate` decides what a match does:

| Value              | Client record                                        | Placed on the pipeline? | Row `status`                                       |
| ------------------ | ---------------------------------------------------- | ----------------------- | -------------------------------------------------- |
| `update` (default) | **Merged** — see the rules below                     | Yes                     | `updated`                                          |
| `skip`             | Left untouched: no fields, no custom fields, no tags | Yes                     | `skipped`                                          |
| `fail`             | Left untouched                                       | No                      | `failed`, error `exists` with `existing_client_id` |

<Warning>
  **This default is the opposite of the single-row endpoint's.** The batch defaults to `on_duplicate: "update"`; **Ingest a client into a pipeline** defaults to `update: false` (place, but do not touch). Set the policy explicitly if you move an integration from one to the other.
</Warning>

### Merge rules

An update is a **merge**, never a replace:

* Only **non-empty** row fields are written. An absent, `null` or blank field leaves the stored value alone — you cannot clear a field through this endpoint.
* **`phone_number` and `area_code` never change.** The phone is the identity; change it in the dashboard.
* **`timezone`** is written only when the row supplies it. A zone inferred from the phone or taken from `defaults.timezone` applies to *new* contacts only.
* **Custom fields (`data`) merge key by key** — JSON Merge Patch style. A key in the row sets it; a key set to `null` **removes** it; keys you do not mention are kept. `defaults.data` is applied first, then the row's own keys win. Keys are strings of at most 100 characters; the merged object must stay under 16 KB.
* **Tags are appended.** Each tag is matched by exact name and created if missing (`color` and `description` apply to new tags only). Tags someone added by hand are never removed.

## Preview with `dry_run`

Send `"dry_run": true` to get the same response — every row's status, matches and errors — without writing anything. Matched rows additionally carry `existing_client`, a snapshot of the stored contact before any merge, so you can show a "current → new" preview. `pipeline.status` is `not_attempted` for every row.

```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}'
```

## All or nothing with `all_or_none`

By default the good rows land even when others fail. With `"all_or_none": true`, every row is still evaluated (so you see every problem at once) and then, if at least one failed, the whole batch is rolled back:

```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` and `details.results` have exactly the shape of a `200` body, so one parser serves both. The statuses describe what *would* have happened; ids the rollback discarded (`client_id` of a `created` row, `client_state_id` of an `added` card) are omitted. Fix the failing rows and resend the whole batch.

## What happens on placement

For every row placed as `added` or `reactivated`:

* The stage's **on-arrival automations** fire — sequences, messages, whatever the stage does when a contact arrives — unless you send `"trigger_automations": false`. That switch is for silent back-fills; a live sync should leave it on so imported leads are worked like any other.
* The input node's **assignment rules** run (strategy and pool), exactly as for a lead that arrived any other way.
* The node's **`default_lead_value`** is written to the client's value if the client has none yet.
* The card's `entry_source` is `api` and its entry log points at the node.

Nothing above happens for `already_active` rows: the contact was already on the board and their card, stage and progress are left alone. Re-ingesting a contact who is mid-pipeline is therefore safe — it updates their record (under `update`) and does not move them.

## Limits and good practice

* **500 rows per request.** Split larger sets into consecutive batches (`422` above the cap).
* **Synchronous.** A 500-row batch normally completes in a few seconds. Do not run batches in parallel against the same node.
* **Retries are safe — and you do not need an idempotency key.** Re-running the same rows with `on_duplicate: "update"` *converges*: rows that were `created` come back `updated` / `already_active`, nothing is duplicated. If a request timed out and you do not know whether it landed, send it again.
* **Set `defaults.area_code`** whenever your source holds national numbers. Without a country code a local number is rejected rather than guessed.
* **Be explicit about `on_duplicate`.** `update` for a sync; `skip` for a one-off import that must not overwrite hand-edited data; `fail` when a duplicate means your source has a problem.
* **Prefer `client_id` once you have it.** Store the `client_id` from each result next to your own record; later rows can then update that person even if their phone or email changed on your side.

## Wiring it into n8n

<Steps>
  <Step title="HTTP Request node">
    Method `POST`, URL `https://api.dialtu.com` + the `ingest_path` from **List API input nodes of a pipeline**.
  </Step>

  <Step title="Authentication">
    *Generic Credential Type* → *Header Auth*, name `Authorization`, value `Bearer pk_xxxxxxxx`.
  </Step>

  <Step title="Body">
    **Body Content Type** `JSON`. Build `clients` from your earlier nodes (an *Aggregate* or *Code* node turns items into one array; stay under 500 per run and use *Split In Batches* above that). Put your own record id in `external_id`.
  </Step>

  <Step title="After the call">
    Loop over `results[]`: store `client_id` on your side for `created` / `updated` rows; route `failed` rows by `errors[0].code`. A `400 INGEST_ROLLED_BACK` (only with `all_or_none`) means nothing landed — fix and resend. See [Errors](/en/api-reference/errors#salesflow-batch-ingest).
  </Step>
</Steps>

## Not through this endpoint

Moving a contact who is already on the board is not what an ingest does — use **Move a client between stages**. Creating API input nodes, and clearing a stored field to empty, are done in the dashboard.
