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

# POST /leads/batch

> Send many leads in one call, read the per-row outcome, and understand why a bad row cannot poison the rest.

One `POST` runs up to 100 leads through the same intake logic as [POST /leads](/en/developers/post-leads) and always answers `200`, with a per-row outcome inside the body. So a row that fails while it's being processed doesn't cost you the rest of the call.

<div className="pl-availability">
  <div className="pl-availability__row">
    <div className="pl-availability__label">Where</div>
    <div className="pl-availability__value"><span className="pl-path">Settings<span className="pl-path__sep">→</span>API & Integrations</span></div>
  </div>

  <div className="pl-availability__row">
    <div className="pl-availability__label">Your role needs</div>
    <div className="pl-availability__value">Update access to Lead intake API <code>update-companies</code>. Admin have it by default.</div>
  </div>

  <div className="pl-availability__note">Authenticated by a workspace API key, not by a signed-in session. Creating a key needs the same permission as the rest of the API settings tab.</div>
</div>

## Status codes

| Status                     | When                                                                                                                             |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `200 OK`                   | The call validated: the body always carries one entry per row inside `data`, whatever happened to any of them                    |
| `400 Bad Request`          | The `Idempotency-Key` header is longer than the API accepts                                                                      |
| `401 Unauthorized`         | Missing, invalid, revoked or expired key: see [Authentication](/en/developers/authentication)                                    |
| `403 Forbidden`            | The key is valid but wasn't granted `leads:write`                                                                                |
| `409 Conflict`             | The same `Idempotency-Key` was sent with a different body                                                                        |
| `422 Unprocessable Entity` | `leads` is missing, empty, has more than 100 entries, or any row in it fails shape validation: nothing in the batch is processed |
| `429 Too Many Requests`    | Too many calls on this key inside one minute                                                                                     |

<Note>
  There's no top-level `409` for a duplicate lead here, unlike `POST /leads`. Under `on_duplicate: "error"`, a duplicate inside a batch shows up as a failed row in the `200` response instead of rejecting the whole call. See [Response envelope](#response-envelope) below.
</Note>

## Request body

| Field                                                      | Rule                                                                                              |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `leads`                                                    | Required array of lead objects, 1 to 100 of them                                                  |
| `leads[].name`                                             | Always required                                                                                   |
| `leads[].email`, `leads[].phone`, `leads[].linkedin_url`   | Send at least one of the three per row, or in their place, a non-empty `emails` or `phones` array |
| `dry_run`, `on_duplicate`, `create_missing_tags`, `enrich` | Same four switches as `POST /leads`, sent once and applied to every row in the call               |

Every other field a single lead accepts (`job_title`, `company`, `tags`, `custom_fields`, and the rest) works the same way inside each entry of `leads[]`. See the [lead fields reference](/en/developers/lead-fields-reference) for the full list, and [POST /leads](/en/developers/post-leads) for what each option switch does and its default.

A switch applies to the whole call, so its effect repeats on every row: `enrich` is on unless you turn it off, and each new company a batch creates from a domain queues its own website analysis. Send `enrich: false` on a bulk import if you only want the leads stored.

<CodeGroup>
  ```bash cURL theme={"system"}
  curl -X POST 'https://api.pipelime.ai/api/v1/leads/batch' \
    -H 'Authorization: Bearer YOUR_API_KEY' \
    -H 'Content-Type: application/json' \
    -d '{
      "leads": [
        { "name": "Jane Doe", "email": "jane.doe@acme-demo.test", "tags": ["Inbound"] },
        { "name": "Marco Reyes", "email": "marco@acme-demo.test", "tags": ["Inbound"] }
      ]
    }'
  ```

  ```json Two leads in one call theme={"system"}
  {
    "leads": [
      { "name": "Jane Doe", "email": "jane.doe@acme-demo.test", "tags": ["Inbound"] },
      { "name": "Marco Reyes", "email": "marco@acme-demo.test", "tags": ["Inbound"] }
    ]
  }
  ```

  ```json With a shared option theme={"system"}
  {
    "leads": [
      { "name": "Jane Doe", "email": "jane.doe@acme-demo.test", "tags": ["Inbound"] },
      { "name": "Marco Reyes", "email": "marco@acme-demo.test", "tags": ["Inbound"] }
    ],
    "on_duplicate": "skip"
  }
  ```
</CodeGroup>

## Every row runs on its own

Each row runs inside its own savepoint, because the whole request already runs inside one transaction. A row that trips a database constraint rolls back only that row, never the ones before or after it. `dry_run: true` previews every row the same way `POST /leads` does: each one runs for real inside a savepoint that's then rolled back, so nothing in the batch is saved.

