02 Get your data in /Integrations
Alchemer
Send survey responses from Alchemer to Dimension Labs with the universal API.
- A AlchemerTickets, chats, calls, reviews
- ⟳ Your jobPull new · map fields · every 15 min
- { } Universal APIPOST /track · /trackhistorical
- 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.
What you need
Section titled “What you need”- 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.
1. Get API access in Alchemer
Section titled “1. Get API access in Alchemer”Alchemer’s steps are in Authentication.
- 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.
- If you aren’t an admin, your key is under Account > Integrations > API Key. If there is none, ask your admin to create one.
- Copy the API token and API token secret. Alchemer takes both as query parameters,
api_tokenandapi_token_secret, on every request. Because they travel in the URL, keep them out of any logs. - The base URL is
https://api.alchemer.com. Alchemer’s authentication page listshttps://api.alchemer.eufor the EU andhttps://apica.alchemer.comfor Canada; use the one for your account’s region.
2. Pull new responses
Section titled “2. Pull new responses”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}/surveyresponsereturns every response to one survey except deleted ones.- Incremental: filter on
date_submittedwith the>=operator. Alchemer givesdate_submittedin US Eastern time (EST/EDT, GMT -5 or -4), not UTC, both in the filter and in the results, for example2018-09-27 10:42:26 EDT. The code converts both ways. - The second filter keeps responses whose
statusisComplete. The other values arePartialandDisqualified. - Pagination:
pagestarts at 1.resultsperpagedefaults to 50 and goes up to 500, which Alchemer says is subject to change. The response hastotal_pages. - Each response has a
survey_dataobject keyed by question ID. Each entry has the questionid,type,question(the title),answerandshown. Alchemer’s examples show text boxes asTEXTBOXand essay questions asESSAY. - 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
Section titled “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.
4. Send it
Section titled “4. Send it”// 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(/ /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
Section titled “5. Run it”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-01sends 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.
Alchemer reference
Section titled “Alchemer reference”- Authentication
- API request limits
- SurveyResponse sub-object (v5) (list, filters, pagination)
- SurveyResponse returned fields (v5)
- SurveyResponse return per question type (v5)
Checked against Alchemer’s documentation on 2026-10-26.