# 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](https://docs.melioralab.io/en/reference/leads/#lead-fields), in both `GET /v1/leads` and `GET /v1/leads/{id}`.

> **Warning.** Feed events currently don't include `record_id`: `data.lead.fields.record_id` is `null`, and `record_id_status` is `unknown`. Link an event by `data.lead.id`: get `record_id` from `GET /v1/leads/{id}` and store the `lead.id` and `record_id` pair on your side, so you don't have to fetch the lead for every event.

```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`:

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

```js
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}`);
  }
}
```
```python
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](https://docs.melioralab.io/en/reference/messages/) 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](https://docs.melioralab.io/en/events-polling/) guide.
