# RingCentral

> Send call transcripts from RingCentral to Dimension Labs with the universal API.

This brings in transcripts of recorded RingCentral RingEX calls, turn by turn, from AI Conversation Expert (ACE, formerly RingSense). One call becomes one conversation in Dimension Labs. You run it on your side: a small script pulls from the RingCentral API and posts each message to the [universal API](/api/tracker).

## What you need

- **An AI Conversation Expert licence.** ACE is a paid add-on: the licence has to be bought and assigned to the users whose calls you want. RingCentral only transcribes calls that are recorded by a user with an active ACE licence, and keeps ACE data for 1 year by default (set in your ACE settings).
- **Access to the AI Conversation Expert API, which is in beta.** Your app needs the `RingSense` app scope. If the Developer Console doesn't offer it, request it from RingCentral. RingCentral's developer team says ACE is the only API for call transcripts.
- **Call recording** on the calls you want, automatic or on demand. RingCentral doesn't keep recordings shorter than 30 seconds.
- A RingCentral user for the sync whose role has the **AI Conversation Expert - Access Insights** permission (to read ACE data for every licensed user) and can read the company call log.
- A Dimension Labs API key for a new **Universal API** source. See [Your API key](/api/tracker#your-api-key).

This covers RingEX voice calls. RingCentral has no API to find the recordings of RingCX calls, so their insights only arrive through ACE event notifications, which this script doesn't use.

## 1. Get API access in RingCentral

1. In the RingCentral Developer Console, go to **Apps** and select **Create App**. Choose **REST API App**, then under **Authentication** select **JWT auth flow**. RingCentral's steps are in [JWT quick start](https://developers.ringcentral.com/guide/authentication/jwt/quick-start).
2. Under **Security**, add the `ReadCallLog` and `RingSense` permissions, and make the app private to your account. Note the client ID and client secret.
3. Signed in as the sync user, open **Credentials** (under your name in the top right) and select **Create JWT**. RingCentral's steps are in [Create a JWT credential](https://developers.ringcentral.com/guide/getting-started/create-credential).
4. Get a token: `POST https://platform.ringcentral.com/restapi/oauth/token` with the header `Authorization: Basic BASE64(client_id:client_secret)` and the body `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=YOUR_JWT`. Send the `access_token` from the response as `Authorization: Bearer YOUR_TOKEN`.

To check the sync user's permission, send `GET /restapi/v1.0/account/~/extension/~/authz-profile/check?permissionId=ReadRingSenseInsights`: `"successful": true` means it can read ACE data.

## 2. Pull new calls

Find recorded calls in the company call log, then get each recording's ACE insights:

```bash
curl -H "Authorization: Bearer $RC_TOKEN" \
  "https://platform.ringcentral.com/restapi/v1.0/account/~/call-log?recordingType=All&dateFrom=2026-10-25T00:00:00.000Z&dateTo=2026-10-26T00:00:00.000Z&perPage=100"

curl -H "Authorization: Bearer $RC_TOKEN" \
  "https://platform.ringcentral.com/ai/ringsense/v1/public/accounts/~/domains/pbx/records/RECORDING_ID/insights"
```

- `recordingType=All` returns only calls with a recording. `dateFrom` and `dateTo` filter by the call's start time, in ISO 8601 with a time zone. The code looks back `RC_LOOKBACK_HOURS` (24 by default) before the cursor and keeps calls that ended since the last run (`startTime` plus `duration`).
- Follow `navigation.nextPage.uri` for the next page.
- Each record's `recording.id` is the `sourceRecordId` ACE needs. A call transferred between agents can have a recording per leg, all under one `telephonySessionId`.
- The insights response has `recordingStartTime`, `speakerInfo[]` and `insights.Transcript[]`, each turn with `text`, `start` and `end` (seconds into the recording) and `speakerId`. Look the speaker up in `speakerInfo`: a speaker with an `extensionId` is a user on your account; a call party outside the account has `extensionId` `null`.
- The response also holds `Summary`, `NextSteps`, `Sentiment` and other insights. The code keeps the overall `Sentiment`.

## 3. Map the fields

| Dimension Labs | RingCentral | Notes |
|---|---|---|
| `text` | `insights.Transcript[].text` | Skip empty turns. |
| `userId` | `sourceSessionId` | ACE gives no id for the outside party, only a name. The code uses the `sessionId`. |
| `incoming` | `speakerInfo[].extensionId` | `false` when the turn's speaker has an `extensionId` (a user on your account). `true` otherwise. |
| `sessionId` | `sourceSessionId` | The telephony session id: all recorded legs of one call share it. |
| `conversationId` | `sourceSessionId` | Same value as `sessionId`. |
| `dimensionlabs_timestamp` | `recordingStartTime` plus `Transcript[].start` | `start` is seconds into the recording. Turns closer than a second apart are spaced to 1,000 ms. |
| `platformJson` | `sourceRecordId`, `callDirection`, `domain`, `insights.Sentiment` | Leave out `title` and `speakerInfo[].name`: they hold the caller's and agent's names. Leave out the call log's `from` and `to`: they are phone numbers and names. |

**Personal data.** Transcripts hold what people say: names, phone numbers, card and account numbers. ACE names the speakers and builds the call's `title` from their names, so the code never sends either. Redact the text before you send. See [PII redaction](/data/pii-redaction).

## 4. Send it

```js
// ringcentral.mjs. Environment: RC_CLIENT_ID, RC_CLIENT_SECRET, RC_JWT, RC_SERVER (default https://platform.ringcentral.com),
// RC_LOOKBACK_HOURS (default 24), RC_DELAY_MINUTES (default 60), DL_API_KEY.


const HOUR = 60 * 60 * 1000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const SERVER = process.env.RC_SERVER ?? 'https://platform.ringcentral.com';
const LOOKBACK = Number(process.env.RC_LOOKBACK_HOURS ?? 24) * HOUR;
const DELAY = Number(process.env.RC_DELAY_MINUTES ?? 60) * 60 * 1000;
let token;

async function newToken() {
  const basic = Buffer.from(`${process.env.RC_CLIENT_ID}:${process.env.RC_CLIENT_SECRET}`).toString('base64');
  const res = await fetch(`${SERVER}/restapi/oauth/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: `Basic ${basic}` },
    body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: process.env.RC_JWT }),
  });
  if (!res.ok) throw new Error(`RingCentral token request returned ${res.status}: ${await res.text()}`);
  token = (await res.json()).access_token;
}

