# Qualtrics

> Send survey responses from Qualtrics to Dimension Labs with the universal API.

This brings in your Qualtrics survey responses: each open-text question and the respondent's answer, with scores and respondent metadata alongside. You run it on your side with the [universal API](/api/tracker), using Qualtrics' response export API.

## What you need

- A Qualtrics user with the **Access API** permission.
- A Qualtrics API token, your datacenter ID, and the ID of each survey (it starts with `SV_`).
- 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 Qualtrics

Qualtrics' steps are in [API Key Authentication](https://api.qualtrics.com/2b4ffbd8af74e-api-key-authentication) and [Base URL and Datacenter IDs](https://api.qualtrics.com/1aea264443797-base-url-and-datacenter-i-ds).

1. Log in to Qualtrics and open **Account Settings** from the user menu.
2. Go to **Qualtrics IDs**. In the **API** box, click **Generate Token**. If a token already exists, generating a new one replaces it and the old one stops working at once.
3. In the **User** box on the same page, note your datacenter ID, for example `iad1`. Your base URL is `https://YOUR_DATACENTER.qualtrics.com/API/v3`. Qualtrics says to always call your own datacenter.
4. Send the token in the `X-API-TOKEN` header. Qualtrics also supports OAuth 2.0 with the `read:survey_responses` scope.

## 2. Pull new responses

A response export runs in three calls: start it, poll it, download the file.

```bash
# 1. Start an export of the responses recorded since a date.
curl -X POST -H "X-API-TOKEN: $QUALTRICS_TOKEN" -H "Content-Type: application/json" \
  -d '{"format":"json","compress":false,"startDate":"2026-10-13T00:00:00Z"}' \
  "https://YOUR_DATACENTER.qualtrics.com/API/v3/surveys/SURVEY_ID/export-responses"

# 2. Poll until result.status is "complete". The result then has a fileId.
curl -H "X-API-TOKEN: $QUALTRICS_TOKEN" \
  "https://YOUR_DATACENTER.qualtrics.com/API/v3/surveys/SURVEY_ID/export-responses/PROGRESS_ID"

# 3. Download the file.
curl -H "X-API-TOKEN: $QUALTRICS_TOKEN" \
  "https://YOUR_DATACENTER.qualtrics.com/API/v3/surveys/SURVEY_ID/export-responses/FILE_ID/file"
```

