# SurveyMonkey

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

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

## What you need

- A paid SurveyMonkey plan. SurveyMonkey says a Basic plan account gets error `1005` after 25 responses.
- A SurveyMonkey app (a Private app for your own account) and its access token, with the scope to view response details.
- 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 SurveyMonkey

SurveyMonkey's steps are in the [API v3 documentation](https://api.surveymonkey.com/v3/docs).

1. Register an app in the [SurveyMonkey developer portal](https://developer.surveymonkey.com/apps/). Registering creates a draft app with an access token that works against your own account for 90 days.
2. Before the 90 days end, deploy it as a **Private app**. All users of a Private app must be on the same SurveyMonkey team with a paid plan that includes API access.
3. Open the app's **Settings** in the **My Apps** tab and copy the access token. If you will read other people's accounts, use SurveyMonkey's OAuth 2.0 flow instead.
4. Send the token as `Authorization: Bearer YOUR_ACCESS_TOKEN`. The bulk responses endpoint needs the View Response Details scope (`responses_read_detail`).

## 2. Pull new responses

```bash
curl -H "Authorization: Bearer $SURVEYMONKEY_TOKEN" \
  "https://api.surveymonkey.com/v3/surveys/SURVEY_ID/responses/bulk?per_page=100&sort_by=date_modified&sort_order=ASC&start_modified_at=2026-10-01T00:00:00"
```

- `GET /v3/surveys/{id}/responses/bulk` returns full responses with every answer. `per_page` goes up to 100 (default 50).
- **Incremental:** `start_modified_at` returns responses modified after that date. Sort with `sort_by=date_modified` and `sort_order=ASC`. Add `status=completed` to skip partial responses.
- **Pagination:** request `links.next` until it is missing.
- Answers carry question and choice IDs. Get the question text from `GET /v3/surveys/{id}/details`: each page's `questions` has `id`, `family` (for example `open_ended`) and `headings[].heading`. SurveyMonkey suggests caching it.
- **Rate limits (SurveyMonkey's):** draft and private apps start at 120 requests per minute and 500 requests per day, reset at midnight GMT. Your current limits are in the `X-Ratelimit-App-Global-*` response headers.

## 3. Map the fields

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

| Dimension Labs | SurveyMonkey | Notes |
|---|---|---|
| `text` | `headings[0].heading` from survey details (outgoing), `answers[].text` (incoming) | `text` holds open-ended answers and "other" text. |
| `userId` | `recipient_id` | SurveyMonkey's internal id, only set for email collectors. Otherwise use the response `id`. |
| `incoming` | | `false` for the question, `true` for the answer. |
| `sessionId` | `id` (the response ID) | |
| `conversationId` | `id` | |
| `dimensionlabs_timestamp` | `date_created` | SurveyMonkey dates are UTC, sometimes without an offset; `toMillis` reads those as UTC. Add 1,000 ms per message. |
| `platformJson` | `choice_id` and `row_id` of choice questions (for example an NPS question), `collector_id`, `response_status`, `custom_variables`, `language` | Look up choice IDs in survey details to get the label. `custom_variables` is a good place for account IDs to join on. |

**Personal data.** Don't put emails or names in `custom_variables`, and leave out the response's `ip_address` and any contact fields. Open-ended answers can hold them too: redact them before you send. See [PII redaction](/data/pii-redaction).

## 4. Send it

```js
// surveymonkey.mjs. Environment: SURVEYMONKEY_TOKEN, SURVEYMONKEY_SURVEY_IDS (comma-separated), DL_API_KEY.


const HOUR = 60 * 60 * 1000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const API = 'https://api.surveymonkey.com/v3';

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

// Sends every completed response modified at or after `since` (Unix ms), for each survey. Returns the new `since`.
export async function syncSurveyMonkey({ since, historical = false }) {
  let newest = since;
  for (const surveyId of process.env.SURVEYMONKEY_SURVEY_IDS.split(',').map((x) => x.trim()).filter(Boolean)) {
    const details = await get(`${API}/surveys/${surveyId}/details`);
    const questions = Object.fromEntries(details.pages.flatMap((p) => p.questions).map((q) => [q.id, q]));
    let next = `${API}/surveys/${surveyId}/responses/bulk?per_page=100&status=completed&sort_by=date_modified&sort_order=ASC&start_modified_at=${new Date(since).toISOString().slice(0, 19)}`;
    while (next) {
      const page = await get(next);
      for (const r of page.data) {
        const answered = r.pages.flatMap((p) => p.questions);
        const choices = Object.fromEntries(answered.map((q) => [q.id, q.answers.filter((a) => a.choice_id).map((a) => a.choice_id)]).filter(([, c]) => c.length));
        const base = { userId: String(r.recipient_id || r.id), sessionId: String(r.id), conversationId: String(r.id),
          platformJson: { surveyId, collectorId: r.collector_id, status: r.response_status, language: r.language, customVariables: r.custom_variables, choices } };
        const created = toMillis(r.date_created);
        const old = historical || created < Date.now() - HOUR;
        let t = created;
        for (const q of answered) {
          const texts = q.answers.map((a) => a.text?.trim()).filter(Boolean);
          if (!texts.length) continue;
          const heading = questions[q.id]?.headings?.[0]?.heading ?? `Question ${q.id}`;
          await sendToDimensionLabs({ ...base, text: heading, incoming: false, dimensionlabs_timestamp: t }, { historical: old });
          await sendToDimensionLabs({ ...base, text: texts.join('\n'), incoming: true, dimensionlabs_timestamp: t + 1000 }, { historical: old });
          t += 2000;
        }
        newest = Math.max(newest, toMillis(r.date_modified));
      }
      next = page.links?.next ?? null;
    }
  }
  return newest;
}
```

A response that is edited later comes back with a new `date_modified` and is sent again under the same `sessionId`. The parts that didn't change are duplicates, and are removed automatically. One Dimension Labs source can hold all your surveys: the survey ID goes in `platformJson.surveyId`.

## 5. Run it

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

- **Check the daily limit first.** Each run uses at least two requests per survey, and draft and private apps start at 500 requests a day. A 15-minute schedule is 96 runs a day, so with more than two surveys, 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 SurveyMonkey 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.

## SurveyMonkey reference

- [SurveyMonkey API v3 documentation](https://api.surveymonkey.com/v3/docs) (registering and deploying apps, request limits, authentication, pagination, survey details, and the bulk responses endpoint)
- [SurveyMonkey developer apps](https://developer.surveymonkey.com/apps/) (sign-in required)

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