# QuestionPro

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

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

## What you need

- A QuestionPro license that includes API calls. QuestionPro lists its free API call allowance per license on its [Limits](https://www.questionpro.com/api/limits.html) page.
- A QuestionPro API key and the ID of each survey.
- 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 QuestionPro

QuestionPro's steps are in [How to generate API key](https://www.questionpro.com/help/generate-api-key.html).

1. Log in and go to **Surveys » Integration » API**.
2. Click **Get API Key** and copy the key. The key is specific to your account. The same page has **Regenerate** and an **Inactive** toggle.
3. Find your datacenter: open the **My Account** menu in the top-right corner; the datacenter is at the bottom. It sets your API domain, for example `api.questionpro.com` (US), `api.questionpro.eu` (EU) or `api.questionpro.ca` (CA). See [Environments](https://www.questionpro.com/api/environments.html).
4. Send the key in the `api-key` header.

## 2. Pull new responses

```bash
curl -X POST -H "api-key: $QUESTIONPRO_API_KEY" -H "Content-Type: application/json" \
  -d '{"resultMode":2,"startDate":"2026-10-01T00:00:00","endDate":"2026-10-13T00:00:00","timeZone":"GMT+00:00"}' \
  "https://api.questionpro.com/a/api/v2/surveys/SURVEY_ID/responses/filter?page=1&perPage=100"
```

- `POST /a/api/v2/surveys/{survey-id}/responses/filter` filters responses by `startDate` and `endDate` (format `YYYY-MM-DDTHH24:MI:SS`). `resultMode` `2` returns completed responses only.
- **Time zone:** without `timeZone` (GMT format, for example `GMT-07:00`), QuestionPro applies PST/PDT to the dates.
- **Pagination:** `page` and `perPage`. The default is 100 per page and QuestionPro supports up to 1,000. Request the next page until a page comes back with fewer than `perPage` responses.
- Each response holds its questions and answers in `responseSet`, so there is no second call per response.
- **Rate limits (QuestionPro's):** 300 calls per 60 seconds per API key and 2,400 per 60 seconds per organization. Separately, each license has a free API call allowance for the subscription (for example 5,000 calls on Advanced and 20,000 on Research Edition). Once it is used up, calls return `429` until you buy more.

## 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 | QuestionPro | Notes |
|---|---|---|
| `text` | `responseSet[].questionText` (outgoing), `answerValues[].value.text` (incoming) | |
| `userId` | `externalReference` if it is your own internal customer id, otherwise `responseID` | Never an email address. |
| `incoming` | | `false` for the question, `true` for the answer. |
| `sessionId` | `responseID` | A number: send it as a string. |
| `conversationId` | `responseID` | |
| `dimensionlabs_timestamp` | `timestamp` | Looks like `05 Oct, 2020 02:51:51 PM PDT`. Node's `Date` parses it; pass the `Date` to `toMillis`. Add 1,000 ms per message. |
| `platformJson` | `value.scale` and `answerText` of scale questions (for example NPS), `surveyID`, `responseStatus`, `language`, `customVariables`, `location.countryCode` | Custom variables are a good place for account IDs to join on. |

**Personal data.** Contact information questions put names, emails and phone numbers in `value.text`, like any open-text answer. The code skips questions whose text asks for a name, email, phone or address. To choose exactly which questions to send, list their `questionCode` values in `QUESTIONPRO_QUESTION_CODES`. Redact open-text answers too. See [PII redaction](/data/pii-redaction).

## 4. Send it

```js
// questionpro.mjs. Environment: QUESTIONPRO_API_KEY, QUESTIONPRO_SURVEY_IDS (comma-separated),
// QUESTIONPRO_HOST (default api.questionpro.com), QUESTIONPRO_QUESTION_CODES (optional), DL_API_KEY.


const HOUR = 60 * 60 * 1000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const API = `https://${process.env.QUESTIONPRO_HOST ?? 'api.questionpro.com'}/a/api/v2`;
const stamp = (ms) => new Date(ms).toISOString().slice(0, 19); // UTC, YYYY-MM-DDTHH:MM:SS
const ONLY = new Set((process.env.QUESTIONPRO_QUESTION_CODES ?? '').split(',').map((x) => x.trim()).filter(Boolean));
const CONTACT = /\b(name|e-?mail|phone|mobile|address)\b/i; // contact information questions

// Retries QuestionPro's rate limits (429) and server errors (5xx), honoring Retry-After when it is sent.
async function post(url, body) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, { method: 'POST', body, headers: { 'api-key': process.env.QUESTIONPRO_API_KEY, 'Content-Type': 'application/json' } });
    if (res.ok) return res.json();
    if ((res.status !== 429 && res.status < 500) || attempt === 3) throw new Error(`QuestionPro returned ${res.status}: ${await res.text()}`);
    await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000);
  }
}