- **Incremental:** `startDate` (and optionally `endDate`) limits the export to responses recorded in that range, in ISO 8601 UTC. Qualtrics also offers continuation tokens (`allowContinuation`, then `continuationToken`), which expire after a week; the date filter is simpler to store and never expires.
- Export files expire one week after the export completes.
- `format` can be `csv`, `tsv`, `json`, `ndjson`, `spss` or `xml`. The file comes zipped unless you send `"compress": false`, which Qualtrics recommends only for small exports. Exports over 1.8 GB fail, so load a long history in date ranges.
- The question text comes from `GET /API/v3/surveys/SURVEY_ID`: each entry in `result.questions` has `questionText` and `questionType.type`, where `TE` is a text entry question.
- **Rate limits (Qualtrics'):** 3,000 requests per minute per brand. Lower per-endpoint limits apply: 100 per minute to start an export, 1,000 per minute to check progress, 100 per minute to download the file. Wait between progress checks.

## 3. Map the fields

Each open-text question becomes an outgoing message (the question) followed by an incoming message (the answer), under the response ID. Skip blank answers.

| Dimension Labs | Qualtrics | Notes |
|---|---|---|
| `text` | `questionText` (outgoing), the text entry answer in `values` (incoming) | Question text can hold HTML. Strip it. |
| `userId` | `responseId` | No respondent ID is guaranteed, so use the response ID. Use `externalDataReference` instead if you set it to your own internal customer id (not an email). |
| `incoming` | | `false` for the question, `true` for the answer. |
| `sessionId` | `responseId` | For example `R_1QasAWMu5RPGgTs`. |
| `conversationId` | `responseId` | |
| `dimensionlabs_timestamp` | `values.recordedDate` | Convert with `toMillis`. Add 1,000 ms per message to keep the order and the one-second spacing. |
| `platformJson` | numeric `values` such as NPS or CSAT questions, `distributionChannel`, `userLanguage`, `finished`, the survey ID | Embedded data fields are a good place for account or store IDs to join on. |

**Personal data.** A response's `values` also hold fields such as `recipientEmail`, `recipientFirstName`, `ipAddress` and location: the code sends only the fields it names. Open-text answers can hold names and emails too: redact them before you send. See [PII redaction](/data/pii-redaction).

## 4. Send it

```js
// qualtrics.mjs. Environment: QUALTRICS_DC, QUALTRICS_TOKEN, QUALTRICS_SURVEY_IDS (comma-separated), DL_API_KEY.


const HOUR = 60 * 60 * 1000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const headers = { 'X-API-TOKEN': process.env.QUALTRICS_TOKEN, 'Content-Type': 'application/json' };
const strip = (s) => String(s).replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim();

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

async function exportSince(api, since) {
  const body = { format: 'json', compress: false, startDate: new Date(since).toISOString().replace(/\.\d{3}Z$/, 'Z') };
  const { result: job } = await call(`${api}/export-responses`, { method: 'POST', body: JSON.stringify(body) });
  for (let wait = 2000; ; wait = Math.min(wait * 2, 30000)) {
    const { result: progress } = await call(`${api}/export-responses/${job.progressId}`);
    if (progress.status === 'complete') return call(`${api}/export-responses/${progress.fileId}/file`);
    if (progress.status === 'failed') throw new Error('Qualtrics export failed');
    await sleep(wait);
  }
}

// Sends every response recorded at or after `since` (Unix ms), for each survey. Returns the new `since`.
export async function syncQualtrics({ since, historical = false }) {
  let newest = since;
  for (const surveyId of process.env.QUALTRICS_SURVEY_IDS.split(',').map((x) => x.trim()).filter(Boolean)) {
    const api = `https://${process.env.QUALTRICS_DC}.qualtrics.com/API/v3/surveys/${surveyId}`;
    const { result: survey } = await call(api);
    const textQuestions = Object.entries(survey.questions).filter(([, q]) => q.questionType.type === 'TE');
    const file = await exportSince(api, since);
    for (const r of file.responses ?? []) { // confirm this layout against one export file of your own
      const v = r.values;
      const scores = Object.fromEntries(Object.entries(v).filter(([k, x]) => k.startsWith('QID') && typeof x === 'number'));
      const base = { userId: r.responseId, sessionId: r.responseId, conversationId: r.responseId,
        platformJson: { surveyId, distributionChannel: v.distributionChannel, userLanguage: v.userLanguage, finished: v.finished, scores } };
      const recorded = toMillis(v.recordedDate);
      const historicalNow = historical || recorded < Date.now() - HOUR;
      let t = recorded;
      for (const [qid, q] of textQuestions) {
        const answer = Object.entries(v).find(([k, x]) => (k === qid || k.startsWith(`${qid}_`)) && typeof x === 'string' && x.trim())?.[1];
        if (!answer) continue;
        await sendToDimensionLabs({ ...base, text: strip(q.questionText), incoming: false, dimensionlabs_timestamp: t }, { historical: historicalNow });
        await sendToDimensionLabs({ ...base, text: answer.trim(), incoming: true, dimensionlabs_timestamp: t + 1000 }, { historical: historicalNow });
        t += 2000;
      }
      newest = Math.max(newest, recorded);
    }
  }
  return newest;
}
```

Qualtrics' docs describe the fields of one response (`responseId`, `values`, `labels`) but not the layout of a JSON export file, so open one file before you go live and adjust the `file.responses` line if it differs. Check the keys your text entry answers use in `values` the same way.

One Dimension Labs source can hold all your surveys: the survey ID goes in `platformJson.surveyId`, so you can filter by survey.

## 5. Run it

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

- **History first:** `node run.mjs 2025-01-01` exports and sends every response since that date to `/trackhistorical`. Then start a [backfill](/dimensions/backfill) from the **Backfill** tab.
- **Then every 15 minutes:** the scheduled job sends new responses to `/track`, and they are enriched automatically every day. A response 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.

You can also export responses from the survey's **Data & Analysis** tab, [upload the file](/data/upload-a-file) (see [Wide survey exports](/transform/recipe-wide-survey)), and check it first with the [file checker](/data/check-a-file), then run a backfill.

## Qualtrics reference

- [API Key Authentication](https://api.qualtrics.com/2b4ffbd8af74e-api-key-authentication)
- [Base URL and Datacenter IDs](https://api.qualtrics.com/1aea264443797-base-url-and-datacenter-i-ds)
- [Survey Response Export Guide](https://api.qualtrics.com/u9e5lh4172v0v-survey-response-export-guide)
- [Surveys Response Import/Export API](https://api.qualtrics.com/206a07d54ca31-surveys-response-import-export-api)
- [Start Response Export](https://api.qualtrics.com/6b00592b9c013-start-response-export)
- [Get Response Export Progress](https://api.qualtrics.com/37e6a66f74ab4-get-response-export-progress)
- [Get Response Export File](https://api.qualtrics.com/41296b6f2e828-get-response-export-file)
- [Get Survey](https://api.qualtrics.com/73d7e07ec68b2-get-survey)
- [Retrieve a Survey Response](https://api.qualtrics.com/1179a68b7183c-retrieve-a-survey-response)
- [Limits](https://api.qualtrics.com/a5e9a1a304902-limits)
- [Getting Started with the Qualtrics API](https://www.qualtrics.com/support/integrations/api-integration/overview/)

Checked against Qualtrics' documentation on 2026-10-13.
