API RU
Early access. API keys are issued on request. How to get a key

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

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

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.

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"}}]}'

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. 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 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 and limits.