Shape is checked in one pass over the whole array, not row by row as each is processed. `name`, the contact-detail requirement, and every other field's format are validated against the entire `leads` array before the first row runs. So one row with no name or no way to reach the person fails the whole call with a `422`, and none of the batch runs at all. Fix that row (or remove it) and resend the whole batch. Once shape passes, a problem the service only finds while processing a row (an unknown `tag_id`, an unknown tag name when you sent `create_missing_tags: false`, a duplicate under `on_duplicate: "error"`) is captured as a failed row instead, and the rest of the batch keeps going.

Rows are processed in order, and on a real call an earlier row is already visible to the ones after it: send the same person twice in one batch and the second row matches the first, so on the default `on_duplicate: "update"` you get a `created` followed by an `updated` rather than two leads. A dry run is the exception: each preview is rolled back before the next row starts, so two copies of the same person both come back `created`.

A `created` row fires the new-lead trigger, exactly as a single `POST /leads` would. An `updated` row doesn't. But tags are still applied on an update, and a tag the lead didn't already carry starts a trigger of its own. A `skipped` row attaches no tag and fires nothing. None of this spends credits: a lead you hand over is stored as non-chargeable. See [Automations](/en/developers/post-leads#automations) on `POST /leads` for the trigger names.

## Response envelope

The body is always `{ data, meta }`. `data` is an array with one entry per row, in the same order you sent them:

| Field      | Type                                        | Notes                                                                                        |
| ---------- | ------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `index`    | integer                                     | Position in the `leads` array you sent, zero-based                                           |
| `status`   | `created`, `updated`, `skipped` or `failed` | What happened to this row                                                                    |
| `lead_id`  | integer or `null`                           | The lead's id: `null` on a dry run and on most failed rows                                   |
| `warnings` | array of strings                            | On every row that isn't `failed`: can be empty                                               |
| `error`    | string                                      | Only on a `failed` row                                                                       |
| `field`    | string or `null`                            | Only on a `failed` row: the payload field the failure points at, like `tags` or `company.id` |

A duplicate rejected under `on_duplicate: "error"` is the one failed row that still carries a `lead_id`, the id of the lead it matched:

```json One row inside a 200 batch response theme={"system"}
{
  "index": 3,
  "status": "failed",
  "lead_id": 4821,
  "error": "A lead with this email address already exists in this workspace.",
  "field": "email"
}
```

<Warning>
  `field` on that duplicate row always reads `"email"`, even when the real match was on a LinkedIn URL or a phone number. Only the `error` message says what actually matched.
</Warning>

A row that fails for a reason the API didn't anticipate gets a generic message and no `field` at all:

```json An unhandled row failure theme={"system"}
{
  "index": 7,
  "status": "failed",
  "lead_id": null,
  "error": "This lead could not be processed. Please retry or contact support.",
  "field": null
}
```

`meta` counts the whole call:

| Field                                     | Type    | Notes                                  |
| ----------------------------------------- | ------- | -------------------------------------- |
| `created`, `updated`, `skipped`, `failed` | integer | How many rows landed in each outcome   |
| `total`                                   | integer | Length of the `leads` array you sent   |
| `dry_run`                                 | boolean | Echoes whether this call was a preview |

```json meta for a batch of 5, one duplicate theme={"system"}
{
  "created": 3,
  "updated": 1,
  "skipped": 0,
  "failed": 1,
  "total": 5,
  "dry_run": false
}
```

## Idempotency and rate limits

An `Idempotency-Key` header works exactly as it does on `POST /leads`. Send one and a retried call with the same body returns the stored response instead of running again. Validation runs before that replay check, though: a malformed batch fails with `422` without ever touching the idempotency cache, so fixing the payload and resending under the same key still runs as a fresh attempt.

<Warning>
  This call always answers `200` once it validates, per-row failures included. Only a status below `400` is memoized. That means a batch sent under an `Idempotency-Key` is cached even when every row inside it failed. Retry it and you get the identical failed rows back, not a fresh attempt.
</Warning>

However many leads it carries, one batch call counts as a single request against your rate limit, so a hundred leads sent this way cost one request instead of a hundred. Full mechanics, including the exact limit and the idempotency replay window, are on [Idempotency, deduplication and rate limits](/en/developers/idempotency-deduplication-and-rate-limits).

## Related

<CardGroup cols={2}>
  <Card title="POST /leads" icon="send" href="/en/developers/post-leads">
    The single-lead version of this endpoint: request body, duplicate handling and dry run.
  </Card>

  <Card title="Idempotency, deduplication and rate limits" icon="repeat" href="/en/developers/idempotency-deduplication-and-rate-limits">
    Retry safely, and see what a batch call costs against your rate limit.
  </Card>

  <Card title="Errors reference" icon="circle-alert" href="/en/developers/errors">
    Every status the API returns and what to change before retrying.
  </Card>
</CardGroup>