// Gets a fresh token on 401, and retries rate limits (429) and server errors (5xx). Returns null on 404 (no insights for that recording).
async function get(url) {
  for (let attempt = 0; ; attempt++) {
    if (!token) await newToken();
    const res = await fetch(url.startsWith('http') ? url : `${SERVER}${url}`, { headers: { Authorization: `Bearer ${token}` } });
    if (res.ok) return res.json();
    if (res.status === 404) return null;
    if (res.status === 401 && attempt === 0) { token = null; continue; }
    if ((res.status !== 429 && res.status < 500) || attempt === 5) throw new Error(`RingCentral returned ${res.status} for ${url}: ${await res.text()}`);
    await sleep((Number(res.headers.get('retry-after')) || (res.status === 429 ? 30 : 2 ** attempt)) * 1000);
  }
}

// Recorded calls that started in [from, to), from the company call log.
async function* recordedCalls(from, to) {
  const q = new URLSearchParams({ recordingType: 'All', dateFrom: new Date(from).toISOString(), dateTo: new Date(to).toISOString(), perPage: '100' });
  let url = `/restapi/v1.0/account/~/call-log?${q}`;
  while (url) {
    const page = await get(url);
    yield* page?.records ?? [];
    url = page?.navigation?.nextPage?.uri ?? null;
  }
}

