Skip to content

02 Get your data in /Integrations

Google Forms

Send form responses from Google Forms to Dimension Labs with the universal API.

  1. GF Google FormsTickets, 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 Google Forms responses: each text question and the respondent’s answer, one conversation per response, with scale, rating and quiz scores alongside. You run it on your side: a small script pulls from the Google Forms API and posts each message to the universal API.

  • A Google Cloud project with the Google Forms API enabled.
  • An OAuth 2.0 client ID and secret, and a Google account that can open the forms, to authorize once with the https://www.googleapis.com/auth/forms.body.readonly and https://www.googleapis.com/auth/forms.responses.readonly scopes and get a refresh token.
  • The ID of each form.
  • A Dimension Labs API key for a new Universal API source. See Your API key.

Google’s steps are in the Forms API quickstart and Configure the OAuth consent screen.

  1. In the Google Cloud console, create or pick a project and enable the Google Forms API.
  2. Set up the consent screen under Menu > Google Auth platform > Branding. Choose Internal under Audience if the forms belong to your Google Workspace organization. If you choose External and leave the app in Testing, Google expires refresh tokens after 7 days, so publish it to In production.
  3. Go to Menu > Google Auth platform > Clients and click Create Client. Choose Web application, add https://developers.google.com/oauthplayground as an authorized redirect URI, and copy the client ID and client secret.
  4. Get a refresh token once: open the OAuth 2.0 Playground, click the settings icon, select Use your own OAuth credentials and enter your client ID and secret. Authorize the two scopes above with an account that can open the forms, click Exchange authorization code for tokens, and copy the refresh token. Keep it in a secrets manager.
  5. The code below uses google-auth-library to turn the refresh token into a fresh access token whenever it needs one, and sends it as Authorization: Bearer ACCESS_TOKEN.
  6. The form ID is in the form’s URL: in https://docs.google.com/forms/d/FORM_ID/edit it is FORM_ID.
Terminal window
curl -G -H "Authorization: Bearer $ACCESS_TOKEN" \
--data-urlencode "filter=timestamp >= 2026-10-01T00:00:00Z" \
"https://forms.googleapis.com/v1/forms/FORM_ID/responses"
  • GET /v1/forms/{formId}/responses lists the responses to one form. There is no call that lists every form, so list the form IDs yourself.
  • Incremental: filter supports only timestamp > N and timestamp >= N, which return responses submitted after (or at) that time. N is RFC 3339 UTC, for example 2014-10-02T15:01:23Z.
  • Pagination: pageSize defaults to a maximum of 5,000. Pass nextPageToken as pageToken for the next page, with the same filter as the first request.
  • Answers are keyed by question ID. The question titles come from GET /v1/forms/{formId}: each entry in items has a title, and a questionItem.question with its questionId. A textQuestion is a short answer or, with paragraph: true, a paragraph question.
  • Rate limits (Google’s): listing responses counts as an expensive read: 450 per minute per project and 180 per minute per user per project. Over the limit, Google returns 429.

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

Dimension LabsGoogle FormsNotes
textitem title (outgoing), answers[questionId].textAnswers.answers[].value (incoming)Only for textQuestion items.
userIdresponseIdGoogle returns no respondent ID, so use the response ID. Never respondentEmail.
incomingfalse for the question, true for the answer.
sessionIdresponseIdPrefix it, for example google-forms-ACYDBNj....
conversationIdresponseIdSame value as sessionId.
dimensionlabs_timestamplastSubmittedTimeRFC 3339. Convert with toMillis. Add 1,000 ms per message.
platformJsonform ID, totalScore (quizzes), the answers to scale and rating questionsScale and rating answers come back as text; the code turns them into numbers.

Personal data. respondentEmail is filled in when the form collects email addresses: the code doesn’t send it, so keep it that way. Text answers can hold names, emails and phone numbers too: redact them before you send. See PII redaction.

Install the auth library in your project first: npm install google-auth-library.

