CRM mapping
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}.
{
"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:
| Value | What it means | What to do |
|---|---|---|
valid | A single, consistent value. | Look up the CRM record by fields.record_id. |
missing | There is no value. | Match manually or by your own rules. |
conflict | The contact record has two different values. fields.record_id is null. | Fix the record. Don't pick a value yourself. |
unknown | The 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
usernamebecomes different ornull; - one contact in two campaigns produces two leads with different
idvalues 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 field | CRM status |
|---|---|
lead.first_message_sent or a non-empty first_message_sent_at | In progress |
lead.replied or a non-empty first_reply_at | Connected |
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}`);
}
}RANK = {"IN_PROGRESS": 1, "CONNECTED": 2}
def crm_id_for(tx, lead_id):
# Get record_id from the lead record and remember it: events don't include it.
known = tx.get_crm_id(lead_id)
if known is not None:
return known or None
lead = meliora(f"/leads/{lead_id}")
crm_id = lead["fields"]["record_id"] if lead["record_id_status"] == "valid" else None
tx.remember_crm_id(lead_id, crm_id or "")
return crm_id
def apply_to_crm(tx, event):
lead_id = event["data"]["lead"]["id"]
crm_id = crm_id_for(tx, lead_id)
if not crm_id:
tx.queue_for_review(event["id"], lead_id) # manual review
return
target = "CONNECTED" if event["type"] == "lead.replied" else "IN_PROGRESS"
record = tx.crm.get(crm_id)
if record is None:
tx.queue_for_review(event["id"], lead_id)
return
if RANK.get(record.status, 0) < RANK[target]:
tx.crm.set_status(crm_id, target)
if event["type"] == "lead.replied":
tx.crm.add_note(crm_id, f"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.completenessalways 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 intext_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:
- Get a feed cursor:
GET /v1/events?start=now. - Walk through
GET /v1/leadsfor the campaigns you need, match leads to CRM records byrecord_id, and store thelead.idandrecord_idpairs for event processing. - Carry statuses over based on
first_message_sent_atandfirst_reply_at. - Start polling the feed from the cursor you got in step 1.
For details of the loop, see the Polling events guide.