# Survicate

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

This brings in your Survicate survey responses: each open-text question and the respondent's answer, plus the comments respondents add to a choice, one conversation per response, with ratings and NPS scores alongside. You run it on your side: a small script pulls from the Survicate Data Export API and posts each message to the [universal API](/api/tracker).

## What you need

- A Survicate plan that includes API access. Survicate says the Data Export API is available on some plans only, and requests from other accounts return an error. Check [Survicate's pricing](https://survicate.com/pricing) or ask Survicate.
- A Survicate organization owner or workspace admin, to copy the API key.
- 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 Survicate

Survicate's steps are in [Setup](https://developers.survicate.com/data-export/setup/).

1. In the Survicate panel, go to **Settings > Organization > Access Keys**. Only organization owners and workspace admins can open that page.
2. Copy the API key.
3. Send it as `Authorization: Basic YOUR_API_KEY`. This is the raw key after `Basic`, not a base64 user name and password.
4. The base URL is `https://data-api.survicate.com/v2`. Use v2: Survicate's migration guide gives September 15, 2026 as the date the v1 Data Export API is turned off ([migrating from v1 to v2](https://developers.survicate.com/data-export/migration-v1-v2/)).
5. `GET /surveys` lists your surveys with their `id`, if you don't have the IDs to hand.

## 2. Pull new responses

```bash
curl -H "Authorization: Basic $SURVICATE_API_KEY" \
  "https://data-api.survicate.com/v2/surveys/SURVEY_ID/responses?start=2026-10-26T00:00:00.000000Z&end=2026-10-01T00:00:00.000000Z&items_per_page=100"
```

- `GET /surveys/{survey_id}/responses` returns responses newest first.
- **Incremental, and reversed:** `start` is the **newer** bound and `end` the **older** one, so `start` must be later than `end`. Both are inclusive, in ISO 8601 with microseconds, for example `2024-08-31T23:59:59.000000Z`. Put your cursor in `end`.
- **Pagination:** `items_per_page` takes 1 to 100. Each page has `pagination_data.has_more` and `next_url`, a path relative to `https://data-api.survicate.com/v2`. Prepend the base URL, request it as it is, and stop when `has_more` is `false`.
- Each response has a `uuid`, `collected_at`, `language`, `respondent.uuid` and an `answers` array. Skipped questions aren't in it. A text question's `answer` is a string. Choice answers can carry a `comment`, ratings a `rating`, and NPS answers a `content` with a `tag` (`detractor`, `passive` or `promoter`).
- The question text comes from `GET /surveys/{survey_id}/questions`: each entry has an `id`, a `type` (for example `text`, `nps`, `rating` or `form`) and the `question` as shown to the respondent.
- **Attributes** you passed to Survicate, such as your own customer ID, come back only when you name them: add `attributes[]=customer_id` once per name, up to 100.
- **Rate limits (Survicate's):** 5 requests at the same time, and 1,000 requests per minute per workspace. Over either, Survicate returns `429`.

## 3. Map the fields

Each open-text answer, and each comment on a choice, becomes an outgoing message (the question) followed by an incoming message (the text), under the response UUID. Skip blank answers.

| Dimension Labs | Survicate | Notes |
|---|---|---|
| `text` | `question` from the questions list (outgoing); `answer` for `text` questions, or `answer.comment` and `answers[].comment` (incoming) | Contact form (`form`) and `date` answers aren't sent. |
| `userId` | `respondent.uuid` | Survicate's own respondent ID. Fall back to the `sessionId`. |
| `incoming` | | `false` for the question, `true` for the answer. |
| `sessionId` | `uuid` | Prefix it, for example `survicate-15263384-6a46-...`. |
| `conversationId` | `uuid` | Same value as `sessionId`. |
| `dimensionlabs_timestamp` | `collected_at` | ISO 8601 UTC. Convert with `toMillis`. Add 1,000 ms per message. |
| `platformJson` | survey ID, `language`, `device_type`, `answer.rating`, NPS `content` and `tag`, the attributes you request | Attributes such as an account or order ID are the place to join on. |

**Personal data.** Contact form answers (`fields`) hold names, emails and phone numbers: the code skips them. Only request attributes that aren't personal data: never `email` or a name. Open-text answers can hold personal data too: redact them before you send. See [PII redaction](/data/pii-redaction).

## 4. Send it

```js
// survicate.mjs. Environment: SURVICATE_API_KEY, SURVICATE_SURVEY_IDS (comma-separated),
// SURVICATE_ATTRIBUTES (optional, comma-separated attribute names, for example customer_id), DL_API_KEY.


const HOUR = 60 * 60 * 1000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const API = 'https://data-api.survicate.com/v2';
const list = (v) => (v ?? '').split(',').map((x) => x.trim()).filter(Boolean);
const micro = (ms) => new Date(ms).toISOString().replace(/\.(\d{3})Z$/, '.$1000Z'); // ISO 8601 with microseconds
const strip = (s) => String(s ?? '').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim();

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

// Follows next_url one page at a time, as Survicate asks.
async function* pages(path) {
  for (let next = path; next; ) {
    const page = await get(next);
    yield page.data ?? [];
    next = page.pagination_data?.has_more ? page.pagination_data.next_url : null;
  }
}

async function syncSurvey(surveyId, since, historical) {
  const questions = {};
  for await (const items of pages(`/surveys/${surveyId}/questions?items_per_page=100`)) {
    for (const q of items) questions[q.id] = q;
  }
  const q = new URLSearchParams({ start: micro(Date.now()), end: micro(since), items_per_page: '100' });
  for (const name of list(process.env.SURVICATE_ATTRIBUTES)) q.append('attributes[]', name);
  let newest = since;
  for await (const responses of pages(`/surveys/${surveyId}/responses?${q}`)) {
    for (const r of responses) {
      const collected = toMillis(r.collected_at);
      newest = Math.max(newest, collected);
      const attributes = Object.fromEntries([...(r.respondent?.attributes ?? []), ...(r.attributes ?? [])].map((a) => [a.name, a.value]));
      const scores = {};
      const texts = [];
      for (const a of r.answers ?? []) {
        const question = questions[a.question_id];
        if (typeof a.answer?.rating === 'number') scores[a.question_id] = a.answer.rating;
        if (a.answer?.tag) scores[a.question_id] = { score: Number(a.answer.content), tag: a.answer.tag };
        const comments = [a.answer?.comment, ...(a.answers ?? []).map((x) => x.comment)];
        const text = typeof a.answer === 'string' && question?.type === 'text' ? a.answer : comments.filter((c) => c?.trim()).join('\n');
        if (text?.trim()) texts.push({ question: strip(question?.question) || `Question ${a.question_id}`, text: text.trim() });
      }
      const sessionId = `survicate-${r.uuid}`;
      const base = { userId: r.respondent?.uuid ? `survicate-${r.respondent.uuid}` : sessionId, sessionId, conversationId: sessionId,
        platformJson: { surveyId, language: r.language, device_type: r.device_type, scores, attributes } };
      const old = historical || collected < Date.now() - HOUR;
      let t = collected;
      for (const { question, text } of texts) {
        await sendToDimensionLabs({ ...base, text: question, incoming: false, dimensionlabs_timestamp: t }, { historical: old });
        await sendToDimensionLabs({ ...base, text, incoming: true, dimensionlabs_timestamp: t + 1000 }, { historical: old });
        t += 2000;
      }
    }
  }
  return newest;
}

// Sends every response collected at or after `since` (Unix ms), for each survey. Returns the new `since`.
export async function syncSurvicate({ since, historical = false }) {
  let newest = since;
  for (const surveyId of list(process.env.SURVICATE_SURVEY_IDS)) {
    newest = Math.max(newest, await syncSurvey(surveyId, since, historical));
  }
  return newest;
}
```

The code fetches one page at a time, so it never comes near Survicate's limit of 5 requests at once. One Dimension Labs source can hold all your surveys: the survey ID goes in `platformJson.surveyId`.

## 5. Run it

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

- **History first:** `node run.mjs 2025-01-01` 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. Because `end` is inclusive, the last response of one run comes back in the next; it is removed as a duplicate.

If your plan has no API access, export instead: open the survey's **Analyze** tab, click **Export**, choose CSV, and pick all responses or only the filtered ones. Survicate emails the link, and past exports are under **Settings > Exports**. Export dates are in UTC. [Upload the file](/data/upload-a-file), check it first with the [file checker](/data/check-a-file), then run a backfill. File export is also available on some plans only.

## Survicate reference

- [Data Export API setup](https://developers.survicate.com/data-export/setup/) (authentication, pagination, attributes, rate limits)
- [Survey](https://developers.survicate.com/data-export/survey/) (surveys and questions)
- [Response](https://developers.survicate.com/data-export/response/)
- [Respondent](https://developers.survicate.com/data-export/respondent/)
- [Migrating from v1 to v2](https://developers.survicate.com/data-export/migration-v1-v2/)
- [Data Export API (help center)](https://help.survicate.com/en/articles/5849901-data-export-api)
- [Data export: CSV and XLSX reports](https://help.survicate.com/en/articles/3942566-data-export-csv-and-xlsx-reports)

Checked against Survicate's documentation on 2026-10-26.
