Skip to content

02 Get your data in /Integrations

Voiceflow

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

  1. V VoiceflowTickets, chats, calls, reviews
  2. ⟳ Your jobPull new · map fields · every 15 min
  3. { } Universal APIPOST /track · /trackhistorical
  4. Dimension LabsRead in full · enriched daily

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.

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

Use a service key for an automated job: it has its own identity, separate from any person’s account (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 also works, but acts as you, with your account’s access.

Search transcripts created since a point in time. Search is a POST with a JSON body:

Terminal window
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 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.

Dimension LabsVoiceflowNotes
textaction log: data.payload (text), data.payload.query (intent) or data.payload.label (button); trace log of type text or speak: data.payload.messageSkip launch requests, other trace types and empty text.
userIdtranscript userIDThe id of the end user who had the conversation. Use it on both sides. Fall back to the sessionId.
incominglog typetrue for action (the user), false for trace (the agent).
sessionIdtranscript idPrefix it, for example voiceflow-<id>.
conversationIdtranscript idSame value as sessionId.
dimensionlabs_timestamplog createdAtConvert with toMillis. Turns less than a second apart are spaced 1,000 ms apart.
platformJsontranscript sessionID, environmentAlias, version, properties, evaluationsProperties 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).

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.

// voiceflow.mjs. Environment: VOICEFLOW_API_KEY, VOICEFLOW_PROJECT_ID, VOICEFLOW_ENVIRONMENT (optional), DL_API_KEY.
import { sendToDimensionLabs, toMillis } from './dimensionlabs.mjs';
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;
}

Save the code as voiceflow.mjs in a project set up as in Set up your project, and run it with the 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 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 (check it with the file checker), then run a backfill.

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