# LivePerson Conversational Cloud

> Send LivePerson Conversational Cloud messaging conversations to Dimension Labs with the universal API.

This brings in each LivePerson messaging conversation as one conversation: what the consumer wrote and what your agents and bots replied, across web, app, SMS and social channels. You run it on your side: a small script pulls from LivePerson's Messaging Interactions API and posts each message to the [universal API](/api/tracker).

## What you need

- A Conversational Cloud user with Administrator permissions, to create an OAuth 2.0 application.
- Your LivePerson account ID (your site ID), and the `client_id` and `client_secret` of that application.
- A Dimension Labs API key for a new **Universal API** source. See [Your API key](/api/tracker#your-api-key).

## 1. Get API access in LivePerson

LivePerson recommends OAuth 2.0 client credentials for machine-to-machine access, and the Messaging Interactions API supports it ([OAuth 2.0 client credentials](https://developers.liveperson.com/oauth-2-0-client-credentials.html)).

1. Log in to Conversational Cloud with Administrator permissions and open **Manage > Management Console**.
2. Search for "Application Install", select it, then click **Proceed to Configure**.
3. Click **Create Application** and configure a `client_credentials` application. For the Messaging Interactions API, use the scope `data.services.external.msghist` ([Messaging Interactions API overview](https://developers.liveperson.com/messaging-interactions-api-overview.html)).
4. Save. Select **edit** on the new application in the list to copy its `client_id` and `client_secret`.

Your LivePerson account team can also install the application for you.

To get an access token, look up your authorization server with the [Domain API](https://developers.liveperson.com/domain-api.html) (service name `sentinel`), then post your credentials to it:

```bash
curl -X POST "https://SENTINEL_DOMAIN/sentinel/api/v2/account/$LP_ACCOUNT_ID/app/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "client_id=$LP_CLIENT_ID&client_secret=$LP_CLIENT_SECRET&grant_type=client_credentials"
```

The response holds an `access_token`. Send it as `Authorization: Bearer ACCESS_TOKEN`.

## 2. Pull new conversations

The Messaging Interactions API lives on an account-specific domain. Look it up with the Domain API, using the service name `msgHist`:

```bash
curl "https://api.liveperson.net/api/account/$LP_ACCOUNT_ID/service/msgHist/baseURI.json?version=1.0"
```

Then [search conversations](https://developers.liveperson.com/messaging-interactions-api-methods-conversations.html) by start time:

```bash
curl -X POST "https://MSGHIST_DOMAIN/messaging_history/api/account/$LP_ACCOUNT_ID/conversations/search?offset=0&limit=100&sort=start:asc&source=DimensionLabsSync" \
  -H "Authorization: Bearer $LP_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"start":{"from":1760313600000,"to":1760918400000},"contentToRetrieve":["messageRecords","consumerParticipants"]}'
```

- `start` is required: epoch milliseconds, at most three months per request, rounded to the nearest 10 minutes.
- `limit` goes up to 100. Page with `offset`; `_metadata.count` is the total number of matches.
- `source` is required: a name for your process, 20 characters at most.
- When you send `contentToRetrieve`, list every type you want, including default ones such as `messageRecords`.
- LivePerson keeps conversations for 13 months. The API can lag by up to a minute.
- If a conversation's `info.isPartial` is `true`, its data was truncated. Get it in full with [Get conversation by conversation ID](https://developers.liveperson.com/messaging-interactions-api-methods-get-conversation-by-conversation-id.html).
- The API returns `429` when you send too many requests. LivePerson's docs don't publish the limits.

## 3. Map the fields

| Dimension Labs | LivePerson | Notes |
|---|---|---|
| `text` | `messageRecords[].messageData.msg.text` | Skip messages whose `audience` is `AGENTS_AND_MANAGERS`: those are private messages consumers can't see. Skip messages with no text, such as files. |
| `userId` | `consumerParticipants[].participantId` | LivePerson's id for the consumer, on every message of the conversation. Fall back to the `sessionId`. |
| `incoming` | `messageRecords[].sentBy` | `true` when `sentBy` is `Consumer`. Agents and bots are `Agent`. |
| `sessionId` | `info.conversationId` | Prefix it, for example `liveperson-e5c58e49-...`. |
| `conversationId` | `info.conversationId` | Same value as `sessionId`. |
| `dimensionlabs_timestamp` | `messageRecords[].timeL` | Already in milliseconds. |
| `platformJson` | `info.source`, `info.device`, `info.latestSkillName`, `info.status`, `info.mcs`, `info.csatRate`; message `dialogId` | The skill name lets you group by team or queue. |

**Personal data.** Don't send `consumerParticipants[].firstName`, `lastName`, `email` or `phone`, or the agent names and login names in `info`. Messages can hold names, addresses and account numbers: redact them before you send. See [PII redaction](/data/pii-redaction).

## 4. Send it

```js
// liveperson.mjs. Environment: LP_ACCOUNT_ID, LP_CLIENT_ID, LP_CLIENT_SECRET, DL_API_KEY.


const HOUR = 60 * 60 * 1000;
const DAY = 24 * HOUR;
const WINDOW = 89 * DAY; // LivePerson allows at most three months per search
const SOURCE = 'DimensionLabsSync'; // LivePerson asks each caller to name itself, 20 characters at most
const CONTENT = ['messageRecords', 'consumerParticipants'];
const account = process.env.LP_ACCOUNT_ID;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// Retries rate limits (429) and server errors (5xx), honoring Retry-After.
async function call(url, options = {}) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, options);
    if (res.ok) return res.json();
    if ((res.status !== 429 && res.status < 500) || attempt === 5) throw new Error(`LivePerson returned ${res.status}: ${await res.text()}`);
    await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000);
  }
}

const domain = async (service) =>
  (await call(`https://api.liveperson.net/api/account/${account}/service/${service}/baseURI.json?version=1.0`)).baseURI;

async function getToken() {
  const sentinel = await domain('sentinel');
  const body = new URLSearchParams({ client_id: process.env.LP_CLIENT_ID, client_secret: process.env.LP_CLIENT_SECRET, grant_type: 'client_credentials' });
  const res = await call(`https://${sentinel}/sentinel/api/v2/account/${account}/app/token`, {
    method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body,
  });
  return res.access_token;
}

// Posts a search. Gets a new token once if the current one has expired (401).
let token = null;
async function search(url, body) {
  for (let fresh = false; ; fresh = true) {
    token ??= await getToken();
    try {
      return await call(url, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
    } catch (err) {
      if (fresh || !String(err.message).includes(' 401')) throw err;
      token = null;
    }
  }
}

// Sends every ended conversation started at or after `since` (Unix ms). Returns the new `since`.
export async function syncLivePerson({ since, historical = false }) {
  const base = `https://${await domain('msgHist')}/messaging_history/api/account/${account}/conversations`;
  const now = Date.now();
  let newest = since;
  let holdAt = Infinity; // the start of the oldest conversation that is still open
  for (let from = since; from < now; from += WINDOW) {
    const to = Math.min(from + WINDOW, now);
    for (let offset = 0; ; ) {
      const page = await search(`${base}/search?offset=${offset}&limit=100&sort=start:asc&source=${SOURCE}`, { start: { from, to }, contentToRetrieve: CONTENT });
      const records = page.conversationHistoryRecords ?? [];
      for (let r of records) {
        const started = r.info.startTimeL;
        if (r.info.status !== 'CLOSE' && started > now - DAY) { holdAt = Math.min(holdAt, started); continue; } // send it once it ends
        if (r.info.isPartial) {
          const full = await search(`${base}/conversation/search?source=${SOURCE}`, { conversationId: r.info.conversationId, contentToRetrieve: CONTENT });
          r = full.conversationHistoryRecords?.[0] ?? r;
        }
        const id = `liveperson-${r.info.conversationId}`;
        const consumer = r.consumerParticipants?.[0]?.participantId;
        for (const m of r.messageRecords ?? []) {
          const text = m.messageData?.msg?.text;
          if (m.audience === 'AGENTS_AND_MANAGERS' || !text?.trim()) continue;
          const ts = toMillis(m.timeL);
          await sendToDimensionLabs({
            text,
            userId: consumer ?? id,
            incoming: String(m.sentBy).toLowerCase() === 'consumer',
            sessionId: id,
            conversationId: id,
            dimensionlabs_timestamp: ts,
            platformJson: { source: r.info.source, device: r.info.device, skill: r.info.latestSkillName, status: r.info.status, mcs: r.info.mcs, csat: r.info.csatRate, dialogId: m.dialogId },
          }, { historical: historical || ts < Date.now() - HOUR });
        }
        newest = Math.max(newest, started);
      }
      offset += records.length;
      if (!records.length || offset >= (page._metadata?.count ?? 0)) break;
    }
  }
  return Math.min(newest, holdAt);
}
```

A conversation that is still open is skipped, and the cursor stays at its start, so the next run picks it up once it has closed. Later conversations that already closed are sent straight away; when the cursor is held, they come round again on the next run and the duplicates are removed automatically. A conversation left open for more than a day is sent as it is, so one forgotten conversation can't hold the cursor back.

## 5. Run it

Save the code as `liveperson.mjs` in a project set up as in [Set up your project](/api/tracker#set-up-your-project), and run it with the [scheduled sync](/api/scheduled-sync) runner: `import { syncLivePerson as sync } from './liveperson.mjs';`.

- **History first:** `node run.mjs 2025-11-01` sends every conversation since that date to `/trackhistorical`. LivePerson keeps 13 months, so start no earlier than that. Then start a [backfill](/dimensions/backfill) from the **Backfill** tab.
- **Then every 15 minutes:** the scheduled job sends closed conversations to `/track`, and they are enriched automatically every day. Because a conversation is sent once it closes, the first messages of a conversation that ran for more than an hour go to `/trackhistorical`, and need a backfill.
- **Re-runs are safe:** duplicates are removed automatically.

The code waits whenever LivePerson returns `429`. You can also [upload a file](/data/upload-a-file) of exported conversations with one row per message (check it with the [file checker](/data/check-a-file)), then run a backfill.

## LivePerson reference

- [Messaging Interactions API overview](https://developers.liveperson.com/messaging-interactions-api-overview.html)
- [Conversations (search)](https://developers.liveperson.com/messaging-interactions-api-methods-conversations.html)
- [Get conversation by conversation ID](https://developers.liveperson.com/messaging-interactions-api-methods-get-conversation-by-conversation-id.html)
- [OAuth 2.0 client credentials](https://developers.liveperson.com/oauth-2-0-client-credentials.html)
- [Domain API](https://developers.liveperson.com/domain-api.html)

Checked against LivePerson's documentation on 2026-10-26.
