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

# Bulk leads

> Send many leads in one request. One result per lead.

`POST /api/v1/leads/bulk` sends **many leads for one campaign in a single request**. OnDial creates each lead and starts the calls the same way as [`POST /api/v1/leads`](https://docs.ondial.ai/get-started/guides/push-leads); you get **one result per lead**, in the same order as your request.

Before you send: [Lead setup](https://docs.ondial.ai/api-reference/endpoint/outbound/lead-setup.md) with your `campaignId`. Optional: send the same body with `"dryRun": true` first (nothing is created).

## Request

```bash theme={"system"}
curl -sS -X POST "$ONDIAL_BASE_URL/api/v1/leads/bulk" \
  -H "Authorization: Bearer $ONDIAL_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: crm-batch-2026-10-16-001" \
  -d '{
    "campaignId": "YOUR_CAMPAIGN_ID",
    "defaults": {
      "channel": "call",
      "consent": { "outboundCall": true, "source": "crm" }
    },
    "leads": [
      { "clientRef": "CRM-1", "leadId": 177, "phone": "+919876543210", "name": "Asha" },
      { "clientRef": "CRM-2", "phone": "+919812345678", "name": "Ravi", "priority": "high" }
    ]
  }'
```

| Field | Meaning |
| - | - |
| `campaignId` | One campaign per request. Use another request for another campaign. |
| `defaults` | Used when a lead leaves a field out (`channel`, `consent`, `priority`, `scheduledAt`, templates, …). A value on the lead wins. `metadata` is merged. |
| `leads[]` | Each lead takes the same fields as Create lead, plus optional `clientRef` (your id, max 100 characters, returned in the result) and `leadId` (your CRM lead id, a string or number, returned unchanged in the result and in webhooks). |
| `dryRun` | `true` = check every lead, create nothing. |
| `atomic` | `true` = create nothing unless **every** lead is valid. |

## Email and WhatsApp leads

The request has the same three forms as [Create lead](https://docs.ondial.ai/get-started/guides/push-leads.md): **Call**, **Email** and **WhatsApp**. Pick the tab you need on the endpoint page. Put the shared values in `defaults` and only what changes in each lead.

| Channel | Set once in `defaults` | Each lead needs |
| - | - | - |
| Call | `channel: "call"`, `consent` | `phone` |
| Email | `channel: "email"`, `emailProfileId`, `emailTemplateId` | `email`, `templateVariables` |
| WhatsApp | `channel: "whatsapp"`, `whatsappProfileId`, `whatsappTemplateId` | `phone`, `whatsappParams` |

Consent is only needed for calls. Get the profile and template ids from [Lead setup](https://docs.ondial.ai/api-reference/endpoint/outbound/lead-setup.md).

## Response

```json theme={"system"}
{
  "batchId": "1f0c2f4e-8e0a-4b1d-9a63-0f3c8a5f2b12",
  "campaignId": "YOUR_CAMPAIGN_ID",
  "mode": "live",
  "summary": { "received": 3, "accepted": 1, "rejected": 2, "duplicates": 1, "queuedPaused": 0 },
  "results": [
    { "index": 0, "clientRef": "CRM-1", "leadId": 177, "status": "accepted", "contactId": "6a7acd39…", "action": "created", "reasonCode": null },
    { "index": 1, "clientRef": "CRM-2", "status": "rejected", "reasonCode": "invalid_phone", "error": "Invalid phone number" },
    { "index": 2, "clientRef": "CRM-3", "status": "rejected", "reasonCode": "duplicate_in_request", "error": "Same phone/email as lead #0 in this request", "details": { "firstIndex": 0 } }
  ],
  "warnings": []
}
```

`results[i]` is the lead at `leads[i]`. Use `contactId` with [Get contact](https://docs.ondial.ai/api-reference/endpoint/contacts/get-contact.md) and your normal webhooks: each accepted lead still sends its own `outbound.lead.created` / `outbound.lead.updated` event.

| HTTP | Meaning |
| - | - |
| `201` | Every lead accepted. |
| `207` | Some accepted, some rejected. Read `results`. |
| `200` | Dry run. `results[].status` is `valid` or `rejected`. |
| `422` | No lead accepted (`bulk_all_rejected`), the `atomic` batch was rejected (`bulk_atomic_rejected`), or the campaign cannot accept leads. A dry run is always `200`, even when every lead is invalid. |

## Limits

| Limit | Default | Notes |
| - | - | - |
| Leads per request | **100** | Can be raised for your API key, up to 500. Over the limit → `413 bulk_limit_exceeded` (nothing is processed). |
| Request body | 2 MB | |
| `metadata` per lead | 8 KB | Larger → that lead is rejected (`bulk_lead_too_large`). |
| Requests per minute | 60 per API key | Shared with every other `/api/v1` call. A `429` here still counts as a request, so wait for `Retry-After` before retrying. |
| Leads per minute | **600** per API key | Counted across bulk requests. Over it → `429 rate_limited` with `Retry-After` and `details.limitPerMinute`; **nothing is processed** and the rejected request does not use up the lead budget. |

Example: 1,000 leads = 10 requests of 100 leads, comfortably inside 600 leads per minute if you spread them over about two minutes. Ask us if you need higher limits.

## Every case

**The whole request is rejected (no lead is created)**

| Situation | Response |
| - | - |
| Body is not JSON | `400 invalid_json` |
| `leads` missing, empty or not an array | `400 bulk_invalid_body` |
| More leads than the limit, or body too large | `413 bulk_limit_exceeded` (`details.maxLeads`) |
| `campaignId` missing, invalid, or not yours | `404 campaign_not_found` |
| Campaign is not an API campaign, is a draft, archived, completed or past its end window | `422` with the same codes as Create lead (`not_api_campaign`, `campaign_not_accepting`, `campaign_closed`, `campaign_window_closed`) |
| No credits (call leads) | `503 insufficient_credits` |
| Over the request or lead rate limit | `429 rate_limited` (`details.retryAfterSec`) |
| Another bulk request for this campaign is still running | `409 bulk_in_progress`. Retry in a few seconds. |
| Same `Idempotency-Key` with a different body | `409 idempotency_conflict` |
| `atomic: true` and at least one lead is invalid | `422 bulk_atomic_rejected`. Nothing is created; each result says which lead failed and the rest are `not_processed`. |

**One lead is rejected, the others continue** (`status: "rejected"`, with `reasonCode` and `error`)

| `reasonCode` | Why |
| - | - |
| `invalid_phone`, `email_required`, `invalid_channel`, `contact_identity_required` | Phone / email / channel problem on that lead. |
| `consent_missing`, `dnc_blocked` | Call lead without consent, or number on do-not-call. |
| `whatsapp_unsubscribed` | Number is on the WhatsApp STOP list. |
| Template or profile errors | Email / WhatsApp lead without the required profile, template or variables. |
| `duplicate_in_request` | The same phone (or email, for email-only leads) appears earlier in the same request. Only the first is used. |
| `requeue_required` | The contact already finished. Send `"requeue": true` to call again. |
| `bulk_invalid_lead`, `bulk_lead_too_large` | The lead is not an object, `clientRef` is too long, or `metadata` is invalid / too big. |
| `duplicate_key`, `internal` | Rare. Safe to retry that lead on its own. |

**A lead is accepted, with a note**

| Situation | Result |
| - | - |
| Contact is already pending or in progress | `accepted`, `reasonCode: "duplicate_no_requeue"`. Data is updated, no extra call is made. |
| Campaign is paused | `queued_paused`. Dials after the campaign resumes. |
| You use a test key (`ond_test_…`) | `sandbox_accepted`. Nothing is dialed. |
| Credits look too low for the whole batch | Accepted, with a message in `warnings`. Calls pause automatically if credits run out. |

## Result statuses

| `status` | Meaning |
| - | - |
| `accepted` | Lead created, updated or requeued (see `action`). |
| `queued_paused` | Accepted while the campaign is paused. |
| `sandbox_accepted` | Accepted with a test key; nothing is dialed. |
| `registered` | Stored without contacting the lead (`channel: "none"`). |
| `rejected` | Not created. See `reasonCode` and `error`. |
| `valid` | Dry run only: this lead would be accepted. |
| `not_processed` | `atomic` batch rejected because another lead was invalid. |

## Idempotency and retries

* Send an `Idempotency-Key` with every bulk request. If your request times out, send it again with the **same key and the same body**: you normally get the same response back (`"idempotentReplay": true`).
* Replay is **best effort**: if the saved response is not available (for example it was not stored yet, or it expired after 24 hours), the batch is processed again. It still never creates a lead twice: leads that already exist come back as `duplicate_no_requeue` instead of `created`, so read `results` rather than assuming the first response.
* To retry only what failed, build a new request from the `results` with `status: "rejected"` that you have fixed (use `index` / `clientRef` to find them). Do not retry `duplicate_in_request` or `duplicate_no_requeue`.
* A lead that was already created is never created twice: sending it again returns `duplicate_no_requeue`.

## Tips

* Run with `dryRun: true` first for a new list. It checks everything, including leads that already exist in the campaign, without creating anything.
* Use `atomic: true` when a partly-loaded list would be a problem.
* Always set `clientRef` to your CRM id: it makes the response easy to match back to your records.
* Do not send the same campaign from two requests at the same moment; a second request waits (up to 10 seconds) or returns `409 bulk_in_progress`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.