// Sends every recorded call that ended at or after `since` (Unix ms). Returns the new `since`.
export async function syncRingCentral({ since, historical = false }) {
  token = null; // a new token for each run
  const until = Date.now() - DELAY; // give AI Conversation Expert time to analyze the recording
  if (until <= since) return since;
  const seen = new Set();
  for await (const c of recordedCalls(since - LOOKBACK, until)) {
    const recordingId = c.recording?.id;
    if (!recordingId || seen.has(recordingId)) continue;
    seen.add(recordingId);
    const ended = toMillis(c.startTime) + (c.duration ?? 0) * 1000;
    if (ended < since || ended >= until) continue;
    const ace = await get(`/ai/ringsense/v1/public/accounts/~/domains/pbx/records/${recordingId}/insights`);
    if (!ace?.insights?.Transcript?.length) continue;
    // A speaker with an extensionId is a user on your account (your side); anyone else is the customer.
    const ours = new Set((ace.speakerInfo ?? []).filter((s) => s.extensionId).map((s) => s.speakerId));
    const sessionId = `ringcentral-${ace.sourceSessionId ?? c.telephonySessionId ?? c.sessionId}`;
    const start = toMillis(ace.recordingStartTime);
    let last = 0;
    for (const t of ace.insights.Transcript) {
      if (!t.text?.trim()) continue;
      const ts = Math.max(start + Math.round((t.start ?? 0) * 1000), last + 1000); // keep turns at least a second apart
      last = ts;
      await sendToDimensionLabs({
        text: t.text,
        userId: sessionId,
        incoming: !ours.has(t.speakerId),
        sessionId,
        conversationId: sessionId,
        dimensionlabs_timestamp: ts,
        platformJson: { recordingId, direction: ace.callDirection, domain: ace.domain, sentiment: ace.insights.Sentiment?.[0]?.value },
      }, { historical: historical || ts < Date.now() - HOUR });
    }
  }
  return until;
}
```

## 5. Run it

Save the code as `ringcentral.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 { syncRingCentral as sync } from './ringcentral.mjs';`.

- **History first:** `node run.mjs 2026-01-01` sends every recorded call since that date to `/trackhistorical`. Then start a [backfill](/dimensions/backfill) from the **Backfill** tab. Only calls ACE analyzed, and still inside your ACE retention period, have a transcript.
- **Then every 15 minutes:** the scheduled job sends calls that ended since the last run to `/track`, and they are enriched automatically every day. ACE analyzes a call after the recording ends, so the code waits `RC_DELAY_MINUTES` (60 by default). A call whose insights aren't ready by then is skipped, so raise the delay if yours take longer. Turns more than an hour old by then go to `/trackhistorical`, and need a backfill.
- **Re-runs are safe:** duplicates are removed automatically.
- **Rate limits:** RingCentral limits requests per user per minute by group, and the company call log is in the Heavy group (10 requests a minute by default). Check your app's limits under **Rate Limits** in the Developer Console. The code waits whenever RingCentral returns `429`, for `Retry-After` seconds, or 30 seconds without it.

Without an ACE licence there is no transcript to pull through the API. You can still [upload a file](/data/upload-a-file) of transcripts you have, check it first with the [file checker](/data/check-a-file), then run a backfill.

## RingCentral reference

- [AI Conversation Expert API](https://developers.ringcentral.com/ringsense-api)
- [AI Conversation Expert (ACE) developer guide](https://developers.ringcentral.com/guide/ai/ace)
- [Call recordings](https://developers.ringcentral.com/guide/voice/call-log/recordings)
- [Call log access control](https://developers.ringcentral.com/guide/voice/call-log/access)
- [JWT quick start](https://developers.ringcentral.com/guide/authentication/jwt/quick-start)
- [JWT flow](https://developers.ringcentral.com/guide/authentication/jwt-flow)
- [Create a JWT credential](https://developers.ringcentral.com/guide/getting-started/create-credential)
- [Rate limits](https://developers.ringcentral.com/guide/basics/rate-limits)
- [Developer community: transcriptions and API access](https://community.ringcentral.com/developer-platform-apis-integrations-5/transcriptions-and-api-access-10836)

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