// Sends every completed response from `since` (Unix ms) up to now, for each survey. Returns the new `since`.
export async function syncQuestionPro({ since, historical = false }) {
  const upTo = Date.now();
  const body = JSON.stringify({ resultMode: 2, startDate: stamp(since), endDate: stamp(upTo), timeZone: 'GMT+00:00' });
  for (const surveyId of process.env.QUESTIONPRO_SURVEY_IDS.split(',').map((x) => x.trim()).filter(Boolean)) {
    for (let page = 1; ; page++) {
      const responses = (await post(`${API}/surveys/${surveyId}/responses/filter?page=${page}&perPage=100`, body)).response ?? [];
      for (const r of responses) {
        const id = String(r.responseID);
        const scores = {};
        for (const q of r.responseSet ?? []) {
          const scale = q.answerValues.find((a) => a.value?.scale)?.value.scale;
          if (scale) scores[q.questionCode] = Number(scale);
        }
        const base = { userId: r.externalReference || id, sessionId: id, conversationId: id,
          platformJson: { surveyId: r.surveyID, status: r.responseStatus, language: r.language, customVariables: r.customVariables, scores } };
        const submitted = toMillis(new Date(r.timestamp));
        const old = historical || submitted < Date.now() - HOUR;
        let t = submitted;
        for (const q of r.responseSet ?? []) {
          if (ONLY.size ? !ONLY.has(q.questionCode) : CONTACT.test(q.questionText)) continue;
          const texts = q.answerValues.map((a) => a.value?.text?.trim()).filter(Boolean);
          if (!texts.length) continue;
          await sendToDimensionLabs({ ...base, text: q.questionText, incoming: false, dimensionlabs_timestamp: t }, { historical: old });
          await sendToDimensionLabs({ ...base, text: texts.join('\n'), incoming: true, dimensionlabs_timestamp: t + 1000 }, { historical: old });
          t += 2000;
        }
      }
      if (responses.length < 100) break;
    }
  }
  return upTo;
}
```

One Dimension Labs source can hold all your surveys: the survey ID goes in `platformJson.surveyId`.

## 5. Run it

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

- **Check your call allowance first.** A 15-minute schedule makes at least 96 calls a day per survey. If that is too many for your license, run it less often, for example hourly.
- **History first:** `node run.mjs 2025-01-01` sends every completed response since that date to `/trackhistorical`. Then start a [backfill](/dimensions/backfill) from the **Backfill** tab.
- **Then on the schedule:** the 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.

If you export responses from QuestionPro as a file instead, [upload it](/data/upload-a-file) and check it first with the [file checker](/data/check-a-file), then run a backfill.

## QuestionPro reference

- [How to generate API key](https://www.questionpro.com/help/generate-api-key.html)
- [Authentication](https://www.questionpro.com/api/authentication.html)
- [Environments](https://www.questionpro.com/api/environments.html)
- [Pagination](https://www.questionpro.com/api/pagination.html)
- [Limits](https://www.questionpro.com/api/limits.html)
- [Get Responses](https://www.questionpro.com/api/get-responses.html)
- [Create Responses Filter](https://www.questionpro.com/api/create-response-filter.html)

Checked against QuestionPro's documentation on 2026-10-13.
