# Voiceflow

> Send agent conversations from Voiceflow to Dimension Labs with the universal API.

This brings in your Voiceflow agent conversations, turn by turn: what each user typed or said and what the agent answered, in chat and voice. Each Voiceflow transcript becomes one conversation. You run it on your side: a small script pulls from the Voiceflow transcript API and posts each message to the [universal API](/api/tracker).

## What you need

- A Voiceflow organization admin with permission to manage the organization, to create a service key.
- The project id: in the project, **Settings > General > Metadata**.
- 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 Voiceflow

Use a service key for an automated job: it has its own identity, separate from any person's account ([service keys](https://www.voiceflow.com/docs/developers/authentication/service-keys)).

1. Open **Settings > Service keys** and choose **New key**.
2. Give the key a name. Under **Grant access to these projects**, pick the project, and under **With these permissions**, choose only what the job needs: reading transcripts.
3. Choose an expiration date, and plan to replace the key before it lapses.
4. Submit the form and copy the key from the confirmation dialog. Voiceflow shows it only once.

Send it as a bearer token: `Authorization: Bearer YOUR_KEY`. A [personal key](https://www.voiceflow.com/docs/developers/authentication/personal-keys) also works, but acts as you, with your account's access.

## 2. Pull new transcripts

[Search transcripts](https://www.voiceflow.com/docs/api-reference/transcript/search-transcripts) created since a point in time. Search is a `POST` with a JSON body:

```bash
curl -X POST "https://realtime-api.voiceflow.com/v1/stable/transcript/search?projectID=$VOICEFLOW_PROJECT_ID" \
  -H "Authorization: Bearer $VOICEFLOW_API_KEY" -H "Content-Type: application/json" \
  -d '{"take":100,"skip":0,"startDate":"2026-10-01T00:00:00.000Z","version":"published"}'
```

- `startDate` and `endDate` filter on when the transcript was created, in ISO 8601 UTC ending in `Z`.
- Page with `take` (default 20, at most 100) and `skip`.
- `version: "published"` leaves out conversations on the draft version. Put `environmentAlias` in the body, not the query string, to pick an environment other than the main one.
- Search returns each transcript's `id`, `userID`, `sessionID`, `createdAt`, `properties` and `evaluations`, but not the conversation. [Get each transcript](https://www.voiceflow.com/docs/api-reference/transcript/get-transcript) with `GET /v1/stable/transcript/{id}?projectID=...` for its `logs`.
- Logs of type `action` are what the user sent: a text request has the text in `payload`, an intent request in `payload.query`, a button press in `payload.label`. Logs of type `trace` are what the agent sent: `text` traces (chat) and `speak` traces (voice) carry it in `payload.message`.
- Transcripts are written asynchronously, a few seconds after the conversation, and a conversation keeps adding turns until it ends. The code re-reads the transcripts created in the last two hours on every run.
- Voiceflow notes that trace payload strings can contain raw control characters, which some JSON parsers reject. The code strips them before parsing.

Voiceflow's transcript docs don't list a rate limit. The code sends one request at a time and retries `429` and `5xx` responses.

## 3. Map the fields

| Dimension Labs | Voiceflow | Notes |
|---|---|---|
| `text` | `action` log: `data.payload` (text), `data.payload.query` (intent) or `data.payload.label` (button); `trace` log of type `text` or `speak`: `data.payload.message` | Skip launch requests, other trace types and empty text. |
| `userId` | transcript `userID` | The id of the end user who had the conversation. Use it on both sides. Fall back to the `sessionId`. |
| `incoming` | log `type` | `true` for `action` (the user), `false` for `trace` (the agent). |
| `sessionId` | transcript `id` | Prefix it, for example `voiceflow-<id>`. |
| `conversationId` | transcript `id` | Same value as `sessionId`. |
| `dimensionlabs_timestamp` | log `createdAt` | Convert with `toMillis`. Turns less than a second apart are spaced 1,000 ms apart. |
| `platformJson` | transcript `sessionID`, `environmentAlias`, `version`, `properties`, `evaluations` | Properties and evaluation results become `name: value` pairs, so you can filter on your own outcome labels. |

Voiceflow's own PII redaction, if you turn it on, replaces names, emails and phone numbers in transcripts with markers such as `[EMAIL_ADDRESS]` ([PII redaction](https://www.voiceflow.com/docs/documentation/build/data/pii-redaction)).

**Personal data.** Check what your deployment uses as `userID`: an integration you built may pass an email address or phone number. If so, use the `sessionId` instead. Users sometimes type or say names, emails or order numbers: redact them before you send. See [PII redaction](/data/pii-redaction).

## 4. Send it

```js
// voiceflow.mjs. Environment: VOICEFLOW_API_KEY, VOICEFLOW_PROJECT_ID, VOICEFLOW_ENVIRONMENT (optional), DL_API_KEY.


const HOUR = 60 * 60 * 1000;
const LOOKBACK = 2 * HOUR; // re-read recent transcripts: they keep growing until the conversation ends
const TAKE = 100;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const BASE = 'https://realtime-api.voiceflow.com/v1/stable';
const PROJECT = encodeURIComponent(process.env.VOICEFLOW_PROJECT_ID);

// Retries Voiceflow's rate limits (429) and server errors (5xx), honoring Retry-After.
// Strips raw control characters, which Voiceflow says trace payloads can contain.
async function call(path, body) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(`${BASE}${path}`, {
      method: body ? 'POST' : 'GET',
      headers: { Authorization: `Bearer ${process.env.VOICEFLOW_API_KEY}`, ...(body && { 'Content-Type': 'application/json' }) },
      body: body && JSON.stringify(body),
    });
    if (res.ok) return JSON.parse((await res.text()).replace(/[\u0000-\u001f]/g, ' '));
    if ((res.status !== 429 && res.status < 500) || attempt === 5) throw new Error(`Voiceflow returned ${res.status}: ${await res.text()}`);
    await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000);
  }
}

// What the user sent (an action log) or what the agent said (a text or speak trace).
function textOf(log) {
  const d = log.data ?? {};
  if (log.type === 'action') {
    if (d.type === 'launch') return null;
    if (typeof d.payload === 'string') return d.payload;
    return d.payload?.query ?? d.payload?.label ?? d.payload?.message ?? null;
  }
  if (log.type === 'trace' && (d.type === 'text' || d.type === 'speak')) return d.payload?.message ?? null;
  return null;
}

const pairs = (list = []) => Object.fromEntries(list.map((p) => [p.name, p.value]));

// Sends every turn of the transcripts created since `since` (Unix ms), minus the lookback. Returns the new `since`.
export async function syncVoiceflow({ since, historical = false }) {
  const started = Date.now();
  const filter = { take: TAKE, startDate: new Date(since - LOOKBACK).toISOString(), version: 'published' };
  if (process.env.VOICEFLOW_ENVIRONMENT) filter.environmentAlias = process.env.VOICEFLOW_ENVIRONMENT;
  const found = [];
  for (let skip = 0; ; skip += TAKE) {
    const page = await call(`/transcript/search?projectID=${PROJECT}`, { ...filter, skip });
    found.push(...(page.transcripts ?? []));
    if ((page.transcripts ?? []).length < TAKE) break;
  }

  for (const summary of found) {
    const { transcript: t } = await call(`/transcript/${encodeURIComponent(summary.id)}?projectID=${PROJECT}`);
    const id = `voiceflow-${t.id}`.replace(/:/g, '-');
    const platformJson = {
      voiceflow_session_id: t.sessionID, environment: t.environmentAlias, version: t.version,
      properties: pairs(t.properties), evaluations: pairs(t.evaluations),
    };
    const logs = (t.logs ?? []).slice().sort((a, b) => toMillis(a.createdAt) - toMillis(b.createdAt));
    let last = 0;
    for (const log of logs) {
      const text = textOf(log);
      if (typeof text !== 'string' || !text.trim()) continue;
      const ts = Math.max(toMillis(log.createdAt), last + 1000); // keep turns at least a second apart
      last = ts;
      await sendToDimensionLabs({
        text,
        userId: t.userID ? String(t.userID).replace(/:/g, '-') : id,
        incoming: log.type === 'action',
        sessionId: id,
        conversationId: id,
        dimensionlabs_timestamp: ts,
        platformJson,
      }, { historical: historical || ts < Date.now() - HOUR });
    }
  }
  return started;
}
```

## 5. Run it

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

- **History first:** `node run.mjs 2026-01-01` sends every transcript created since that date to `/trackhistorical`. Each transcript has an `expiresAt` time, so history reaches back only as far as Voiceflow still keeps it. Then start a [backfill](/dimensions/backfill) from the **Backfill** tab.
- **Then every 15 minutes:** the scheduled job sends new messages to `/track`, and they are enriched automatically every day. A message that is more than an hour old when the job sees it goes to `/trackhistorical`, and needs a backfill.
- **Re-runs are safe:** duplicates are removed automatically. Each run re-sends the last two hours of transcripts, so late turns are caught; the repeats are dropped. Raise `LOOKBACK` if your conversations run longer.

Each transcript costs one call, so a long history takes a while. You can also write the converted messages to a JSONL file instead of posting them, [upload it](/data/upload-a-file) (check it with the [file checker](/data/check-a-file)), then run a backfill.

## Voiceflow reference

- [Service keys](https://www.voiceflow.com/docs/developers/authentication/service-keys)
- [Personal keys](https://www.voiceflow.com/docs/developers/authentication/personal-keys)
- [Permissions and scopes](https://www.voiceflow.com/docs/developers/authentication/permissions)
- [Search transcripts](https://www.voiceflow.com/docs/api-reference/transcript/search-transcripts)
- [Get transcript](https://www.voiceflow.com/docs/api-reference/transcript/get-transcript)
- [Find and label a transcript](https://www.voiceflow.com/docs/cookbook/find-a-conversation)
- [Messages and UI traces](https://www.voiceflow.com/docs/api-reference/traces/messages-ui)
- [Transcripts](https://www.voiceflow.com/docs/documentation/measure/transcripts)
- [PII redaction](https://www.voiceflow.com/docs/documentation/build/data/pii-redaction)

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