# Alchemer

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

This brings in your Alchemer survey responses: each open-text question and the respondent's answer, one conversation per response, with the scores you choose alongside. You run it on your side: a small script pulls from the Alchemer API and posts each message to the [universal API](/api/tracker).

## What you need

- API access on your Alchemer account. Alchemer's [request limits page](https://apihelp.alchemer.com/help/api-request-limits) says API access is available only to enterprise customers, so check with your Alchemer account team first.
- An Alchemer API token and API token secret, 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 Alchemer

Alchemer's steps are in [Authentication](https://apihelp.alchemer.com/help/authentication).

1. If you are an Alchemer admin, go to **Security > API Access** and click **Create an API Key**. Keys belong to one user, so create one for the user the script will act as.
2. If you aren't an admin, your key is under **Account > Integrations > API Key**. If there is none, ask your admin to create one.
3. Copy the **API token** and **API token secret**. Alchemer takes both as query parameters, `api_token` and `api_token_secret`, on every request. Because they travel in the URL, keep them out of any logs.
4. The base URL is `https://api.alchemer.com`. Alchemer's authentication page lists `https://api.alchemer.eu` for the EU and `https://apica.alchemer.com` for Canada; use the one for your account's region.

## 2. Pull new responses

```bash
curl -G "https://api.alchemer.com/v5/survey/SURVEY_ID/surveyresponse" \
  --data-urlencode "api_token=$ALCHEMER_API_TOKEN" \
  --data-urlencode "api_token_secret=$ALCHEMER_API_TOKEN_SECRET" \
  --data-urlencode "resultsperpage=500" --data-urlencode "page=1" \
  --data-urlencode "filter[field][0]=date_submitted" --data-urlencode "filter[operator][0]=>=" \
  --data-urlencode "filter[value][0]=2026-10-01 00:00:00" \
  --data-urlencode "filter[field][1]=status" --data-urlencode "filter[operator][1]==" \
  --data-urlencode "filter[value][1]=Complete"
```

- `GET /v5/survey/{survey_id}/surveyresponse` returns every response to one survey except deleted ones.
- **Incremental:** filter on `date_submitted` with the `>=` operator. Alchemer gives `date_submitted` in US Eastern time (EST/EDT, GMT -5 or -4), not UTC, both in the filter and in the results, for example `2018-09-27 10:42:26 EDT`. The code converts both ways.
- The second filter keeps responses whose `status` is `Complete`. The other values are `Partial` and `Disqualified`.
- **Pagination:** `page` starts at 1. `resultsperpage` defaults to 50 and goes up to 500, which Alchemer says is subject to change. The response has `total_pages`.
- Each response has a `survey_data` object keyed by question ID. Each entry has the question `id`, `type`, `question` (the title), `answer` and `shown`. Alchemer's examples show text boxes as `TEXTBOX` and essay questions as `ESSAY`.
- **Rate limits (Alchemer's):** 240 requests per minute per account on Professional and Full Access accounts, with lower limits on some legacy plans. Over the limit, Alchemer returns `429`, which can last up to a minute. Alchemer caches identical GET requests for 60 seconds.

## 3. Map the fields

Each open-text question becomes an outgoing message (the question title) followed by an incoming message (the answer), under the response ID. Skip blank answers and questions that weren't shown.

| Dimension Labs | Alchemer | Notes |
|---|---|---|
| `text` | `survey_data[].question` (outgoing), `survey_data[].answer` (incoming) | For `TEXTBOX` and `ESSAY` questions, or the question IDs you list. Titles can hold HTML: strip it. |
| `userId` | `contact_id` | Alchemer's email campaign contact ID, an internal ID. Responses from a plain link have none: fall back to the `sessionId`. |
| `incoming` | | `false` for the question, `true` for the answer. |
| `sessionId` | survey ID and response `id` | Response IDs count up within each survey, so prefix both, for example `alchemer-123456-42`. |
| `conversationId` | survey ID and response `id` | Same value as `sessionId`. |
| `dimensionlabs_timestamp` | `date_submitted` | US Eastern time. The code converts it to UTC milliseconds. Add 1,000 ms per message. |
| `platformJson` | survey ID, `status`, `language`, the answers to the score questions you list | Name NPS, rating or other number questions in `ALCHEMER_SCORE_QUESTION_IDS`. |

**Personal data.** A response also holds `ip_address`, location fields and any email or name questions: the code sends only the fields it names. Don't list an email, name or phone question as a text or score question. Open-text answers can hold names and emails too: redact them before you send. See [PII redaction](/data/pii-redaction).

## 4. Send it

```js
// alchemer.mjs. Environment: ALCHEMER_API_TOKEN, ALCHEMER_API_TOKEN_SECRET, ALCHEMER_SURVEY_IDS (comma-separated),
// ALCHEMER_API (default https://api.alchemer.com), ALCHEMER_TEXT_QUESTION_IDS and ALCHEMER_SCORE_QUESTION_IDS
// (optional, comma-separated question IDs), DL_API_KEY.


const HOUR = 60 * 60 * 1000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const API = process.env.ALCHEMER_API ?? 'https://api.alchemer.com';
const ids = (v) => new Set((v ?? '').split(',').map((x) => x.trim()).filter(Boolean));
const TEXT_IDS = ids(process.env.ALCHEMER_TEXT_QUESTION_IDS);
const SCORE_IDS = ids(process.env.ALCHEMER_SCORE_QUESTION_IDS);
const strip = (s) => String(s).replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim();

// Unix ms to Alchemer's filter format, in US Eastern time: "YYYY-MM-DD HH:MM:SS".
function eastern(ms) {
  const p = Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', hourCycle: 'h23',
    year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
    .formatToParts(new Date(ms)).map((x) => [x.type, x.value]));
  return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}:${p.second}`;
}

