Skip to content

02 Get your data in /Integrations

Alchemer

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

  1. A AlchemerTickets, chats, calls, reviews
  2. ⟳ Your jobPull new · map fields · every 15 min
  3. { } Universal APIPOST /track · /trackhistorical
  4. Dimension LabsRead in full · enriched daily

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 access on your Alchemer account. Alchemer’s request limits page 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.

Alchemer’s steps are in 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.
Terminal window
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.

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 LabsAlchemerNotes
textsurvey_data[].question (outgoing), survey_data[].answer (incoming)For TEXTBOX and ESSAY questions, or the question IDs you list. Titles can hold HTML: strip it.
userIdcontact_idAlchemer’s email campaign contact ID, an internal ID. Responses from a plain link have none: fall back to the sessionId.
incomingfalse for the question, true for the answer.
sessionIdsurvey ID and response idResponse IDs count up within each survey, so prefix both, for example alchemer-123456-42.
conversationIdsurvey ID and response idSame value as sessionId.
dimensionlabs_timestampdate_submittedUS Eastern time. The code converts it to UTC milliseconds. Add 1,000 ms per message.
platformJsonsurvey ID, status, language, the answers to the score questions you listName 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.

// 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.
import { sendToDimensionLabs, toMillis } from './dimensionlabs.mjs';
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.

Save the code as alchemer.mjs in a project set up as in Set up your project, and run it with the 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 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 and check it first with the file checker, then run a backfill.

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