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

Reliable mapping relies on your CRM record ID, which you upload together with your contacts. The API returns it in the lead record, and events link to a lead by its id.

#The record_id field

Pass the CRM record ID when you upload contacts to a campaign. Add a record_id column to the contacts file, and in the upload dialog choose "Custom column" for it with the name record_id. The value can be up to 128 characters, with no line breaks.

The API returns it in fields.record_id on the lead, in both GET /v1/leads and GET /v1/leads/{id}.

JSON
{
  "fields": {
    "record_id": "crm-10482",
    "first_name": "Anna",
    "company": "Example Ltd"
  },
  "fields_truncated": false,
  "record_id_status": "valid"
}

#record_id status

Before you use record_id, check record_id_status:

ValueWhat it meansWhat to do
validA single, consistent value.Look up the CRM record by fields.record_id.
missingThere is no value.Match manually or by your own rules.
conflictThe contact record has two different values. fields.record_id is null.Fix the record. Don't pick a value yourself.
unknownThe value's reliability is not confirmed, for example in older data. fields.record_id is null.Check the lead with GET /v1/leads/{id} or manually.

valid means the value is correct in Meliora's data. Whether that record exists in your CRM is for your integration to check.

On events, record_id_status is currently always unknown. See the warning above.

#Why not the username

A Telegram username (username) doesn't work as a mapping key:

  • a person can change or remove their username, and then username becomes different or null;
  • one contact in two campaigns produces two leads with different id values and the same username;
  • different people can have the same username at different times.

The GET /v1/leads?username=... filter is handy for searching and checking, but not for automatic matching. The external_id field is always null in v1: it is reserved for future versions.

#Which fields are returned

fields contains only the keys listed here, if the contact has them: record_id, first_name, last_name, company, position, website, source, utm_source, utm_medium, utm_campaign. The record_id key is always present. The others appear only when they have a value.

Not returned: phone, email, other custom columns and service data. The "Full name" column doesn't go into fields either: for the API, upload the first and last name as separate "Name" and "Last name" columns.

Values longer than 1024 characters are truncated, and the whole object is at most 8 KiB. If anything is truncated or omitted because of size, fields_truncated is true.

#CRM statuses

An example mapping for a CRM pipeline:

Event or fieldCRM status
lead.first_message_sent or a non-empty first_message_sent_atIn progress
lead.replied or a non-empty first_reply_atConnected

lead.replied arrives for every counted reply, so one lead can have several. Move the status forward only: "Connected" must never go back to "In progress", even if the first message event arrives after the reply. Merge contact and reply times the same way: keep the earliest known time, and don't replace it with a later one.

const RANK = { IN_PROGRESS: 1, CONNECTED: 2 };

// Get record_id from the lead record and remember it: events don't include it.
async function crmIdFor(tx, leadId) {
  const known = await tx.getCrmId(leadId);
  if (known !== undefined) return known;
  const lead = await meliora(`/leads/${leadId}`);
  const crmId = lead.record_id_status === 'valid' ? lead.fields.record_id : null;
  await tx.rememberCrmId(leadId, crmId);
  return crmId;
}

async function applyToCrm(tx, event) {
  const leadId = event.data.lead.id;
  const crmId = await crmIdFor(tx, leadId);
  if (!crmId) {
    await tx.queueForReview(event.id, leadId); // manual review
    return;
  }
  const target = event.type === 'lead.replied' ? 'CONNECTED' : 'IN_PROGRESS';
  const record = await tx.crm.get(crmId);
  if (!record) return tx.queueForReview(event.id, leadId);
  if ((RANK[record.status] ?? 0) < RANK[target]) await tx.crm.setStatus(crmId, target);
  if (event.type === 'lead.replied') {
    await tx.crm.addNote(crmId, `Reply in Telegram, ${event.data.reply.replied_at}`);
  }
}

#Conversations in your CRM

You can get message text in two ways:

  • from events with include_text=true: the text of the first message and of the reply;
  • from the conversation endpoint GET /v1/leads/{id}/messages: all saved messages with the lead.

What to know about the conversation archive:

  • The archive can be incomplete: conversations from before the API was connected are only partly saved, and messages sent from a phone or directly in Telegram are not included. Currently meta.completeness always reports the archive as incomplete.
  • Edits and deletions in Telegram are not tracked: the copy keeps the original text.
  • Attachments are passed only as a type in media_kind, without files. If an outgoing message had an attachment, its text may be missing from the copy.
  • A message can appear in the archive later, and with an earlier time than messages you have already read. So read a lead's entire conversation, all pages, and merge it with what you have already saved by id.
  • When text_unavailable: true, no text is returned, and the reason is in text_unavailable_reason. Don't create empty notes in your CRM for such messages.

Read conversations only for leads that had an event or a status change, not for every lead: the request has a weight of 3.

#Initial load

To connect campaigns that are already running:

  1. Get a feed cursor: GET /v1/events?start=now.
  2. Walk through GET /v1/leads for the campaigns you need, match leads to CRM records by record_id, and store the lead.id and record_id pairs for event processing.
  3. Carry statuses over based on first_message_sent_at and first_reply_at.
  4. Start polling the feed from the cursor you got in step 1.

For details of the loop, see the Polling events guide.