// "2018-09-27 10:42:26 EDT" to Unix ms.
function fromEastern(s) {
  const m = String(s).match(/^(\d{4}-\d\d-\d\d) (\d\d:\d\d:\d\d) (EDT|EST)$/);
  return m ? Date.parse(`${m[1]}T${m[2]}${m[3] === 'EDT' ? '-04:00' : '-05:00'}`) : toMillis(s);
}

// Retries Alchemer's rate limit (429, which can last a minute) 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);
    if (res.ok) return res.json();
    if ((res.status !== 429 && res.status < 500) || attempt === 5) throw new Error(`Alchemer returned ${res.status}: ${await res.text()}`);
    await sleep((Number(res.headers.get('retry-after')) || 5 * 2 ** attempt) * 1000);
  }
}

async function syncSurvey(surveyId, since, historical) {
  let newest = since;
  for (let page = 1, pages = 1; page <= pages; page++) {
    const q = new URLSearchParams({
      api_token: process.env.ALCHEMER_API_TOKEN, api_token_secret: process.env.ALCHEMER_API_TOKEN_SECRET,
      page: String(page), resultsperpage: '500',
      'filter[field][0]': 'date_submitted', 'filter[operator][0]': '>=', 'filter[value][0]': eastern(since),
      'filter[field][1]': 'status', 'filter[operator][1]': '=', 'filter[value][1]': 'Complete',
    });
    const body = await get(`${API}/v5/survey/${surveyId}/surveyresponse?${q}`);
    pages = Number(body.total_pages) || 1;
    for (const r of body.data ?? []) {
      if (r.is_test_data === true || r.is_test_data === '1') continue;
      const submitted = fromEastern(r.date_submitted);
      newest = Math.max(newest, submitted);
      const answers = Object.values(r.survey_data ?? {}).filter((a) => a.shown !== false);
      const scores = Object.fromEntries(answers.filter((a) => SCORE_IDS.has(String(a.id)) && a.answer !== undefined && a.answer !== '')
        .map((a) => [a.id, Number(a.answer)]));
      const sessionId = `alchemer-${surveyId}-${r.id}`;
      const hasContact = r.contact_id && r.contact_id !== '0';
      const base = { userId: hasContact ? `alchemer-contact-${r.contact_id}` : sessionId, sessionId, conversationId: sessionId,
        platformJson: { surveyId, status: r.status, language: r.language, scores } };
      const old = historical || submitted < Date.now() - HOUR;
      let t = submitted;
      for (const a of answers) {
        const isText = TEXT_IDS.size ? TEXT_IDS.has(String(a.id)) : ['TEXTBOX', 'ESSAY'].includes(String(a.type).toUpperCase());
        if (!isText || typeof a.answer !== 'string' || !a.answer.trim()) continue;
        await sendToDimensionLabs({ ...base, text: strip(a.question), incoming: false, dimensionlabs_timestamp: t }, { historical: old });
        await sendToDimensionLabs({ ...base, text: a.answer.trim(), incoming: true, dimensionlabs_timestamp: t + 1000 }, { historical: old });
        t += 2000;
      }
    }
  }
  return newest;
}

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

Before you go live, open one response of your own and check the `type` of your open-text questions in `survey_data`. If they aren't `TEXTBOX` or `ESSAY`, list their IDs in `ALCHEMER_TEXT_QUESTION_IDS`, and the code uses only those. One Dimension Labs source can hold all your surveys: the survey ID goes in `platformJson.surveyId`.

## 5. Run it

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

- **History first:** `node run.mjs 2025-01-01` sends every complete 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. A run 15 minutes later never repeats an identical request, so Alchemer's 60-second cache doesn't hide new responses.

Each run costs one request per page of 500 responses per survey, well inside 240 a minute. Alchemer warns that routinely hitting the limit can get API access disabled, so don't run many syncs at once on one account. If your plan doesn't include the API, export the responses from Alchemer as a file, [upload it](/data/upload-a-file) and check it first with the [file checker](/data/check-a-file), then run a backfill.

## Alchemer reference

- [Authentication](https://apihelp.alchemer.com/help/authentication)
- [API request limits](https://apihelp.alchemer.com/help/api-request-limits)
- [SurveyResponse sub-object (v5)](https://apihelp.alchemer.com/help/surveyresponse-sub-object-v5) (list, filters, pagination)
- [SurveyResponse returned fields (v5)](https://apihelp.alchemer.com/help/surveyresponse-returned-fields-v5)
- [SurveyResponse return per question type (v5)](https://apihelp.alchemer.com/help/surveyresponse-per-question-v5)

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