// google-forms.mjs. Environment: GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REFRESH_TOKEN,
// GOOGLE_FORM_IDS (comma-separated), DL_API_KEY.
import { sendToDimensionLabs, toMillis } from './dimensionlabs.mjs';
import { OAuth2Client } from 'google-auth-library';
const HOUR = 60 * 60 * 1000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const API = 'https://forms.googleapis.com/v1/forms';
const oauth = new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET);
oauth.setCredentials({ refresh_token: process.env.GOOGLE_REFRESH_TOKEN });
// Uses a fresh access token on every call (the library refreshes it when it expires),
// and retries Google's rate limits (429) and server errors (5xx) with a growing pause.
async function get(url) {
for (let attempt = 0; ; attempt++) {
const { token } = await oauth.getAccessToken();
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (res.ok) return res.json();
if ((res.status !== 429 && res.status < 500) || attempt === 5) throw new Error(`Google returned ${res.status}: ${await res.text()}`);
await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000);
}
}
// The form's text questions, in form order, and the IDs of its scale and rating questions.
async function readForm(formId) {
const textQuestions = [], scoreIds = new Set();
for (const item of (await get(`${API}/${formId}`)).items ?? []) {
const q = item.questionItem?.question;
if (!q) continue;
if (q.textQuestion) textQuestions.push({ id: q.questionId, title: item.title });
if (q.scaleQuestion || q.ratingQuestion) scoreIds.add(q.questionId);
}
return { textQuestions, scoreIds };
}
async function syncForm(formId, since, historical) {
const { textQuestions, scoreIds } = await readForm(formId);
const filter = `timestamp >= ${new Date(since).toISOString().replace(/\.\d{3}Z$/, 'Z')}`;
let newest = since, pageToken = '';
do {
const q = new URLSearchParams({ filter, ...(pageToken && { pageToken }) });
const page = await get(`${API}/${formId}/responses?${q}`);
for (const r of page.responses ?? []) {
const submitted = toMillis(r.lastSubmittedTime ?? r.createTime);
newest = Math.max(newest, submitted);
const text = (qid) => (r.answers?.[qid]?.textAnswers?.answers ?? []).map((a) => a.value).join(', ').trim();
const scores = Object.fromEntries([...scoreIds].filter((qid) => text(qid)).map((qid) => [qid, Number(text(qid))]));
const id = `google-forms-${r.responseId}`.replace(/:/g, '-');
const base = { userId: id, sessionId: id, conversationId: id, platformJson: { formId, totalScore: r.totalScore, scores } };
const old = historical || submitted < Date.now() - HOUR;
let t = submitted;
for (const question of textQuestions) {
const answer = text(question.id);
if (!answer) continue;
await sendToDimensionLabs({ ...base, text: question.title, incoming: false, dimensionlabs_timestamp: t }, { historical: old });
await sendToDimensionLabs({ ...base, text: answer, incoming: true, dimensionlabs_timestamp: t + 1000 }, { historical: old });
t += 2000;
}
}
pageToken = page.nextPageToken ?? '';
} while (pageToken);
return newest;
}
// Sends every response submitted at or after `since` (Unix ms), for each form. Returns the new `since`.
export async function syncGoogleForms({ since, historical = false }) {
let newest = since;
for (const formId of process.env.GOOGLE_FORM_IDS.split(',').map((x) => x.trim()).filter(Boolean)) {
newest = Math.max(newest, await syncForm(formId, since, historical));
}
return newest;
}

Questions inside a grid (questionGroupItem) are choice rows, not free text, so the code leaves them out. One Dimension Labs source can hold all your forms: the form ID goes in platformJson.formId.

Save the code as google-forms.mjs in a project set up as in Set up your project, and run it with the scheduled sync runner: import { syncGoogleForms as sync } from './google-forms.mjs';.

  • History first: node run.mjs 2025-01-01 sends every 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. Each run reads each form once and lists its responses, so it stays far below Google’s limits.

If you have responses in a file instead, upload it and check it first with the file checker, then run a backfill.

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