# Adding leads

POST /v1/campaigns/{id}/leads accepts your leads with a ready first message for each. A 202 response reports intake, not sending.

> **Note.** Write access is enabled for your organization on request.

## Prepare the campaign

### Configure the campaign in the app

Create a linear campaign. Select API first-message mode and enable API lead intake. The first step must be ready with an empty text, no AI personalization, word rotations or attachments. Manual account assignment must be off. Configure accounts, the schedule and subsequent steps in the app.

### Activate the campaign

The campaign must be active with no rebuild in progress. An empty API campaign waits for your batches. Check `first_message_mode=api`, `accepts_api_leads=true` and `status=active` by [reading the campaign](https://docs.melioralab.io/en/reference/campaigns/). `accepts_api_leads` alone is not enough for intake.

When reading, `first_message_mode` can be `unknown` and `accepts_api_leads` can be `null` if the value is not established. Neither confirms that the campaign is ready for intake.

### Grant the key permission

The key needs `leads:enroll` and must include the workspace and campaign in its scope. To check sending, separately grant `leads:read`, `events:read` and, if needed, `messages:read`.

## Request body

Send `Content-Type: application/json` and an object with a single `leads` field containing 1 to 100 entries. The complete body must fit in 512 KiB; even a batch of 100 leads can exceed this size.

- `username` is required: a Telegram username, `@username` or public profile link. It is lowercased and the initial `@` is removed. The normalized username has 4 to 32 Latin letters, digits or `_`, starts with a letter and ends with a letter or digit. Phone numbers and chat invitations are not accepted.
- `external_id` is optional and nullable: 1 to 128 characters without control characters. It identifies a participation in this campaign, separately from `fields.record_id`, which links a record to your CRM.
- `first_message` is required: 1 to 4096 Unicode characters after CRLF/CR become LF, not whitespace only. Empty strings and the null character are rejected. Leading and trailing spaces are preserved.
- `fields` is optional: only `record_id`, `first_name`, `last_name`, `company`, `position`, `website`, `source`, `utm_source`, `utm_medium`, `utm_campaign`. Values are strings or `null`; `record_id` up to 128 characters, others up to 1024. Up to 50 keys and 8 KiB per object. Extra fields are rejected at every request level.

> **Warning.** The first message is sent literally: no `{{name}}` substitution, AI, rotations or Markdown/HTML parsing. Prepare the final text in your system. At 700 characters the item gets a `first_message_long` warning, but text up to 4096 characters remains valid. Changing the campaign template does not replace already accepted text.

The conversation archive returns up to 4000 characters with truncation metadata. An accepted 4096-character first message may not be returned in full by the archive.

## Idempotency and retries

The required `Idempotency-Key` contains 1 to 255 printable ASCII characters. Create one unique value per logical operation and persist it with the target and original body before making the request.

- After 429, 503, timeout or a lost connection, retry the original request with the same key. For 429/503, wait for `Retry-After` plus jitter. Do not generate a new key inside the retry loop.
- Retrying an accepted request returns the same command in its current state, without new leads or another daily quota charge. Retries still consume the HTTP budget.
- Deduplication is organization-wide and survives API key rotation.
- A different body, lead order, target or operation with the same key gives 422 `idempotency_key_reused`. Preserve the original body even if two texts would become identical after line-ending normalization.

These examples use a previously persisted `MELIORA_IDEMPOTENCY_KEY`. curl retries 429 and temporary errors respecting `Retry-After`; Python and Node.js show an explicit bounded loop.

```bash
curl --retry 5 --retry-max-time 300 \
  -X POST https://api.melioralab.io/v1/campaigns/4d8b2f73-6e1a-4c95-b7d0-8a3f5e2c9b14/leads \
  -H "Authorization: Bearer $MELIORA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $MELIORA_IDEMPOTENCY_KEY" \
  --data-raw '{"leads":[{"username":"example_lead","external_id":"crm-1042","first_message":"Hello, Anna! May I ask a question?","fields":{"record_id":"crm-1042","first_name":"Anna"}}]}'
```
```python
import json
import os
import random
import time
import requests

url = "https://api.melioralab.io/v1/campaigns/4d8b2f73-6e1a-4c95-b7d0-8a3f5e2c9b14/leads"
headers = {
    "Authorization": f"Bearer {os.environ['MELIORA_API_KEY']}",
    "Content-Type": "application/json",
    "Idempotency-Key": os.environ["MELIORA_IDEMPOTENCY_KEY"],
}
payload = json.dumps({"leads": [{
    "username": "example_lead", "external_id": "crm-1042",
    "first_message": "Hello, Anna! May I ask a question?",
    "fields": {"record_id": "crm-1042", "first_name": "Anna"},
}]}, ensure_ascii=False).encode("utf-8")

for attempt in range(6):
    response = requests.post(url, headers=headers, data=payload, timeout=15)
    if response.status_code in (429, 503) and attempt < 5:
        delay = max(float(response.headers.get("Retry-After", "5")), min(60, 2 ** attempt))
        time.sleep(delay + random.uniform(0, 1))
        continue
    response.raise_for_status()
    command = response.json()
    print(command["id"], command["items"])
    break
```
```js
const url = 'https://api.melioralab.io/v1/campaigns/4d8b2f73-6e1a-4c95-b7d0-8a3f5e2c9b14/leads';
const headers = {
  Authorization: `Bearer ${process.env.MELIORA_API_KEY}`,
  'Content-Type': 'application/json',
  'Idempotency-Key': process.env.MELIORA_IDEMPOTENCY_KEY,
};
const payload = JSON.stringify({ leads: [{
  username: 'example_lead', external_id: 'crm-1042',
  first_message: 'Hello, Anna! May I ask a question?',
  fields: { record_id: 'crm-1042', first_name: 'Anna' },
}] });
for (let attempt = 0; attempt < 6; attempt++) {
  const response = await fetch(url, {
    method: 'POST', headers, body: payload, signal: AbortSignal.timeout(15_000),
  });
  if ([429, 503].includes(response.status) && attempt < 5) {
    const delay = Math.max(Number(response.headers.get('Retry-After') ?? 5), Math.min(60, 2 ** attempt));
    await response.body?.cancel();
    await new Promise((resolve) => setTimeout(resolve, (delay + Math.random()) * 1000));
    continue;
  }
  if (!response.ok) throw new Error(`Meliora API ${response.status}: ${await response.text()}`);
  const command = await response.json();
  console.log(command.id, command.items);
  break;
}
```

If the request loses its connection, the persisted key and body let you retry in the same way after a delay.

## 202 and each lead's result

The response is a [command object](https://docs.melioralab.io/en/reference/commands/). Every input lead has an `items` entry in input order: zero-based `input_index`, `lead_id`, normalized `username`, `external_id`, `status`, `reason`, `warnings` and `queue_state`. The first message text is not returned.

- `accepted`: a new lead accepted for enqueue. Initially `queue_state=waiting`.
- `existing`: the same non-null `external_id` and normalized username already exist in this campaign. Text and fields stay unchanged; no new send is created; `queue_state=not_required`.
- `rejected`: intake refused; see `reason`. Normally `lead_id=null`, `queue_state=not_required`.

`result.accepted` counts new leads accepted for enqueue. `result.inserted` counts inserted records and may include a record refused by policy after insertion. Neither counter includes `existing`. If no new leads are accepted, the command may already have `state=done`.

A structurally invalid body fails the whole request. A valid body may be partially accepted: check every `items` entry even after HTTP 202.

## Conflicts and refusals

- `external_id_conflict`: the external ID belongs to another username in this campaign.
- `username_already_in_campaign`: the username is already in the campaign but the ID/username pair did not match. This also applies to repeated usernames without `external_id`.
- `stoplisted`: the contact is on the stop list.
- `previous_contact`: contacted previously and the campaign forbids this.
- `policy_blocked`: the record was inserted but contact policy blocked enqueue.
- `invalid_username`, `invalid_external_id`, `invalid_first_message`, `invalid_fields`: invalid item values. Some are caught earlier as HTTP 422 `invalid_request` for the whole request.
- `daily_lead_quota_exceeded`, `waiting_lead_quota_exceeded`: exhausted quota; other items may still be accepted.

Duplicates within one batch are processed in order by the same rules. External ID uniqueness is per campaign, not across the organization. Send corrected rejected items as a new batch with a new key; an unchanged retry preserves the original intake results.

## Queue and first message

`queue_state` describes item processing:

- `waiting`: awaiting enqueue;
- `queued`: a task was created;
- `held`: enqueue held; inspect `reason` and the lead;
- `failed`: enqueue failed;
- `stopped`: the lead was stopped before enqueue;
- `not_required`: no enqueue for `existing` or `rejected`.

Poll the [command](https://docs.melioralab.io/en/commands/) to a terminal state, then inspect leads and events. `queued` and `done` do not confirm sending. The first message is sent when the schedule, account availability, limits and an active subscription allow it. There is no promised exact time. Confirmation comes from `lead.first_message_sent`, `message.sent` or the lead's `first_message_sent_at`. Steps 2 and later follow the normal campaign sequence. An unknown outcome does not authorize sending the text again.

## Quotas and request errors

Request weight is 5. Up to 20 active enrollment commands (`accepted` or `running`) per organization. Per workspace: 5000 new records per UTC day and 20000 waiting work items. Waiting work includes pending and processing tasks plus accepted items not yet enqueued. Retries and `existing` do not consume daily quota.

Quotas may appear as HTTP 429 or as `items[].reason` in a 202 response. For `too_many_active_commands`, wait for earlier commands. Daily quota resets on the next UTC day; waiting quota becomes available as work is processed. `Retry-After` gives a delay, not a guarantee that quota will recover by then.

422 `campaign_first_message_not_api` requires selecting API mode in the app; `campaign_not_accepting` requires checking campaign readiness and activity. 413 `payload_too_large` requires a smaller batch. 403 `api_write_disabled` means write access is not enabled yet. See [errors](https://docs.melioralab.io/en/errors/) and [limits](https://docs.melioralab.io/en/rate-limits/).
