02 Get your data in /Integrations
Voiceflow
Send agent conversations from Voiceflow to Dimension Labs with the universal API.
- V VoiceflowTickets, chats, calls, reviews
- ⟳ Your jobPull new · map fields · every 15 min
- { } Universal APIPOST /track · /trackhistorical
- 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.
What you need
Section titled “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.
1. Get API access in Voiceflow
Section titled “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).
- Open Settings > Service keys and choose New key.
- 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.
- Choose an expiration date, and plan to replace the key before it lapses.
- 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.
2. Pull new transcripts
Section titled “2. Pull new transcripts”Search transcripts created since a point in time. Search is a POST with a JSON body:
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"}'startDateandendDatefilter on when the transcript was created, in ISO 8601 UTC ending inZ.- Page with
take(default 20, at most 100) andskip. version: "published"leaves out conversations on the draft version. PutenvironmentAliasin the body, not the query string, to pick an environment other than the main one.- Search returns each transcript’s
id,userID,sessionID,createdAt,propertiesandevaluations, but not the conversation. Get each transcript withGET /v1/stable/transcript/{id}?projectID=...for itslogs. - Logs of type
actionare what the user sent: a text request has the text inpayload, an intent request inpayload.query, a button press inpayload.label. Logs of typetraceare what the agent sent:texttraces (chat) andspeaktraces (voice) carry it inpayload.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
Section titled “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).
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.
4. Send it
Section titled “4. Send it”// 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 endsconst 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
Section titled “5. Run it”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-01sends every transcript created since that date to/trackhistorical. Each transcript has anexpiresAttime, 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
LOOKBACKif 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.
Voiceflow reference
Section titled “Voiceflow reference”- Service keys
- Personal keys
- Permissions and scopes
- Search transcripts
- Get transcript
- Find and label a transcript
- Messages and UI traces
- Transcripts
- PII redaction
Checked against Voiceflow’s documentation on 2026-10-26.